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:
-
Log in to your Host UK account
+
Log in to your account
Navigate to Settings → API Keys
Click Create API Key
Enter a descriptive name (e.g., "Production", "Development")
- 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 --}}
-
-
Create a Biolink
-
- Create a new biolink page with a POST request:
-
- 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.
- 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.
- 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.
+
+
-
@@ -82,13 +100,23 @@
To configure webhooks:
-
-
Go to Settings → Webhooks in your workspace
+
+
Go to Settings → Webhooks in your workspace
Click Add Webhook
-
Enter your endpoint URL (must be HTTPS)
+
Enter your endpoint URL (must be HTTPS in production)
Select the events you want to receive
-
Save and note your webhook secret
+
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:
+ Every webhook request includes the following headers:
+
+
+
+
+
+
+
Header
+
Description
+
+
+
+
+
X-Webhook-Signature
+
HMAC-SHA256 signature for verification
+
+
+
X-Webhook-Timestamp
+
Unix timestamp when the webhook was sent
+
+
+
X-Webhook-Event
+
The event type (e.g., bio.created)
+
+
+
X-Webhook-Id
+
Unique delivery ID for idempotency
+
+
+
Content-Type
+
Always 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
+
+
Extract X-Webhook-Signature and X-Webhook-Timestamp headers
+ If your endpoint returns a non-2xx status code or times out, we'll retry with exponential backoff:
+
+
+
+
+
+
+
Attempt
+
Delay
+
+
+
+
+
1 (initial)
+
Immediate
+
+
+
2
+
1 minute
+
+
+
3
+
5 minutes
+
+
+
4
+
30 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
+