From 931974645bd926d530539f6ee3051c31ca9f275a Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 26 Jan 2026 20:57:08 +0000 Subject: [PATCH] monorepo sepration --- README.md | 239 ++--- TODO.md | 246 ++++++ changelog/2026/jan/features.md | 122 +++ composer.json | 70 +- src/Mod/Api/Boot.php | 98 +++ src/Mod/Api/Concerns/HasApiResponses.php | 92 ++ src/Mod/Api/Concerns/HasApiTokens.php | 76 ++ src/Mod/Api/Concerns/ResolvesWorkspace.php | 84 ++ .../Console/Commands/CheckApiUsageAlerts.php | 291 +++++++ .../Commands/CleanupExpiredGracePeriods.php | 67 ++ src/Mod/Api/Controllers/McpApiController.php | 625 +++++++++++++ .../Api/Database/Factories/ApiKeyFactory.php | 253 ++++++ .../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 ++ src/Mod/Api/Documentation/ModuleDiscovery.php | 209 +++++ src/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/Guards/AccessTokenGuard.php | 98 +++ src/Mod/Api/Jobs/DeliverWebhookJob.php | 182 ++++ src/Mod/Api/Middleware/AuthenticateApiKey.php | 125 +++ src/Mod/Api/Middleware/CheckApiScope.php | 52 ++ src/Mod/Api/Middleware/EnforceApiScope.php | 65 ++ src/Mod/Api/Middleware/PublicApiCors.php | 64 ++ src/Mod/Api/Middleware/RateLimitApi.php | 352 ++++++++ src/Mod/Api/Middleware/TrackApiUsage.php | 81 ++ ...026_01_07_002358_create_api_keys_table.php | 41 + ..._002400_create_webhook_endpoints_table.php | 40 + ...002401_create_webhook_deliveries_table.php | 40 + ...0_add_secure_hashing_to_api_keys_table.php | 46 + src/Mod/Api/Models/ApiKey.php | 412 +++++++++ src/Mod/Api/Models/ApiUsage.php | 135 +++ src/Mod/Api/Models/ApiUsageDaily.php | 172 ++++ src/Mod/Api/Models/WebhookDelivery.php | 209 +++++ src/Mod/Api/Models/WebhookEndpoint.php | 266 ++++++ .../HighApiUsageNotification.php | 111 +++ src/Mod/Api/RateLimit/RateLimit.php | 42 + src/Mod/Api/RateLimit/RateLimitResult.php | 71 ++ src/Mod/Api/RateLimit/RateLimitService.php | 247 ++++++ src/Mod/Api/Resources/ApiKeyResource.php | 59 ++ src/Mod/Api/Resources/ErrorResource.php | 93 ++ src/Mod/Api/Resources/PaginatedCollection.php | 49 ++ .../Api/Resources/WebhookEndpointResource.php | 67 ++ src/Mod/Api/Resources/WorkspaceResource.php | 68 ++ src/Mod/Api/Routes/api.php | 103 +++ src/Mod/Api/Services/ApiKeyService.php | 217 +++++ src/Mod/Api/Services/ApiSnippetService.php | 427 +++++++++ src/Mod/Api/Services/ApiUsageService.php | 361 ++++++++ src/Mod/Api/Services/WebhookService.php | 192 ++++ src/Mod/Api/Services/WebhookSignature.php | 206 +++++ .../Api/Tests/Feature/ApiKeyRotationTest.php | 232 +++++ .../Api/Tests/Feature/ApiKeySecurityTest.php | 381 ++++++++ src/Mod/Api/Tests/Feature/ApiKeyTest.php | 617 +++++++++++++ .../Tests/Feature/ApiScopeEnforcementTest.php | 232 +++++ src/Mod/Api/Tests/Feature/ApiUsageTest.php | 362 ++++++++ .../Feature/OpenApiDocumentationTest.php | 120 +++ src/Mod/Api/Tests/Feature/RateLimitTest.php | 532 ++++++++++++ .../Api/Tests/Feature/WebhookDeliveryTest.php | 770 ++++++++++++++++ src/Mod/Api/config.php | 237 +++++ src/Website/Api/Boot.php | 35 + .../Api/Controllers/DocsController.php | 72 ++ src/Website/Api/Routes/web.php | 34 + src/Website/Api/Services/OpenApiGenerator.php | 348 ++++++++ src/Website/Api/View/Blade/docs.blade.php | 111 +++ .../Blade/guides/authentication.blade.php | 187 ++++ .../Api/View/Blade/guides/errors.blade.php | 211 +++++ .../Api/View/Blade/guides/index.blade.php | 88 ++ .../Api/View/Blade/guides/qrcodes.blade.php | 202 +++++ .../View/Blade/guides/quickstart.blade.php | 193 +++++ .../Api/View/Blade/guides/webhooks.blade.php | 586 +++++++++++++ src/Website/Api/View/Blade/index.blade.php | 136 +++ .../Api/View/Blade/layouts/docs.blade.php | 166 ++++ .../View/Blade/partials/endpoint.blade.php | 37 + src/Website/Api/View/Blade/redoc.blade.php | 73 ++ .../Api/View/Blade/reference.blade.php | 261 ++++++ src/Website/Api/View/Blade/scalar.blade.php | 71 ++ src/Website/Api/View/Blade/swagger.blade.php | 58 ++ 92 files changed, 16220 insertions(+), 173 deletions(-) create mode 100644 TODO.md create mode 100644 changelog/2026/jan/features.md create mode 100644 src/Mod/Api/Boot.php create mode 100644 src/Mod/Api/Concerns/HasApiResponses.php create mode 100644 src/Mod/Api/Concerns/HasApiTokens.php create mode 100644 src/Mod/Api/Concerns/ResolvesWorkspace.php create mode 100644 src/Mod/Api/Console/Commands/CheckApiUsageAlerts.php create mode 100644 src/Mod/Api/Console/Commands/CleanupExpiredGracePeriods.php create mode 100644 src/Mod/Api/Controllers/McpApiController.php create mode 100644 src/Mod/Api/Database/Factories/ApiKeyFactory.php create mode 100644 src/Mod/Api/Documentation/Attributes/ApiHidden.php create mode 100644 src/Mod/Api/Documentation/Attributes/ApiParameter.php create mode 100644 src/Mod/Api/Documentation/Attributes/ApiResponse.php create mode 100644 src/Mod/Api/Documentation/Attributes/ApiSecurity.php create mode 100644 src/Mod/Api/Documentation/Attributes/ApiTag.php create mode 100644 src/Mod/Api/Documentation/DocumentationController.php create mode 100644 src/Mod/Api/Documentation/DocumentationServiceProvider.php create mode 100644 src/Mod/Api/Documentation/Examples/CommonExamples.php create mode 100644 src/Mod/Api/Documentation/Extension.php create mode 100644 src/Mod/Api/Documentation/Extensions/ApiKeyAuthExtension.php create mode 100644 src/Mod/Api/Documentation/Extensions/RateLimitExtension.php create mode 100644 src/Mod/Api/Documentation/Extensions/WorkspaceHeaderExtension.php create mode 100644 src/Mod/Api/Documentation/Middleware/ProtectDocumentation.php create mode 100644 src/Mod/Api/Documentation/ModuleDiscovery.php create mode 100644 src/Mod/Api/Documentation/OpenApiBuilder.php create mode 100644 src/Mod/Api/Documentation/Routes/docs.php create mode 100644 src/Mod/Api/Documentation/Views/redoc.blade.php create mode 100644 src/Mod/Api/Documentation/Views/scalar.blade.php create mode 100644 src/Mod/Api/Documentation/Views/swagger.blade.php create mode 100644 src/Mod/Api/Documentation/config.php create mode 100644 src/Mod/Api/Exceptions/RateLimitExceededException.php create mode 100644 src/Mod/Api/Guards/AccessTokenGuard.php create mode 100644 src/Mod/Api/Jobs/DeliverWebhookJob.php create mode 100644 src/Mod/Api/Middleware/AuthenticateApiKey.php create mode 100644 src/Mod/Api/Middleware/CheckApiScope.php create mode 100644 src/Mod/Api/Middleware/EnforceApiScope.php create mode 100644 src/Mod/Api/Middleware/PublicApiCors.php create mode 100644 src/Mod/Api/Middleware/RateLimitApi.php create mode 100644 src/Mod/Api/Middleware/TrackApiUsage.php create mode 100644 src/Mod/Api/Migrations/2026_01_07_002358_create_api_keys_table.php create mode 100644 src/Mod/Api/Migrations/2026_01_07_002400_create_webhook_endpoints_table.php create mode 100644 src/Mod/Api/Migrations/2026_01_07_002401_create_webhook_deliveries_table.php create mode 100644 src/Mod/Api/Migrations/2026_01_27_000000_add_secure_hashing_to_api_keys_table.php create mode 100644 src/Mod/Api/Models/ApiKey.php create mode 100644 src/Mod/Api/Models/ApiUsage.php create mode 100644 src/Mod/Api/Models/ApiUsageDaily.php create mode 100644 src/Mod/Api/Models/WebhookDelivery.php create mode 100644 src/Mod/Api/Models/WebhookEndpoint.php create mode 100644 src/Mod/Api/Notifications/HighApiUsageNotification.php create mode 100644 src/Mod/Api/RateLimit/RateLimit.php create mode 100644 src/Mod/Api/RateLimit/RateLimitResult.php create mode 100644 src/Mod/Api/RateLimit/RateLimitService.php create mode 100644 src/Mod/Api/Resources/ApiKeyResource.php create mode 100644 src/Mod/Api/Resources/ErrorResource.php create mode 100644 src/Mod/Api/Resources/PaginatedCollection.php create mode 100644 src/Mod/Api/Resources/WebhookEndpointResource.php create mode 100644 src/Mod/Api/Resources/WorkspaceResource.php create mode 100644 src/Mod/Api/Routes/api.php create mode 100644 src/Mod/Api/Services/ApiKeyService.php create mode 100644 src/Mod/Api/Services/ApiSnippetService.php create mode 100644 src/Mod/Api/Services/ApiUsageService.php create mode 100644 src/Mod/Api/Services/WebhookService.php create mode 100644 src/Mod/Api/Services/WebhookSignature.php create mode 100644 src/Mod/Api/Tests/Feature/ApiKeyRotationTest.php create mode 100644 src/Mod/Api/Tests/Feature/ApiKeySecurityTest.php create mode 100644 src/Mod/Api/Tests/Feature/ApiKeyTest.php create mode 100644 src/Mod/Api/Tests/Feature/ApiScopeEnforcementTest.php create mode 100644 src/Mod/Api/Tests/Feature/ApiUsageTest.php create mode 100644 src/Mod/Api/Tests/Feature/OpenApiDocumentationTest.php create mode 100644 src/Mod/Api/Tests/Feature/RateLimitTest.php create mode 100644 src/Mod/Api/Tests/Feature/WebhookDeliveryTest.php create mode 100644 src/Mod/Api/config.php create mode 100644 src/Website/Api/Boot.php create mode 100644 src/Website/Api/Controllers/DocsController.php create mode 100644 src/Website/Api/Routes/web.php create mode 100644 src/Website/Api/Services/OpenApiGenerator.php create mode 100644 src/Website/Api/View/Blade/docs.blade.php create mode 100644 src/Website/Api/View/Blade/guides/authentication.blade.php create mode 100644 src/Website/Api/View/Blade/guides/errors.blade.php create mode 100644 src/Website/Api/View/Blade/guides/index.blade.php create mode 100644 src/Website/Api/View/Blade/guides/qrcodes.blade.php create mode 100644 src/Website/Api/View/Blade/guides/quickstart.blade.php create mode 100644 src/Website/Api/View/Blade/guides/webhooks.blade.php create mode 100644 src/Website/Api/View/Blade/index.blade.php create mode 100644 src/Website/Api/View/Blade/layouts/docs.blade.php create mode 100644 src/Website/Api/View/Blade/partials/endpoint.blade.php create mode 100644 src/Website/Api/View/Blade/redoc.blade.php create mode 100644 src/Website/Api/View/Blade/reference.blade.php create mode 100644 src/Website/Api/View/Blade/scalar.blade.php create mode 100644 src/Website/Api/View/Blade/swagger.blade.php diff --git a/README.md b/README.md index 5db97d9..4fc8ca1 100644 --- a/README.md +++ b/README.md @@ -1,138 +1,155 @@ -# Core PHP Framework Project +# Core API Package -[![CI](https://github.com/host-uk/core-template/actions/workflows/ci.yml/badge.svg)](https://github.com/host-uk/core-template/actions/workflows/ci.yml) -[![codecov](https://codecov.io/gh/host-uk/core-template/graph/badge.svg)](https://codecov.io/gh/host-uk/core-template) -[![PHP Version](https://img.shields.io/packagist/php-v/host-uk/core-template)](https://packagist.org/packages/host-uk/core-template) -[![Laravel](https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel)](https://laravel.com) -[![License](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE) - -A modular monolith Laravel application built with Core PHP Framework. - -## Features - -- **Core Framework** - Event-driven module system with lazy loading -- **Admin Panel** - Livewire-powered admin interface with Flux UI -- **REST API** - Scoped API keys, rate limiting, webhooks, OpenAPI docs -- **MCP Tools** - Model Context Protocol for AI agent integration - -## Requirements - -- PHP 8.2+ -- Composer 2.x -- SQLite (default) or MySQL/PostgreSQL -- Node.js 18+ (for frontend assets) +REST API infrastructure with OpenAPI documentation, rate limiting, webhook signing, and secure API key management. ## Installation ```bash -# Clone or create from template -git clone https://github.com/host-uk/core-template.git my-project -cd my-project - -# Install dependencies -composer install -npm install - -# Configure environment -cp .env.example .env -php artisan key:generate - -# Set up database -touch database/database.sqlite -php artisan migrate - -# Start development server -php artisan serve +composer require host-uk/core-api ``` -Visit: http://localhost:8000 +## Features -## Project Structure - -``` -app/ -├── Console/ # Artisan commands -├── Http/ # Controllers & Middleware -├── Models/ # Eloquent models -├── Mod/ # Your custom modules -└── Providers/ # Service providers - -config/ -└── core.php # Core framework configuration - -routes/ -├── web.php # Public web routes -├── api.php # REST API routes -└── console.php # Artisan commands -``` - -## Creating Modules - -```bash -# Create a new module with all features -php artisan make:mod Blog --all - -# Create module with specific features -php artisan make:mod Shop --web --api --admin -``` - -Modules follow the event-driven pattern: +### OpenAPI/Swagger Documentation +Auto-generated API documentation with multiple UI options: ```php - 'onWebRoutes', - ApiRoutesRegistering::class => 'onApiRoutes', - AdminPanelBooting::class => 'onAdminPanel', - ]; - - public function onWebRoutes(WebRoutesRegistering $event): void + public function index() { - $event->routes(fn() => require __DIR__.'/Routes/web.php'); - $event->views('blog', __DIR__.'/Views'); + return ProductResource::collection(Product::paginate()); } } ``` -## Core Packages +**Access documentation:** +- `GET /api/docs` - Scalar UI (default) +- `GET /api/docs/swagger` - Swagger UI +- `GET /api/docs/redoc` - ReDoc +- `GET /api/docs/openapi.json` - OpenAPI spec -| Package | Description | -|---------|-------------| -| `host-uk/core` | Core framework components | -| `host-uk/core-admin` | Admin panel & Livewire modals | -| `host-uk/core-api` | REST API with scopes & webhooks | -| `host-uk/core-mcp` | Model Context Protocol tools | +### Secure API Keys +Bcrypt hashing with backward compatibility: -## Flux Pro (Optional) +```php +use Core\Mod\Api\Models\ApiKey; -This template uses the free Flux UI components. If you have a Flux Pro license: +$key = ApiKey::create([ + 'name' => 'Production API', + 'workspace_id' => $workspace->id, + 'scopes' => ['read', 'write'], +]); -```bash -# Configure authentication -composer config http-basic.composer.fluxui.dev your-email your-license-key - -# Add the repository -composer config repositories.flux-pro composer https://composer.fluxui.dev - -# Install Flux Pro -composer require livewire/flux-pro +// Returns the plain key (shown only once) +$plainKey = $key->getPlainKey(); ``` -## Documentation +**Features:** +- Bcrypt hashing for new keys +- Legacy SHA-256 support +- Key rotation with grace periods +- Scope-based permissions -- [Core PHP Framework](https://github.com/host-uk/core-php) -- [Getting Started Guide](https://host-uk.github.io/core-php/guide/) -- [Architecture](https://host-uk.github.io/core-php/architecture/) +### Rate Limiting +Granular rate limiting per endpoint: + +```php +use Core\Mod\Api\RateLimit\RateLimit; + +#[RateLimit(limit: 100, window: 60, burst: 1.2)] +class ProductController extends Controller +{ + // Limited to 100 requests per 60 seconds + // With 20% burst allowance +} +``` + +**Features:** +- Per-endpoint limits +- Workspace isolation +- Tier-based limits +- Standard headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` + +### Webhook Signing +HMAC-SHA256 signatures for outbound webhooks: + +```php +use Core\Mod\Api\Models\WebhookEndpoint; + +$endpoint = WebhookEndpoint::create([ + 'url' => 'https://example.com/webhooks', + 'events' => ['order.created', 'order.updated'], + 'secret' => WebhookEndpoint::generateSecret(), +]); +``` + +**Verification:** +```php +$signature = hash_hmac('sha256', $timestamp . '.' . $payload, $secret); +hash_equals($signature, $request->header('X-Webhook-Signature')); +``` + +### Scope Enforcement +Fine-grained API permissions: + +```php +use Core\Mod\Api\Middleware\EnforceApiScope; + +Route::middleware(['api', EnforceApiScope::class.':write']) + ->post('/products', [ProductController::class, 'store']); +``` + +## Configuration + +```php +// config/api.php (after php artisan vendor:publish --tag=api-config) + +return [ + 'rate_limits' => [ + 'default' => 60, + 'tiers' => [ + 'free' => 100, + 'pro' => 1000, + 'enterprise' => 10000, + ], + ], + 'docs' => [ + 'enabled' => env('API_DOCS_ENABLED', true), + 'require_auth' => env('API_DOCS_REQUIRE_AUTH', false), + ], +]; +``` + +## API Guides + +The package includes comprehensive guides: + +- **Authentication** - API key creation and usage +- **Quick Start** - Getting started in 5 minutes +- **Rate Limiting** - Understanding limits and tiers +- **Webhooks** - Setting up and verifying webhooks +- **Errors** - Error codes and handling + +Access at: `/api/guides` + +## Requirements + +- PHP 8.2+ +- Laravel 11+ or 12+ + +## Changelog + +See [changelog/2026/jan/features.md](changelog/2026/jan/features.md) for recent changes. + +## Security + +See [changelog/2026/jan/security.md](changelog/2026/jan/security.md) for security updates. ## License -EUPL-1.2 (European Union Public Licence) +EUPL-1.2 - See [LICENSE](../../LICENSE) for details. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..fa18354 --- /dev/null +++ b/TODO.md @@ -0,0 +1,246 @@ +# Core-API TODO + +## Testing & Quality Assurance + +### High Priority + +- [ ] **Test Coverage: API Key Security** - Test bcrypt hashing and rotation + - [ ] Test API key creation with bcrypt hashing + - [ ] Test API key authentication + - [ ] Test key rotation with grace period + - [ ] Test key revocation + - [ ] Test scoped key access + - **Estimated effort:** 3-4 hours + +- [ ] **Test Coverage: Webhook System** - Test delivery and signatures + - [ ] Test webhook endpoint registration + - [ ] Test HMAC-SHA256 signature generation + - [ ] Test signature verification + - [ ] Test webhook delivery retry logic + - [ ] Test exponential backoff + - [ ] Test delivery status tracking + - **Estimated effort:** 4-5 hours + +- [ ] **Test Coverage: Rate Limiting** - Test tier-based limits + - [ ] Test per-tier rate limits + - [ ] Test rate limit headers + - [ ] Test quota exceeded responses + - [ ] Test workspace-scoped limits + - [ ] Test burst allowance + - **Estimated effort:** 3-4 hours + +- [ ] **Test Coverage: Scope Enforcement** - Test permission system + - [ ] Test EnforceApiScope middleware + - [ ] Test wildcard scopes (posts:*, *:read) + - [ ] Test scope inheritance + - [ ] Test scope validation errors + - **Estimated effort:** 3-4 hours + +### Medium Priority + +- [ ] **Test Coverage: OpenAPI Documentation** - Test spec generation + - [ ] Test OpenApiBuilder with controller scanning + - [ ] Test #[ApiParameter] attribute parsing + - [ ] Test #[ApiResponse] rendering + - [ ] Test #[ApiSecurity] requirements + - [ ] Test #[ApiHidden] filtering + - [ ] Test extension system + - **Estimated effort:** 4-5 hours + +- [ ] **Test Coverage: Usage Alerts** - Test quota monitoring + - [ ] Test CheckApiUsageAlerts command + - [ ] Test HighApiUsageNotification delivery + - [ ] Test usage alert thresholds + - [ ] Test alert history tracking + - **Estimated effort:** 2-3 hours + +### Low Priority + +- [ ] **Test Coverage: Webhook Payload Validation** - Test request validation + - [ ] Test payload size limits + - [ ] Test content-type validation + - [ ] Test malformed JSON handling + - **Estimated effort:** 2-3 hours + +## Features & Enhancements + +### High Priority + +- [ ] **Feature: API Versioning** - Support multiple API versions + - [ ] Implement version routing (v1, v2) + - [ ] Add version deprecation warnings + - [ ] Support version-specific transformers + - [ ] Document migration between versions + - [ ] Test backward compatibility + - **Estimated effort:** 6-8 hours + - **Files:** `src/Mod/Api/Versioning/` + +- [ ] **Feature: GraphQL API** - Alternative to REST + - [ ] Implement GraphQL schema generation + - [ ] Add query resolver system + - [ ] Support mutations + - [ ] Add introspection + - [ ] Test complex nested queries + - **Estimated effort:** 12-16 hours + - **Files:** `src/Mod/Api/GraphQL/` + +- [ ] **Feature: Batch Operations** - Bulk API requests + - [ ] Support batched requests + - [ ] Implement atomic batch transactions + - [ ] Add batch size limits + - [ ] Test error handling in batches + - **Estimated effort:** 4-6 hours + - **Files:** `src/Mod/Api/Batch/` + +### Medium Priority + +- [ ] **Enhancement: Webhook Transformers** - Custom payload formatting + - [ ] Create transformer interface + - [ ] Support per-endpoint transformers + - [ ] Add JSON-LD format support + - [ ] Test with complex data structures + - **Estimated effort:** 3-4 hours + - **Files:** `src/Mod/Api/Webhooks/Transformers/` + +- [ ] **Enhancement: API Analytics** - Detailed usage metrics + - [ ] Track API calls per endpoint + - [ ] Monitor response times + - [ ] Track error rates + - [ ] Create admin dashboard + - [ ] Add export to CSV + - **Estimated effort:** 5-6 hours + - **Files:** `src/Mod/Api/Analytics/` + +- [ ] **Enhancement: Request Throttling Strategies** - Advanced rate limiting + - [ ] Implement sliding window algorithm + - [ ] Add burst allowance + - [ ] Support custom throttle strategies + - [ ] Add per-endpoint rate limits + - **Estimated effort:** 4-5 hours + - **Files:** `src/Mod/Api/RateLimit/Strategies/` + +### Low Priority + +- [ ] **Enhancement: API Client SDK Generator** - Auto-generate SDKs + - [ ] Generate PHP SDK from OpenAPI + - [ ] Generate JavaScript SDK + - [ ] Generate Python SDK + - [ ] Add usage examples + - **Estimated effort:** 8-10 hours + - **Files:** `src/Mod/Api/Sdk/` + +- [ ] **Enhancement: Webhook Retry Dashboard** - Visual delivery monitoring + - [ ] Create delivery status dashboard + - [ ] Add manual retry button + - [ ] Show delivery timeline + - [ ] Export delivery logs + - **Estimated effort:** 3-4 hours + - **Files:** `src/Website/Api/Components/` + +## Security + +### High Priority + +- [ ] **Security: API Key IP Whitelisting** - Restrict key usage + - [ ] Add allowed_ips column to api_keys + - [ ] Validate request IP against whitelist + - [ ] Test with IPv4 and IPv6 + - [ ] Add CIDR notation support + - **Estimated effort:** 3-4 hours + +- [ ] **Security: Request Signing** - Prevent replay attacks + - [ ] Implement timestamp validation + - [ ] Add nonce tracking + - [ ] Support custom signing algorithms + - [ ] Test with clock skew + - **Estimated effort:** 4-5 hours + +### Medium Priority + +- [ ] **Security: Webhook Mutual TLS** - Secure webhook delivery + - [ ] Add client certificate support + - [ ] Implement certificate validation + - [ ] Test with self-signed certs + - **Estimated effort:** 4-5 hours + +- [ ] **Audit: API Permission Model** - Review scope granularity + - [ ] Audit all API scopes + - [ ] Ensure least-privilege defaults + - [ ] Document scope requirements + - [ ] Test scope escalation attempts + - **Estimated effort:** 3-4 hours + +## Documentation + +- [x] **Guide: Building REST APIs** - Complete tutorial + - [x] Document resource creation + - [x] Show pagination best practices + - [x] Explain filtering and sorting + - [x] Add authentication examples + - **Completed:** January 2026 + - **File:** `docs/packages/api/building-rest-apis.md` + +- [x] **Guide: Webhook Integration** - For API consumers + - [x] Document signature verification + - [x] Show retry handling + - [x] Explain event types + - [x] Add code examples (PHP, JS, Python) + - **Completed:** January 2026 + - **File:** `docs/packages/api/webhook-integration.md` + +- [x] **API Reference: All Endpoints** - Complete OpenAPI spec + - [x] Document all request parameters + - [x] Add response examples + - [x] Show error responses + - [x] Include authentication notes + - **Completed:** January 2026 + - **File:** `docs/packages/api/endpoints-reference.md` + +## Code Quality + +- [ ] **Refactor: Extract Rate Limiter** - Reusable rate limiting + - [ ] Create standalone RateLimiter service + - [ ] Support multiple backends (Redis, DB, memory) + - [ ] Add configurable strategies + - [ ] Test with high concurrency + - **Estimated effort:** 3-4 hours + +- [ ] **Refactor: Webhook Queue Priority** - Prioritize critical webhooks + - [ ] Add priority field to webhooks + - [ ] Implement priority queue + - [ ] Test delivery order + - **Estimated effort:** 2-3 hours + +- [ ] **PHPStan: Fix Level 5 Errors** - Improve type safety + - [ ] Fix array shape types in resources + - [ ] Add missing return types + - [ ] Fix property type declarations + - **Estimated effort:** 2-3 hours + +## Performance + +- [ ] **Optimization: Response Caching** - Cache GET requests + - [ ] Implement HTTP cache headers + - [ ] Add ETag support + - [ ] Support cache invalidation + - [ ] Test with CDN + - **Estimated effort:** 3-4 hours + +- [ ] **Optimization: Database Query Reduction** - Eager load relationships + - [ ] Audit N+1 queries in resources + - [ ] Add eager loading + - [ ] Benchmark before/after + - **Estimated effort:** 2-3 hours + +--- + +## Completed (January 2026) + +- [x] **API Key Hashing** - Bcrypt hashing for all API keys +- [x] **Webhook Signatures** - HMAC-SHA256 signature verification +- [x] **Scope System** - Fine-grained API permissions +- [x] **Rate Limiting** - Tier-based rate limits with usage alerts +- [x] **OpenAPI Documentation** - Auto-generated API docs with Swagger/Scalar/ReDoc +- [x] **Documentation** - Complete API package documentation + +*See `changelog/2026/jan/` for completed features.* diff --git a/changelog/2026/jan/features.md b/changelog/2026/jan/features.md new file mode 100644 index 0000000..dca84c4 --- /dev/null +++ b/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/composer.json b/composer.json index 5cdb126..ca9ab32 100644 --- a/composer.json +++ b/composer.json @@ -1,76 +1,22 @@ { - "name": "host-uk/core-template", - "type": "project", - "description": "Core PHP Framework - Project Template", - "keywords": ["laravel", "core-php", "modular", "framework", "template"], + "name": "host-uk/core-api", + "description": "REST API module for Core PHP framework", + "keywords": ["laravel", "api", "rest", "json"], "license": "EUPL-1.2", "require": { "php": "^8.2", - "laravel/framework": "^12.0", - "laravel/tinker": "^2.10", - "livewire/flux": "^2.0", - "livewire/livewire": "^3.0", - "host-uk/core": "dev-main", - "host-uk/core-admin": "dev-main", - "host-uk/core-api": "dev-main", - "host-uk/core-mcp": "dev-main" - }, - "require-dev": { - "fakerphp/faker": "^1.23", - "laravel/pail": "^1.2", - "laravel/pint": "^1.18", - "laravel/sail": "^1.41", - "mockery/mockery": "^1.6", - "nunomaduro/collision": "^8.6", - "pestphp/pest": "^3.0", - "pestphp/pest-plugin-laravel": "^3.0" + "host-uk/core": "@dev", + "symfony/yaml": "^7.0" }, "autoload": { "psr-4": { - "App\\": "app/", - "Database\\Factories\\": "database/factories/", - "Database\\Seeders\\": "database/seeders/" + "Core\\Mod\\Api\\": "src/Mod/Api/", + "Core\\Website\\Api\\": "src/Website/Api/" } }, - "autoload-dev": { - "psr-4": { - "Tests\\": "tests/" - } - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/host-uk/core-php.git" - } - ], - "scripts": { - "post-autoload-dump": [ - "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", - "@php artisan package:discover --ansi" - ], - "post-update-cmd": [ - "@php artisan vendor:publish --tag=laravel-assets --ansi --force" - ], - "post-root-package-install": [ - "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" - ], - "post-create-project-cmd": [ - "@php artisan key:generate --ansi", - "@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"", - "@php artisan migrate --graceful --ansi" - ] - }, "extra": { "laravel": { - "dont-discover": [] - } - }, - "config": { - "optimize-autoloader": true, - "preferred-install": "dist", - "sort-packages": true, - "allow-plugins": { - "php-http/discovery": true + "providers": [] } }, "minimum-stability": "stable", diff --git a/src/Mod/Api/Boot.php b/src/Mod/Api/Boot.php new file mode 100644 index 0000000..e02e0b6 --- /dev/null +++ b/src/Mod/Api/Boot.php @@ -0,0 +1,98 @@ + + */ + public static array $listens = [ + ApiRoutesRegistering::class => 'onApiRoutes', + ConsoleBooting::class => 'onConsole', + ]; + + /** + * Register any application services. + */ + public function register(): void + { + $this->mergeConfigFrom( + __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); + } + + /** + * Bootstrap any application services. + */ + public function boot(): void + { + $this->loadMigrationsFrom(__DIR__.'/Migrations'); + } + + // ------------------------------------------------------------------------- + // Event-driven handlers + // ------------------------------------------------------------------------- + + public function onApiRoutes(ApiRoutesRegistering $event): void + { + // 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); + + // Core API routes (SEO, Pixel, Entitlements, MCP) + if (file_exists(__DIR__.'/Routes/api.php')) { + $event->routes(fn () => Route::middleware('api')->group(__DIR__.'/Routes/api.php')); + } + } + + public function onConsole(ConsoleBooting $event): void + { + // 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/src/Mod/Api/Concerns/HasApiResponses.php b/src/Mod/Api/Concerns/HasApiResponses.php new file mode 100644 index 0000000..1db3bf3 --- /dev/null +++ b/src/Mod/Api/Concerns/HasApiResponses.php @@ -0,0 +1,92 @@ +json([ + 'error' => 'no_workspace', + 'message' => 'No workspace found. Please select a workspace first.', + ], 404); + } + + /** + * Return a resource not found response. + */ + protected function notFoundResponse(string $resource = 'Resource'): JsonResponse + { + return response()->json([ + 'error' => 'not_found', + 'message' => "{$resource} not found.", + ], 404); + } + + /** + * Return a feature limit reached response. + */ + protected function limitReachedResponse(string $feature, ?string $message = null): JsonResponse + { + return response()->json([ + 'error' => 'feature_limit_reached', + 'message' => $message ?? 'You have reached your limit for this feature.', + 'feature' => $feature, + 'upgrade_url' => route('hub.usage'), + ], 403); + } + + /** + * Return an access denied response. + */ + protected function accessDeniedResponse(string $message = 'Access denied.'): JsonResponse + { + return response()->json([ + 'error' => 'access_denied', + 'message' => $message, + ], 403); + } + + /** + * Return a success response with message. + */ + protected function successResponse(string $message, array $data = []): JsonResponse + { + return response()->json(array_merge([ + 'message' => $message, + ], $data)); + } + + /** + * Return a created response. + */ + protected function createdResponse(mixed $resource, string $message = 'Created successfully.'): JsonResponse + { + return response()->json([ + 'message' => $message, + 'data' => $resource, + ], 201); + } + + /** + * Return a validation error response. + */ + protected function validationErrorResponse(array $errors): JsonResponse + { + return response()->json([ + 'error' => 'validation_failed', + 'message' => 'The given data was invalid.', + 'errors' => $errors, + ], 422); + } +} diff --git a/src/Mod/Api/Concerns/HasApiTokens.php b/src/Mod/Api/Concerns/HasApiTokens.php new file mode 100644 index 0000000..fb02590 --- /dev/null +++ b/src/Mod/Api/Concerns/HasApiTokens.php @@ -0,0 +1,76 @@ + + */ + public function tokens(): HasMany + { + return $this->hasMany(UserToken::class); + } + + /** + * Create a new personal access token for the user. + * + * @param string $name Human-readable name for the token + * @param \DateTimeInterface|null $expiresAt Optional expiration date + * @return array{token: string, model: UserToken} Plain-text token and model instance + */ + public function createToken(string $name, ?\DateTimeInterface $expiresAt = null): array + { + // Generate a random 40-character token + $plainTextToken = Str::random(40); + + // Hash it for storage + $hashedToken = hash('sha256', $plainTextToken); + + // Create the token record + $token = $this->tokens()->create([ + 'name' => $name, + 'token' => $hashedToken, + 'expires_at' => $expiresAt, + ]); + + return [ + 'token' => $plainTextToken, + 'model' => $token, + ]; + } + + /** + * Revoke all tokens for this user. + * + * @return int Number of tokens deleted + */ + public function revokeAllTokens(): int + { + return $this->tokens()->delete(); + } + + /** + * Revoke a specific token by its ID. + * + * @return bool True if the token was deleted + */ + public function revokeToken(int $tokenId): bool + { + return (bool) $this->tokens()->where('id', $tokenId)->delete(); + } +} diff --git a/src/Mod/Api/Concerns/ResolvesWorkspace.php b/src/Mod/Api/Concerns/ResolvesWorkspace.php new file mode 100644 index 0000000..957958b --- /dev/null +++ b/src/Mod/Api/Concerns/ResolvesWorkspace.php @@ -0,0 +1,84 @@ +attributes->get('workspace'); + if ($workspace instanceof Workspace) { + return $workspace; + } + + // Check for explicit workspace_id + $workspaceId = $request->attributes->get('workspace_id') + ?? $request->input('workspace_id') + ?? $request->header('X-Workspace-Id'); + + if ($workspaceId) { + return $this->findWorkspaceForUser($request, (int) $workspaceId); + } + + // Fall back to user's default workspace + $user = $request->user(); + if ($user instanceof User) { + return $user->defaultHostWorkspace(); + } + + return null; + } + + /** + * Find a workspace by ID that the user has access to. + */ + protected function findWorkspaceForUser(Request $request, int $workspaceId): ?Workspace + { + $user = $request->user(); + + if (! $user instanceof User) { + return null; + } + + return $user->workspaces() + ->where('workspaces.id', $workspaceId) + ->first(); + } + + /** + * Get the authentication type. + */ + protected function getAuthType(Request $request): string + { + return $request->attributes->get('auth_type', 'session'); + } + + /** + * Check if authenticated via API key. + */ + protected function isApiKeyAuth(Request $request): bool + { + return $this->getAuthType($request) === 'api_key'; + } +} diff --git a/src/Mod/Api/Console/Commands/CheckApiUsageAlerts.php b/src/Mod/Api/Console/Commands/CheckApiUsageAlerts.php new file mode 100644 index 0000000..6163605 --- /dev/null +++ b/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/src/Mod/Api/Console/Commands/CleanupExpiredGracePeriods.php b/src/Mod/Api/Console/Commands/CleanupExpiredGracePeriods.php new file mode 100644 index 0000000..2cf5f26 --- /dev/null +++ b/src/Mod/Api/Console/Commands/CleanupExpiredGracePeriods.php @@ -0,0 +1,67 @@ +option('dry-run'); + + if ($dryRun) { + $this->warn('DRY RUN MODE - No keys will be revoked'); + $this->newLine(); + + // Count keys that would be cleaned up + $count = \Mod\Api\Models\ApiKey::gracePeriodExpired() + ->whereNull('deleted_at') + ->count(); + + if ($count === 0) { + $this->info('No API keys with expired grace periods found.'); + } else { + $this->info("Would revoke {$count} API key(s) with expired grace periods."); + } + + return Command::SUCCESS; + } + + $this->info('Cleaning up API keys with expired grace periods...'); + + $count = $service->cleanupExpiredGracePeriods(); + + if ($count === 0) { + $this->info('No API keys with expired grace periods found.'); + } else { + $this->info("Revoked {$count} API key(s) with expired grace periods."); + } + + return Command::SUCCESS; + } +} diff --git a/src/Mod/Api/Controllers/McpApiController.php b/src/Mod/Api/Controllers/McpApiController.php new file mode 100644 index 0000000..b980e51 --- /dev/null +++ b/src/Mod/Api/Controllers/McpApiController.php @@ -0,0 +1,625 @@ +loadRegistry(); + + $servers = collect($registry['servers'] ?? []) + ->map(fn ($ref) => $this->loadServerSummary($ref['id'])) + ->filter() + ->values(); + + return response()->json([ + 'servers' => $servers, + 'count' => $servers->count(), + ]); + } + + /** + * Get server details with tools and resources. + * + * GET /api/v1/mcp/servers/{id} + */ + public function server(Request $request, string $id): JsonResponse + { + $server = $this->loadServerFull($id); + + if (! $server) { + return response()->json(['error' => 'Server not found'], 404); + } + + return response()->json($server); + } + + /** + * List tools for a specific server. + * + * GET /api/v1/mcp/servers/{id}/tools + * + * Query params: + * - include_versions: bool - include version info for each tool + */ + public function tools(Request $request, string $id): JsonResponse + { + $server = $this->loadServerFull($id); + + if (! $server) { + return response()->json(['error' => 'Server not found'], 404); + } + + $tools = $server['tools'] ?? []; + $includeVersions = $request->boolean('include_versions', false); + + // Optionally enrich tools with version information + if ($includeVersions) { + $versionService = app(ToolVersionService::class); + $tools = collect($tools)->map(function ($tool) use ($id, $versionService) { + $toolName = $tool['name'] ?? ''; + $latestVersion = $versionService->getLatestVersion($id, $toolName); + + $tool['versioning'] = [ + 'latest_version' => $latestVersion?->version ?? ToolVersionService::DEFAULT_VERSION, + 'is_versioned' => $latestVersion !== null, + 'deprecated' => $latestVersion?->is_deprecated ?? false, + ]; + + // If version exists, use its schema (may differ from YAML) + if ($latestVersion?->input_schema) { + $tool['inputSchema'] = $latestVersion->input_schema; + } + + return $tool; + })->all(); + } + + return response()->json([ + 'server' => $id, + 'tools' => $tools, + 'count' => count($tools), + ]); + } + + /** + * Execute a tool on an MCP server. + * + * POST /api/v1/mcp/tools/call + * + * Request body: + * - server: string (required) + * - tool: string (required) + * - arguments: array (optional) + * - version: string (optional) - semver version to use, defaults to latest + */ + public function callTool(Request $request): JsonResponse + { + $validated = $request->validate([ + 'server' => 'required|string|max:64', + 'tool' => 'required|string|max:128', + 'arguments' => 'nullable|array', + 'version' => 'nullable|string|max:32', + ]); + + $server = $this->loadServerFull($validated['server']); + if (! $server) { + return response()->json(['error' => 'Server not found'], 404); + } + + // Verify tool exists in server definition + $toolDef = collect($server['tools'] ?? [])->firstWhere('name', $validated['tool']); + if (! $toolDef) { + return response()->json(['error' => 'Tool not found'], 404); + } + + // Version resolution + $versionService = app(ToolVersionService::class); + $versionResult = $versionService->resolveVersion( + $validated['server'], + $validated['tool'], + $validated['version'] ?? null + ); + + // If version was requested but is sunset, block the call + if ($versionResult['error']) { + $error = $versionResult['error']; + + // Sunset versions return 410 Gone + $status = ($error['code'] ?? '') === 'TOOL_VERSION_SUNSET' ? 410 : 400; + + return response()->json([ + 'success' => false, + 'error' => $error['message'] ?? 'Version error', + 'error_code' => $error['code'] ?? 'VERSION_ERROR', + 'server' => $validated['server'], + 'tool' => $validated['tool'], + 'requested_version' => $validated['version'] ?? null, + 'latest_version' => $error['latest_version'] ?? null, + 'migration_notes' => $error['migration_notes'] ?? null, + ], $status); + } + + /** @var McpToolVersion|null $toolVersion */ + $toolVersion = $versionResult['version']; + $deprecationWarning = $versionResult['warning']; + + // Use versioned schema if available for validation + $schemaForValidation = $toolVersion?->input_schema ?? $toolDef['inputSchema'] ?? null; + if ($schemaForValidation) { + $validationErrors = $this->validateToolArguments( + ['inputSchema' => $schemaForValidation], + $validated['arguments'] ?? [] + ); + + if (! empty($validationErrors)) { + return response()->json([ + 'success' => false, + 'error' => 'Validation failed', + 'error_code' => 'VALIDATION_ERROR', + 'validation_errors' => $validationErrors, + 'server' => $validated['server'], + 'tool' => $validated['tool'], + 'version' => $toolVersion?->version ?? 'unversioned', + ], 422); + } + } + + // Get API key for logging + $apiKey = $request->attributes->get('api_key'); + $workspace = $apiKey?->workspace; + + $startTime = microtime(true); + + try { + // Execute the tool via artisan command + $result = $this->executeToolViaArtisan( + $validated['server'], + $validated['tool'], + $validated['arguments'] ?? [] + ); + + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + + // Log the call + $this->logToolCall($apiKey, $validated, $result, $durationMs, true); + + // Dispatch webhooks + $this->dispatchWebhook($apiKey, $validated, true, $durationMs); + + $response = [ + 'success' => true, + 'server' => $validated['server'], + 'tool' => $validated['tool'], + 'version' => $toolVersion?->version ?? ToolVersionService::DEFAULT_VERSION, + 'result' => $result, + 'duration_ms' => $durationMs, + ]; + + // Include deprecation warning if applicable + if ($deprecationWarning) { + $response['_warnings'] = [$deprecationWarning]; + } + + // Log full request for debugging/replay + $this->logApiRequest($request, $validated, 200, $response, $durationMs, $apiKey); + + // Build response with deprecation headers if needed + $jsonResponse = response()->json($response); + + if ($deprecationWarning) { + $jsonResponse->header('X-MCP-Deprecation-Warning', $deprecationWarning['message'] ?? 'Version deprecated'); + if (isset($deprecationWarning['sunset_at'])) { + $jsonResponse->header('X-MCP-Sunset-At', $deprecationWarning['sunset_at']); + } + if (isset($deprecationWarning['latest_version'])) { + $jsonResponse->header('X-MCP-Latest-Version', $deprecationWarning['latest_version']); + } + } + + return $jsonResponse; + } catch (\Throwable $e) { + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + + $this->logToolCall($apiKey, $validated, null, $durationMs, false, $e->getMessage()); + + // Dispatch webhooks (even on failure) + $this->dispatchWebhook($apiKey, $validated, false, $durationMs, $e->getMessage()); + + $response = [ + 'success' => false, + 'error' => $e->getMessage(), + 'server' => $validated['server'], + 'tool' => $validated['tool'], + 'version' => $toolVersion?->version ?? ToolVersionService::DEFAULT_VERSION, + ]; + + // Log full request for debugging/replay + $this->logApiRequest($request, $validated, 500, $response, $durationMs, $apiKey, $e->getMessage()); + + return response()->json($response, 500); + } + } + + /** + * Validate tool arguments against a JSON schema. + * + * @return array Validation error messages + */ + protected function validateToolArguments(array $toolDef, array $arguments): array + { + $inputSchema = $toolDef['inputSchema'] ?? null; + + if (! $inputSchema || ! is_array($inputSchema)) { + return []; + } + + $errors = []; + $properties = $inputSchema['properties'] ?? []; + $required = $inputSchema['required'] ?? []; + + // Check required properties + foreach ($required as $requiredProp) { + if (! array_key_exists($requiredProp, $arguments)) { + $errors[] = "Missing required argument: {$requiredProp}"; + } + } + + // Type validation for provided arguments + foreach ($arguments as $key => $value) { + if (! isset($properties[$key])) { + if (($inputSchema['additionalProperties'] ?? true) === false) { + $errors[] = "Unknown argument: {$key}"; + } + + continue; + } + + $propSchema = $properties[$key]; + $expectedType = $propSchema['type'] ?? null; + + if ($expectedType && ! $this->validateType($value, $expectedType)) { + $errors[] = "Argument '{$key}' must be of type {$expectedType}"; + } + + // Validate enum values + if (isset($propSchema['enum']) && ! in_array($value, $propSchema['enum'], true)) { + $allowedValues = implode(', ', $propSchema['enum']); + $errors[] = "Argument '{$key}' must be one of: {$allowedValues}"; + } + } + + return $errors; + } + + /** + * Validate a value against a JSON Schema type. + */ + protected function validateType(mixed $value, string $type): bool + { + return match ($type) { + 'string' => is_string($value), + 'integer' => is_int($value) || (is_numeric($value) && floor((float) $value) == $value), + 'number' => is_numeric($value), + 'boolean' => is_bool($value), + 'array' => is_array($value) && array_is_list($value), + 'object' => is_array($value) && ! array_is_list($value), + 'null' => is_null($value), + default => true, + }; + } + + /** + * Get version history for a specific tool. + * + * GET /api/v1/mcp/servers/{server}/tools/{tool}/versions + */ + public function toolVersions(Request $request, string $server, string $tool): JsonResponse + { + $serverConfig = $this->loadServerFull($server); + if (! $serverConfig) { + return response()->json(['error' => 'Server not found'], 404); + } + + // Verify tool exists in server definition + $toolDef = collect($serverConfig['tools'] ?? [])->firstWhere('name', $tool); + if (! $toolDef) { + return response()->json(['error' => 'Tool not found'], 404); + } + + $versionService = app(ToolVersionService::class); + $versions = $versionService->getVersionHistory($server, $tool); + + return response()->json([ + 'server' => $server, + 'tool' => $tool, + 'versions' => $versions->map(fn (McpToolVersion $v) => $v->toApiArray())->values(), + 'count' => $versions->count(), + ]); + } + + /** + * Get a specific version of a tool. + * + * GET /api/v1/mcp/servers/{server}/tools/{tool}/versions/{version} + */ + public function toolVersion(Request $request, string $server, string $tool, string $version): JsonResponse + { + $versionService = app(ToolVersionService::class); + $toolVersion = $versionService->getToolAtVersion($server, $tool, $version); + + if (! $toolVersion) { + return response()->json(['error' => 'Version not found'], 404); + } + + $response = response()->json($toolVersion->toApiArray()); + + // Add deprecation headers if applicable + if ($deprecationWarning = $toolVersion->getDeprecationWarning()) { + $response->header('X-MCP-Deprecation-Warning', $deprecationWarning['message'] ?? 'Version deprecated'); + if (isset($deprecationWarning['sunset_at'])) { + $response->header('X-MCP-Sunset-At', $deprecationWarning['sunset_at']); + } + } + + return $response; + } + + /** + * Read a resource from an MCP server. + * + * GET /api/v1/mcp/resources/{uri} + */ + public function resource(Request $request, string $uri): JsonResponse + { + // Parse URI format: server://resource/path + if (! preg_match('/^([a-z0-9-]+):\/\/(.+)$/', $uri, $matches)) { + return response()->json(['error' => 'Invalid resource URI format'], 400); + } + + $serverId = $matches[1]; + $resourcePath = $matches[2]; + + $server = $this->loadServerFull($serverId); + if (! $server) { + return response()->json(['error' => 'Server not found'], 404); + } + + try { + $result = $this->readResourceViaArtisan($serverId, $resourcePath); + + return response()->json([ + 'uri' => $uri, + 'content' => $result, + ]); + } catch (\Throwable $e) { + return response()->json([ + 'error' => $e->getMessage(), + 'uri' => $uri, + ], 500); + } + } + + /** + * Execute tool via artisan MCP server command. + */ + protected function executeToolViaArtisan(string $server, string $tool, array $arguments): mixed + { + $commandMap = [ + 'hosthub-agent' => 'mcp:agent-server', + 'socialhost' => 'mcp:socialhost-server', + 'biohost' => 'mcp:biohost-server', + 'commerce' => 'mcp:commerce-server', + 'supporthost' => 'mcp:support-server', + 'upstream' => 'mcp:upstream-server', + ]; + + $command = $commandMap[$server] ?? null; + if (! $command) { + throw new \RuntimeException("Unknown server: {$server}"); + } + + // Build MCP request + $mcpRequest = [ + 'jsonrpc' => '2.0', + 'id' => uniqid(), + 'method' => 'tools/call', + 'params' => [ + 'name' => $tool, + 'arguments' => $arguments, + ], + ]; + + // Execute via process + $process = proc_open( + ['php', 'artisan', $command], + [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + base_path() + ); + + if (! is_resource($process)) { + throw new \RuntimeException('Failed to start MCP server process'); + } + + fwrite($pipes[0], json_encode($mcpRequest)."\n"); + fclose($pipes[0]); + + $output = stream_get_contents($pipes[1]); + fclose($pipes[1]); + fclose($pipes[2]); + + proc_close($process); + + $response = json_decode($output, true); + + if (isset($response['error'])) { + throw new \RuntimeException($response['error']['message'] ?? 'Tool execution failed'); + } + + return $response['result'] ?? null; + } + + /** + * Read resource via artisan MCP server command. + */ + protected function readResourceViaArtisan(string $server, string $path): mixed + { + // Similar to executeToolViaArtisan but with resources/read method + // Simplified for now - can expand later + return ['path' => $path, 'content' => 'Resource reading not yet implemented']; + } + + /** + * Log full API request for debugging and replay. + */ + protected function logApiRequest( + Request $request, + array $validated, + int $status, + array $response, + int $durationMs, + ?ApiKey $apiKey, + ?string $error = null + ): void { + try { + McpApiRequest::log( + method: $request->method(), + path: '/tools/call', + requestBody: $validated, + responseStatus: $status, + responseBody: $response, + durationMs: $durationMs, + workspaceId: $apiKey?->workspace_id, + apiKeyId: $apiKey?->id, + serverId: $validated['server'], + toolName: $validated['tool'], + errorMessage: $error, + ipAddress: $request->ip(), + headers: $request->headers->all() + ); + } catch (\Throwable $e) { + // Don't let logging failures affect API response + report($e); + } + } + + /** + * Dispatch webhook for tool execution. + */ + protected function dispatchWebhook( + ?ApiKey $apiKey, + array $request, + bool $success, + int $durationMs, + ?string $error = null + ): void { + if (! $apiKey?->workspace_id) { + return; + } + + try { + $dispatcher = new McpWebhookDispatcher; + $dispatcher->dispatchToolExecuted( + workspaceId: $apiKey->workspace_id, + serverId: $request['server'], + toolName: $request['tool'], + arguments: $request['arguments'] ?? [], + success: $success, + durationMs: $durationMs, + errorMessage: $error + ); + } catch (\Throwable $e) { + // Don't let webhook failures affect API response + report($e); + } + } + + /** + * Log tool call for analytics. + */ + protected function logToolCall( + ?ApiKey $apiKey, + array $request, + mixed $result, + int $durationMs, + bool $success, + ?string $error = null + ): void { + McpToolCall::log( + serverId: $request['server'], + toolName: $request['tool'], + params: $request['arguments'] ?? [], + success: $success, + durationMs: $durationMs, + errorMessage: $error, + workspaceId: $apiKey?->workspace_id + ); + } + + // Registry loading methods (shared with McpRegistryController) + + protected function loadRegistry(): array + { + return Cache::remember('mcp:registry', 600, function () { + $path = resource_path('mcp/registry.yaml'); + + return file_exists($path) ? Yaml::parseFile($path) : ['servers' => []]; + }); + } + + protected function loadServerFull(string $id): ?array + { + return Cache::remember("mcp:server:{$id}", 600, function () use ($id) { + $path = resource_path("mcp/servers/{$id}.yaml"); + + return file_exists($path) ? Yaml::parseFile($path) : null; + }); + } + + protected function loadServerSummary(string $id): ?array + { + $server = $this->loadServerFull($id); + if (! $server) { + return null; + } + + return [ + 'id' => $server['id'], + 'name' => $server['name'], + 'tagline' => $server['tagline'] ?? '', + 'status' => $server['status'] ?? 'available', + 'tool_count' => count($server['tools'] ?? []), + 'resource_count' => count($server['resources'] ?? []), + ]; + } +} diff --git a/src/Mod/Api/Database/Factories/ApiKeyFactory.php b/src/Mod/Api/Database/Factories/ApiKeyFactory.php new file mode 100644 index 0000000..36b6898 --- /dev/null +++ b/src/Mod/Api/Database/Factories/ApiKeyFactory.php @@ -0,0 +1,253 @@ + + */ +class ApiKeyFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = ApiKey::class; + + /** + * Store the plain key for testing. + */ + private ?string $plainKey = null; + + /** + * Define the model's default state. + * + * Creates keys with secure bcrypt hashing by default. + * + * @return array + */ + public function definition(): array + { + $plainKey = Str::random(48); + $prefix = 'hk_'.Str::random(8); + $this->plainKey = "{$prefix}_{$plainKey}"; + + return [ + 'workspace_id' => Workspace::factory(), + 'user_id' => User::factory(), + 'name' => fake()->words(2, true).' API Key', + '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, + ]; + } + + /** + * Get the plain key after creation. + * Must be called immediately after create() to get the plain key. + */ + public function getPlainKey(): ?string + { + return $this->plainKey; + } + + /** + * 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( + ?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(); + + return ApiKey::generate( + $workspace->id, + $user->id, + fake()->words(2, true).' API Key', + $scopes, + $expiresAt + ); + } + + /** + * 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. + */ + public function used(): static + { + return $this->state(fn (array $attributes) => [ + 'last_used_at' => now()->subMinutes(fake()->numberBetween(1, 60)), + ]); + } + + /** + * Indicate that the key expires in the future. + * + * @param int $days Number of days until expiration + */ + public function expiresIn(int $days = 30): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => now()->addDays($days), + ]); + } + + /** + * Indicate that the key has expired. + */ + public function expired(): static + { + return $this->state(fn (array $attributes) => [ + 'expires_at' => now()->subDays(1), + ]); + } + + /** + * Set specific scopes. + * + * @param array $scopes + */ + public function withScopes(array $scopes): static + { + return $this->state(fn (array $attributes) => [ + 'scopes' => $scopes, + ]); + } + + /** + * Set read-only scope. + */ + public function readOnly(): static + { + return $this->withScopes([ApiKey::SCOPE_READ]); + } + + /** + * Set all scopes (read, write, delete). + */ + public function fullAccess(): static + { + return $this->withScopes(ApiKey::ALL_SCOPES); + } + + /** + * Set specific server scopes. + * + * @param array|null $servers + */ + public function withServerScopes(?array $servers): static + { + return $this->state(fn (array $attributes) => [ + 'server_scopes' => $servers, + ]); + } + + /** + * Create a revoked (soft-deleted) key. + */ + public function revoked(): static + { + return $this->state(fn (array $attributes) => [ + '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/src/Mod/Api/Documentation/Attributes/ApiHidden.php b/src/Mod/Api/Documentation/Attributes/ApiHidden.php new file mode 100644 index 0000000..4ae0858 --- /dev/null +++ b/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/src/Mod/Api/Documentation/Attributes/ApiResponse.php b/src/Mod/Api/Documentation/Attributes/ApiResponse.php new file mode 100644 index 0000000..2b5092a --- /dev/null +++ b/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/src/Mod/Api/Documentation/Attributes/ApiSecurity.php b/src/Mod/Api/Documentation/Attributes/ApiSecurity.php new file mode 100644 index 0000000..97fcf01 --- /dev/null +++ b/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/src/Mod/Api/Documentation/Attributes/ApiTag.php b/src/Mod/Api/Documentation/Attributes/ApiTag.php new file mode 100644 index 0000000..239d3c5 --- /dev/null +++ b/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/src/Mod/Api/Documentation/DocumentationServiceProvider.php b/src/Mod/Api/Documentation/DocumentationServiceProvider.php new file mode 100644 index 0000000..12b8f2b --- /dev/null +++ b/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/src/Mod/Api/Documentation/Examples/CommonExamples.php b/src/Mod/Api/Documentation/Examples/CommonExamples.php new file mode 100644 index 0000000..f53ab02 --- /dev/null +++ b/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/src/Mod/Api/Documentation/Extension.php b/src/Mod/Api/Documentation/Extension.php new file mode 100644 index 0000000..31e7360 --- /dev/null +++ b/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/src/Mod/Api/Documentation/Extensions/WorkspaceHeaderExtension.php b/src/Mod/Api/Documentation/Extensions/WorkspaceHeaderExtension.php new file mode 100644 index 0000000..0679048 --- /dev/null +++ b/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/src/Mod/Api/Documentation/Middleware/ProtectDocumentation.php b/src/Mod/Api/Documentation/Middleware/ProtectDocumentation.php new file mode 100644 index 0000000..4752c81 --- /dev/null +++ b/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/src/Mod/Api/Documentation/ModuleDiscovery.php b/src/Mod/Api/Documentation/ModuleDiscovery.php new file mode 100644 index 0000000..9bb3681 --- /dev/null +++ b/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/src/Mod/Api/Documentation/OpenApiBuilder.php b/src/Mod/Api/Documentation/OpenApiBuilder.php new file mode 100644 index 0000000..e02764c --- /dev/null +++ b/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/src/Mod/Api/Documentation/Routes/docs.php b/src/Mod/Api/Documentation/Routes/docs.php new file mode 100644 index 0000000..03ae6ad --- /dev/null +++ b/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/src/Mod/Api/Documentation/Views/redoc.blade.php b/src/Mod/Api/Documentation/Views/redoc.blade.php new file mode 100644 index 0000000..d1fd68e --- /dev/null +++ b/src/Mod/Api/Documentation/Views/redoc.blade.php @@ -0,0 +1,60 @@ + + + + + + + {{ config('api-docs.info.title', 'API Documentation') }} - ReDoc + + + + + + + + + + diff --git a/src/Mod/Api/Documentation/Views/scalar.blade.php b/src/Mod/Api/Documentation/Views/scalar.blade.php new file mode 100644 index 0000000..85ac8c8 --- /dev/null +++ b/src/Mod/Api/Documentation/Views/scalar.blade.php @@ -0,0 +1,28 @@ + + + + + + + {{ config('api-docs.info.title', 'API Documentation') }} + + + + + + + diff --git a/src/Mod/Api/Documentation/Views/swagger.blade.php b/src/Mod/Api/Documentation/Views/swagger.blade.php new file mode 100644 index 0000000..2515ddd --- /dev/null +++ b/src/Mod/Api/Documentation/Views/swagger.blade.php @@ -0,0 +1,65 @@ + + + + + + + {{ config('api-docs.info.title', 'API Documentation') }} - Swagger UI + + + + +
+ + + + + + diff --git a/src/Mod/Api/Documentation/config.php b/src/Mod/Api/Documentation/config.php new file mode 100644 index 0000000..0c43186 --- /dev/null +++ b/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/src/Mod/Api/Exceptions/RateLimitExceededException.php b/src/Mod/Api/Exceptions/RateLimitExceededException.php new file mode 100644 index 0000000..62436b1 --- /dev/null +++ b/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/src/Mod/Api/Guards/AccessTokenGuard.php b/src/Mod/Api/Guards/AccessTokenGuard.php new file mode 100644 index 0000000..cd098b4 --- /dev/null +++ b/src/Mod/Api/Guards/AccessTokenGuard.php @@ -0,0 +1,98 @@ +group(function () { + * // Protected API routes + * }); + */ +class AccessTokenGuard +{ + /** + * The authentication factory instance. + */ + protected Factory $auth; + + /** + * Create a new guard instance. + */ + public function __construct(Factory $auth) + { + $this->auth = $auth; + } + + /** + * Handle the authentication for the incoming request. + * + * This method is called by Laravel's authentication system when using + * the guard. It attempts to authenticate the request using the Bearer + * token and returns the authenticated user if successful. + * + * @return User|null The authenticated user or null if authentication fails + */ + public function __invoke(Request $request): ?User + { + $token = $this->getTokenFromRequest($request); + + if (! $token) { + return null; + } + + $accessToken = UserToken::findToken($token); + + if (! $this->isValidAccessToken($accessToken)) { + return null; + } + + // Update last used timestamp + $accessToken->recordUsage(); + + return $accessToken->user; + } + + /** + * Extract the Bearer token from the request. + * + * Looks for the token in the Authorization header in the format: + * Authorization: Bearer {token} + * + * @return string|null The extracted token or null if not found + */ + protected function getTokenFromRequest(Request $request): ?string + { + $token = $request->bearerToken(); + + return ! empty($token) ? $token : null; + } + + /** + * Validate the access token. + * + * Checks if the token exists and hasn't expired. + * + * @return bool True if the token is valid, false otherwise + */ + protected function isValidAccessToken(?UserToken $accessToken): bool + { + if (! $accessToken) { + return false; + } + + return $accessToken->isValid(); + } +} diff --git a/src/Mod/Api/Jobs/DeliverWebhookJob.php b/src/Mod/Api/Jobs/DeliverWebhookJob.php new file mode 100644 index 0000000..ba7612d --- /dev/null +++ b/src/Mod/Api/Jobs/DeliverWebhookJob.php @@ -0,0 +1,182 @@ +queue = config('api.webhooks.queue', 'default'); + + $connection = config('api.webhooks.queue_connection'); + if ($connection) { + $this->connection = $connection; + } + } + + /** + * Execute the job. + */ + public function handle(): void + { + // Don't deliver if endpoint is disabled + $endpoint = $this->delivery->endpoint; + if (! $endpoint || ! $endpoint->shouldReceive($this->delivery->event_type)) { + Log::info('Webhook delivery skipped - endpoint inactive or does not receive this event', [ + 'delivery_id' => $this->delivery->id, + 'event_type' => $this->delivery->event_type, + ]); + + return; + } + + // Get delivery payload with signature headers + $deliveryPayload = $this->delivery->getDeliveryPayload(); + $timeout = config('api.webhooks.timeout', 30); + + Log::info('Attempting webhook delivery', [ + 'delivery_id' => $this->delivery->id, + 'endpoint_url' => $endpoint->url, + 'event_type' => $this->delivery->event_type, + 'attempt' => $this->delivery->attempt, + ]); + + try { + $response = Http::timeout($timeout) + ->withHeaders($deliveryPayload['headers']) + ->withBody($deliveryPayload['body'], 'application/json') + ->post($endpoint->url); + + $statusCode = $response->status(); + $responseBody = $response->body(); + + // Success is any 2xx status code + if ($response->successful()) { + $this->delivery->markSuccess($statusCode, $responseBody); + + Log::info('Webhook delivered successfully', [ + 'delivery_id' => $this->delivery->id, + 'status_code' => $statusCode, + ]); + + return; + } + + // Non-2xx response - mark as failed and potentially retry + $this->handleFailure($statusCode, $responseBody); + + } catch (\Illuminate\Http\Client\ConnectionException $e) { + // Connection timeout or refused + $this->handleFailure(0, 'Connection failed: '.$e->getMessage()); + + } catch (\Throwable $e) { + // Unexpected error + $this->handleFailure(0, 'Unexpected error: '.$e->getMessage()); + + Log::error('Webhook delivery unexpected error', [ + 'delivery_id' => $this->delivery->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + } + + /** + * Handle a failed delivery attempt. + */ + protected function handleFailure(int $statusCode, ?string $responseBody): void + { + Log::warning('Webhook delivery failed', [ + 'delivery_id' => $this->delivery->id, + 'attempt' => $this->delivery->attempt, + 'status_code' => $statusCode, + 'can_retry' => $this->delivery->canRetry(), + ]); + + // Mark as failed (this also schedules retry if attempts remain) + $this->delivery->markFailed($statusCode, $responseBody); + + // If we can retry, dispatch a new job with the appropriate delay + if ($this->delivery->canRetry() && $this->delivery->next_retry_at) { + $delay = $this->delivery->next_retry_at->diffInSeconds(now()); + + Log::info('Scheduling webhook retry', [ + 'delivery_id' => $this->delivery->id, + 'next_attempt' => $this->delivery->attempt, + 'delay_seconds' => $delay, + 'next_retry_at' => $this->delivery->next_retry_at->toIso8601String(), + ]); + + // Dispatch retry with calculated delay + self::dispatch($this->delivery->fresh())->delay($delay); + } + } + + /** + * Handle a job failure. + */ + public function failed(\Throwable $exception): void + { + Log::error('Webhook delivery job failed completely', [ + 'delivery_id' => $this->delivery->id, + 'error' => $exception->getMessage(), + ]); + } + + /** + * Get the tags for the job. + * + * @return array + */ + public function tags(): array + { + return [ + 'webhook', + 'webhook:'.$this->delivery->webhook_endpoint_id, + 'event:'.$this->delivery->event_type, + ]; + } +} diff --git a/src/Mod/Api/Middleware/AuthenticateApiKey.php b/src/Mod/Api/Middleware/AuthenticateApiKey.php new file mode 100644 index 0000000..ab6e101 --- /dev/null +++ b/src/Mod/Api/Middleware/AuthenticateApiKey.php @@ -0,0 +1,125 @@ +withMiddleware(function (Middleware $middleware) { + * $middleware->alias([ + * 'auth.api' => \App\Http\Middleware\Api\AuthenticateApiKey::class, + * ]); + * }) + */ +class AuthenticateApiKey +{ + public function handle(Request $request, Closure $next, ?string $scope = null): Response + { + $token = $request->bearerToken(); + + if (! $token) { + return $this->unauthorized('API key required. Use Authorization: Bearer '); + } + + // Check if it's an API key (prefixed with hk_) + if (str_starts_with($token, 'hk_')) { + return $this->authenticateApiKey($request, $next, $token, $scope); + } + + // Fall back to Sanctum for OAuth tokens + return $this->authenticateSanctum($request, $next, $scope); + } + + /** + * Authenticate using an API key. + */ + protected function authenticateApiKey( + Request $request, + Closure $next, + string $token, + ?string $scope + ): Response { + $apiKey = ApiKey::findByPlainKey($token); + + if (! $apiKey) { + return $this->unauthorized('Invalid API key'); + } + + if ($apiKey->isExpired()) { + return $this->unauthorized('API key has expired'); + } + + // Check scope if required + if ($scope !== null && ! $apiKey->hasScope($scope)) { + return $this->forbidden("API key missing required scope: {$scope}"); + } + + // Record usage (non-blocking) + $apiKey->recordUsage(); + + // Set request context + $request->setUserResolver(fn () => $apiKey->user); + $request->attributes->set('api_key', $apiKey); + $request->attributes->set('workspace', $apiKey->workspace); + $request->attributes->set('workspace_id', $apiKey->workspace_id); + $request->attributes->set('auth_type', 'api_key'); + + return $next($request); + } + + /** + * Fall back to Sanctum authentication for OAuth tokens. + */ + protected function authenticateSanctum( + Request $request, + Closure $next, + ?string $scope + ): Response { + // For API requests, use token authentication + if (! $request->user()) { + // Try to authenticate via Sanctum token + $guard = auth('sanctum'); + if (! $guard->check()) { + return $this->unauthorized('Invalid authentication token'); + } + + $request->setUserResolver(fn () => $guard->user()); + } + + $request->attributes->set('auth_type', 'sanctum'); + + return $next($request); + } + + /** + * Return 401 Unauthorized response. + */ + protected function unauthorized(string $message): Response + { + return response()->json([ + 'error' => 'unauthorized', + 'message' => $message, + ], 401); + } + + /** + * Return 403 Forbidden response. + */ + protected function forbidden(string $message): Response + { + return response()->json([ + 'error' => 'forbidden', + 'message' => $message, + ], 403); + } +} diff --git a/src/Mod/Api/Middleware/CheckApiScope.php b/src/Mod/Api/Middleware/CheckApiScope.php new file mode 100644 index 0000000..826b979 --- /dev/null +++ b/src/Mod/Api/Middleware/CheckApiScope.php @@ -0,0 +1,52 @@ +post('/resource', ...); + * Route::middleware(['auth.api', 'api.scope:read,write'])->put('/resource', ...); + * + * Register in bootstrap/app.php: + * ->withMiddleware(function (Middleware $middleware) { + * $middleware->alias([ + * 'api.scope' => \App\Http\Middleware\Api\CheckApiScope::class, + * ]); + * }) + */ +class CheckApiScope +{ + public function handle(Request $request, Closure $next, string ...$scopes): Response + { + $apiKey = $request->attributes->get('api_key'); + + // If not authenticated via API key, allow through + // (Sanctum auth handles its own scopes) + if (! $apiKey instanceof ApiKey) { + return $next($request); + } + + // Check all required scopes + foreach ($scopes as $scope) { + if (! $apiKey->hasScope($scope)) { + return response()->json([ + 'error' => 'forbidden', + 'message' => "API key missing required scope: {$scope}", + 'required_scopes' => $scopes, + 'key_scopes' => $apiKey->scopes, + ], 403); + } + } + + return $next($request); + } +} diff --git a/src/Mod/Api/Middleware/EnforceApiScope.php b/src/Mod/Api/Middleware/EnforceApiScope.php new file mode 100644 index 0000000..2a91f42 --- /dev/null +++ b/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/src/Mod/Api/Middleware/PublicApiCors.php b/src/Mod/Api/Middleware/PublicApiCors.php new file mode 100644 index 0000000..da299df --- /dev/null +++ b/src/Mod/Api/Middleware/PublicApiCors.php @@ -0,0 +1,64 @@ +isMethod('OPTIONS')) { + return $this->buildPreflightResponse($request); + } + + $response = $next($request); + + return $this->addCorsHeaders($response, $request); + } + + /** + * Build preflight response for OPTIONS requests. + */ + protected function buildPreflightResponse(Request $request): Response + { + $response = response('', 204); + + return $this->addCorsHeaders($response, $request); + } + + /** + * Add CORS headers to response. + */ + protected function addCorsHeaders(Response $response, Request $request): Response + { + $origin = $request->header('Origin', '*'); + + // Allow any origin for public widget/pixel endpoints + $response->headers->set('Access-Control-Allow-Origin', $origin); + $response->headers->set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + $response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Accept, X-Requested-With'); + $response->headers->set('Access-Control-Expose-Headers', 'X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After'); + $response->headers->set('Access-Control-Max-Age', '3600'); + + // Vary on Origin for proper caching + $response->headers->set('Vary', 'Origin'); + + return $response; + } +} diff --git a/src/Mod/Api/Middleware/RateLimitApi.php b/src/Mod/Api/Middleware/RateLimitApi.php new file mode 100644 index 0000000..772bb5d --- /dev/null +++ b/src/Mod/Api/Middleware/RateLimitApi.php @@ -0,0 +1,352 @@ +withMiddleware(function (Middleware $middleware) { + * $middleware->alias([ + * 'api.rate' => \Core\Mod\Api\Middleware\RateLimitApi::class, + * ]); + * }) + */ +class RateLimitApi +{ + public function __construct( + protected RateLimitService $rateLimitService, + ) {} + + public function handle(Request $request, Closure $next): Response + { + // Check if rate limiting is enabled + if (! config('api.rate_limits.enabled', true)) { + return $next($request); + } + + $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, $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, 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) { + $parts[] = "api_key:{$apiKey->id}"; + + // 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()}"; + } + + // 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. + */ + protected function getWorkspaceTier(mixed $workspace): string + { + // Check if workspace has an active package/subscription + if (method_exists($workspace, 'activePackages')) { + $package = $workspace->activePackages()->first(); + + 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'; + } + + /** + * Check if the request is authenticated. + */ + protected function isAuthenticated(Request $request): bool + { + return $request->attributes->get('api_key') !== null + || $request->user() !== null; + } + + /** + * Add rate limit headers to response. + */ + protected function addRateLimitHeaders(Response $response, RateLimitResult $result): Response + { + foreach ($result->headers() as $header => $value) { + $response->headers->set($header, (string) $value); + } + + return $response; + } +} diff --git a/src/Mod/Api/Middleware/TrackApiUsage.php b/src/Mod/Api/Middleware/TrackApiUsage.php new file mode 100644 index 0000000..d836106 --- /dev/null +++ b/src/Mod/Api/Middleware/TrackApiUsage.php @@ -0,0 +1,81 @@ +attributes->get('api_key'); + + if ($apiKey instanceof ApiKey) { + $this->recordUsage($request, $response, $apiKey, $responseTimeMs); + } + + return $response; + } + + /** + * Record the API usage. + */ + protected function recordUsage( + Request $request, + Response $response, + ApiKey $apiKey, + int $responseTimeMs + ): void { + try { + $this->usageService->record( + apiKeyId: $apiKey->id, + workspaceId: $apiKey->workspace_id, + endpoint: $request->path(), + method: $request->method(), + statusCode: $response->getStatusCode(), + responseTimeMs: $responseTimeMs, + requestSize: strlen($request->getContent()), + responseSize: strlen($response->getContent()), + ipAddress: $request->ip(), + userAgent: $request->userAgent() + ); + } catch (\Throwable $e) { + // Don't let analytics failures affect the API response + Log::warning('Failed to record API usage', [ + 'error' => $e->getMessage(), + 'api_key_id' => $apiKey->id, + 'endpoint' => $request->path(), + ]); + } + } +} diff --git a/src/Mod/Api/Migrations/2026_01_07_002358_create_api_keys_table.php b/src/Mod/Api/Migrations/2026_01_07_002358_create_api_keys_table.php new file mode 100644 index 0000000..eb3547a --- /dev/null +++ b/src/Mod/Api/Migrations/2026_01_07_002358_create_api_keys_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('key', 64)->comment('SHA256 hash of the key'); + $table->string('prefix', 16)->comment('Key prefix for identification (hk_xxxxxxxx)'); + $table->json('scopes')->default('["read","write"]'); + $table->json('server_scopes')->nullable()->comment('Per-server access: null=all, ["commerce","biohost"]=specific'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + + // Index for key lookup + $table->index(['prefix', 'key']); + $table->index('workspace_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('api_keys'); + } +}; diff --git a/src/Mod/Api/Migrations/2026_01_07_002400_create_webhook_endpoints_table.php b/src/Mod/Api/Migrations/2026_01_07_002400_create_webhook_endpoints_table.php new file mode 100644 index 0000000..eebe7b3 --- /dev/null +++ b/src/Mod/Api/Migrations/2026_01_07_002400_create_webhook_endpoints_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->string('url'); + $table->string('secret', 64)->comment('HMAC signing secret'); + $table->json('events')->comment('Event types to receive, or ["*"] for all'); + $table->boolean('active')->default(true); + $table->string('description')->nullable(); + $table->timestamp('last_triggered_at')->nullable(); + $table->unsignedInteger('failure_count')->default(0); + $table->timestamp('disabled_at')->nullable()->comment('Auto-disabled after 10 consecutive failures'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['workspace_id', 'active']); + $table->index(['active', 'disabled_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_endpoints'); + } +}; diff --git a/src/Mod/Api/Migrations/2026_01_07_002401_create_webhook_deliveries_table.php b/src/Mod/Api/Migrations/2026_01_07_002401_create_webhook_deliveries_table.php new file mode 100644 index 0000000..96faf41 --- /dev/null +++ b/src/Mod/Api/Migrations/2026_01_07_002401_create_webhook_deliveries_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('webhook_endpoint_id')->constrained('webhook_endpoints')->cascadeOnDelete(); + $table->string('event_id', 32)->comment('Unique event identifier (evt_xxx)'); + $table->string('event_type', 64)->index(); + $table->json('payload'); + $table->unsignedSmallInteger('response_code')->nullable(); + $table->text('response_body')->nullable(); + $table->unsignedTinyInteger('attempt')->default(1); + $table->string('status', 16)->default('pending')->comment('pending, success, failed, retrying'); + $table->timestamp('delivered_at')->nullable(); + $table->timestamp('next_retry_at')->nullable()->index(); + $table->timestamps(); + + $table->index(['webhook_endpoint_id', 'status']); + $table->index(['status', 'next_retry_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('webhook_deliveries'); + } +}; diff --git a/src/Mod/Api/Migrations/2026_01_27_000000_add_secure_hashing_to_api_keys_table.php b/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/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/src/Mod/Api/Models/ApiKey.php b/src/Mod/Api/Models/ApiKey.php new file mode 100644 index 0000000..61587a7 --- /dev/null +++ b/src/Mod/Api/Models/ApiKey.php @@ -0,0 +1,412 @@ + 'array', + 'server_scopes' => 'array', + 'last_used_at' => 'datetime', + 'expires_at' => 'datetime', + 'grace_period_ends_at' => 'datetime', + ]; + + protected $hidden = [ + 'key', // Never expose the hashed key + ]; + + /** + * 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} + */ + public static function generate( + int $workspaceId, + int $userId, + string $name, + array $scopes = [self::SCOPE_READ, self::SCOPE_WRITE], + ?\DateTimeInterface $expiresAt = null + ): array { + $plainKey = Str::random(48); + $prefix = 'hk_'.Str::random(8); + + $apiKey = static::create([ + 'workspace_id' => $workspaceId, + 'user_id' => $userId, + 'name' => $name, + 'key' => Hash::make($plainKey), + 'hash_algorithm' => self::HASH_BCRYPT, + 'prefix' => $prefix, + 'scopes' => $scopes, + 'expires_at' => $expiresAt, + ]); + + // Return plain key only once - never stored + return [ + 'api_key' => $apiKey, + 'plain_key' => "{$prefix}_{$plainKey}", + ]; + } + + /** + * 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 + { + // Expected format: hk_xxxxxxxx_xxxxx... + if (! str_starts_with($plainKey, 'hk_')) { + return null; + } + + $parts = explode('_', $plainKey, 3); + if (count($parts) !== 3) { + return null; + } + + $prefix = $parts[0].'_'.$parts[1]; // hk_xxxxxxxx + $key = $parts[2]; + + // Find potential matches by prefix + $candidates = static::where('prefix', $prefix) + ->whereNull('deleted_at') + ->where(function ($query) { + $query->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }) + ->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(); + } + + /** + * Record API key usage. + */ + public function recordUsage(): void + { + $this->update(['last_used_at' => now()]); + } + + /** + * Check if key has a specific scope. + */ + public function hasScope(string $scope): bool + { + return in_array($scope, $this->scopes ?? [], true); + } + + /** + * Check if key has all specified scopes. + */ + public function hasScopes(array $scopes): bool + { + foreach ($scopes as $scope) { + if (! $this->hasScope($scope)) { + return false; + } + } + + return true; + } + + /** + * Check if key is expired. + */ + public function isExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } + + /** + * Check if key has access to a specific MCP server. + */ + public function hasServerAccess(string $serverId): bool + { + // Null means all servers + if ($this->server_scopes === null) { + return true; + } + + return in_array($serverId, $this->server_scopes, true); + } + + /** + * Get list of allowed servers (null = all). + */ + public function getAllowedServers(): ?array + { + return $this->server_scopes; + } + + /** + * Revoke this API key. + */ + public function revoke(): void + { + $this->delete(); + } + + /** + * Get the masked key for display. + * Shows prefix and last 4 characters. + */ + public function getMaskedKeyAttribute(): string + { + return "{$this->prefix}_****"; + } + + // Relationships + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class, 'workspace_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * 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); + } + + public function scopeActive($query) + { + return $query->whereNull('deleted_at') + ->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()); + }); + } + + public function scopeExpired($query) + { + 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/src/Mod/Api/Models/ApiUsage.php b/src/Mod/Api/Models/ApiUsage.php new file mode 100644 index 0000000..3bae241 --- /dev/null +++ b/src/Mod/Api/Models/ApiUsage.php @@ -0,0 +1,135 @@ + 'datetime', + ]; + + /** + * Create a usage entry from request/response data. + */ + public static function record( + int $apiKeyId, + int $workspaceId, + string $endpoint, + string $method, + int $statusCode, + int $responseTimeMs, + ?int $requestSize = null, + ?int $responseSize = null, + ?string $ipAddress = null, + ?string $userAgent = null + ): static { + return static::create([ + 'api_key_id' => $apiKeyId, + 'workspace_id' => $workspaceId, + 'endpoint' => $endpoint, + 'method' => strtoupper($method), + 'status_code' => $statusCode, + 'response_time_ms' => $responseTimeMs, + 'request_size' => $requestSize, + 'response_size' => $responseSize, + 'ip_address' => $ipAddress, + 'user_agent' => $userAgent ? substr($userAgent, 0, 500) : null, + 'created_at' => now(), + ]); + } + + /** + * Check if this was a successful request (2xx status). + */ + public function isSuccess(): bool + { + return $this->status_code >= 200 && $this->status_code < 300; + } + + /** + * Check if this was a client error (4xx status). + */ + public function isClientError(): bool + { + return $this->status_code >= 400 && $this->status_code < 500; + } + + /** + * Check if this was a server error (5xx status). + */ + public function isServerError(): bool + { + return $this->status_code >= 500; + } + + // Relationships + public function apiKey(): BelongsTo + { + return $this->belongsTo(ApiKey::class); + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + // Scopes + public function scopeForKey($query, int $apiKeyId) + { + return $query->where('api_key_id', $apiKeyId); + } + + public function scopeForWorkspace($query, int $workspaceId) + { + return $query->where('workspace_id', $workspaceId); + } + + public function scopeForEndpoint($query, string $endpoint) + { + return $query->where('endpoint', $endpoint); + } + + public function scopeSuccessful($query) + { + return $query->whereBetween('status_code', [200, 299]); + } + + public function scopeErrors($query) + { + return $query->where('status_code', '>=', 400); + } + + public function scopeBetween($query, $startDate, $endDate) + { + return $query->whereBetween('created_at', [$startDate, $endDate]); + } +} diff --git a/src/Mod/Api/Models/ApiUsageDaily.php b/src/Mod/Api/Models/ApiUsageDaily.php new file mode 100644 index 0000000..9dd15cb --- /dev/null +++ b/src/Mod/Api/Models/ApiUsageDaily.php @@ -0,0 +1,172 @@ + 'date', + ]; + + /** + * Update or create daily stats from a usage record. + * + * Uses Laravel's upsert() for database portability while maintaining + * atomic operations. For increment operations, we use a two-step approach: + * first upsert the base record, then atomically update counters. + */ + public static function recordFromUsage(ApiUsage $usage): static + { + $isSuccess = $usage->isSuccess(); + $isError = $usage->status_code >= 400; + $date = $usage->created_at->toDateString(); + $now = now(); + + // Unique key for this daily aggregation + $uniqueKey = [ + 'api_key_id' => $usage->api_key_id, + 'workspace_id' => $usage->workspace_id, + 'date' => $date, + 'endpoint' => $usage->endpoint, + 'method' => $usage->method, + ]; + + // First, ensure the record exists with upsert (database-portable) + static::upsert( + [ + ...$uniqueKey, + 'request_count' => 0, + 'success_count' => 0, + 'error_count' => 0, + 'total_response_time_ms' => 0, + 'total_request_size' => 0, + 'total_response_size' => 0, + 'min_response_time_ms' => null, + 'max_response_time_ms' => null, + 'created_at' => $now, + 'updated_at' => $now, + ], + ['api_key_id', 'workspace_id', 'date', 'endpoint', 'method'], + ['updated_at'] // Only touch updated_at if record exists + ); + + // Then atomically increment counters using query builder + $query = static::where($uniqueKey); + + // Build raw update for atomic increments + $query->update([ + 'request_count' => DB::raw('request_count + 1'), + 'success_count' => DB::raw('success_count + '.($isSuccess ? 1 : 0)), + 'error_count' => DB::raw('error_count + '.($isError ? 1 : 0)), + 'total_response_time_ms' => DB::raw('total_response_time_ms + '.(int) $usage->response_time_ms), + 'total_request_size' => DB::raw('total_request_size + '.(int) ($usage->request_size ?? 0)), + 'total_response_size' => DB::raw('total_response_size + '.(int) ($usage->response_size ?? 0)), + 'updated_at' => $now, + ]); + + // Update min/max response times (these need conditional logic) + $responseTimeMs = (int) $usage->response_time_ms; + static::where($uniqueKey) + ->where(function ($q) use ($responseTimeMs) { + $q->whereNull('min_response_time_ms') + ->orWhere('min_response_time_ms', '>', $responseTimeMs); + }) + ->update(['min_response_time_ms' => $responseTimeMs]); + + static::where($uniqueKey) + ->where(function ($q) use ($responseTimeMs) { + $q->whereNull('max_response_time_ms') + ->orWhere('max_response_time_ms', '<', $responseTimeMs); + }) + ->update(['max_response_time_ms' => $responseTimeMs]); + + // Retrieve the record for return + return static::where($uniqueKey)->first(); + } + + /** + * Calculate average response time. + */ + public function getAverageResponseTimeMsAttribute(): float + { + if ($this->request_count === 0) { + return 0; + } + + return round($this->total_response_time_ms / $this->request_count, 2); + } + + /** + * Calculate success rate percentage. + */ + public function getSuccessRateAttribute(): float + { + if ($this->request_count === 0) { + return 100; + } + + return round(($this->success_count / $this->request_count) * 100, 2); + } + + // Relationships + public function apiKey(): BelongsTo + { + return $this->belongsTo(ApiKey::class); + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + // Scopes + public function scopeForKey($query, int $apiKeyId) + { + return $query->where('api_key_id', $apiKeyId); + } + + public function scopeForWorkspace($query, int $workspaceId) + { + return $query->where('workspace_id', $workspaceId); + } + + public function scopeForEndpoint($query, string $endpoint) + { + return $query->where('endpoint', $endpoint); + } + + public function scopeBetween($query, $startDate, $endDate) + { + return $query->whereBetween('date', [$startDate, $endDate]); + } +} diff --git a/src/Mod/Api/Models/WebhookDelivery.php b/src/Mod/Api/Models/WebhookDelivery.php new file mode 100644 index 0000000..637b6c2 --- /dev/null +++ b/src/Mod/Api/Models/WebhookDelivery.php @@ -0,0 +1,209 @@ + 1, // 1 minute + 2 => 5, // 5 minutes + 3 => 30, // 30 minutes + 4 => 120, // 2 hours + 5 => 1440, // 24 hours + ]; + + protected $fillable = [ + 'webhook_endpoint_id', + 'event_id', + 'event_type', + 'payload', + 'response_code', + 'response_body', + 'attempt', + 'status', + 'delivered_at', + 'next_retry_at', + ]; + + protected $casts = [ + 'payload' => 'array', + 'delivered_at' => 'datetime', + 'next_retry_at' => 'datetime', + ]; + + /** + * Create a new delivery for an event. + */ + public static function createForEvent( + WebhookEndpoint $endpoint, + string $eventType, + array $data, + ?int $workspaceId = null + ): static { + return static::create([ + 'webhook_endpoint_id' => $endpoint->id, + 'event_id' => 'evt_'.Str::random(24), + 'event_type' => $eventType, + 'payload' => [ + 'id' => 'evt_'.Str::random(24), + 'type' => $eventType, + 'created_at' => now()->toIso8601String(), + 'data' => $data, + 'workspace_id' => $workspaceId, + ], + 'status' => self::STATUS_PENDING, + 'attempt' => 1, + ]); + } + + /** + * Mark as successfully delivered. + */ + public function markSuccess(int $responseCode, ?string $responseBody = null): void + { + $this->update([ + 'status' => self::STATUS_SUCCESS, + 'response_code' => $responseCode, + 'response_body' => $responseBody ? Str::limit($responseBody, 10000) : null, + 'delivered_at' => now(), + 'next_retry_at' => null, + ]); + + $this->endpoint->recordSuccess(); + } + + /** + * Mark as failed and schedule retry if attempts remain. + */ + public function markFailed(int $responseCode, ?string $responseBody = null): void + { + $this->endpoint->recordFailure(); + + if ($this->attempt >= self::MAX_RETRIES) { + $this->update([ + 'status' => self::STATUS_FAILED, + 'response_code' => $responseCode, + 'response_body' => $responseBody ? Str::limit($responseBody, 10000) : null, + ]); + + return; + } + + // Schedule retry + $nextAttempt = $this->attempt + 1; + $delayMinutes = self::RETRY_DELAYS[$nextAttempt] ?? 1440; + + $this->update([ + 'status' => self::STATUS_RETRYING, + 'response_code' => $responseCode, + 'response_body' => $responseBody ? Str::limit($responseBody, 10000) : null, + 'attempt' => $nextAttempt, + 'next_retry_at' => now()->addMinutes($delayMinutes), + ]); + } + + /** + * Check if delivery can be retried. + */ + public function canRetry(): bool + { + return $this->attempt < self::MAX_RETRIES + && $this->status !== self::STATUS_SUCCESS; + } + + /** + * 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(?int $timestamp = null): array + { + $timestamp ??= time(); + $jsonPayload = json_encode($this->payload); + + return [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'X-Webhook-Id' => $this->event_id, + 'X-Webhook-Event' => $this->event_type, + 'X-Webhook-Timestamp' => (string) $timestamp, + 'X-Webhook-Signature' => $this->endpoint->generateSignature($jsonPayload, $timestamp), + ], + 'body' => $jsonPayload, + ]; + } + + // Relationships + public function endpoint(): BelongsTo + { + return $this->belongsTo(WebhookEndpoint::class, 'webhook_endpoint_id'); + } + + // Scopes + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + public function scopeRetrying($query) + { + return $query->where('status', self::STATUS_RETRYING) + ->where('next_retry_at', '<=', now()); + } + + public function scopeNeedsDelivery($query) + { + return $query->where(function ($q) { + $q->where('status', self::STATUS_PENDING) + ->orWhere(function ($q2) { + $q2->where('status', self::STATUS_RETRYING) + ->where('next_retry_at', '<=', now()); + }); + }); + } +} diff --git a/src/Mod/Api/Models/WebhookEndpoint.php b/src/Mod/Api/Models/WebhookEndpoint.php new file mode 100644 index 0000000..6c4ebad --- /dev/null +++ b/src/Mod/Api/Models/WebhookEndpoint.php @@ -0,0 +1,266 @@ + 'array', + 'active' => 'boolean', + 'last_triggered_at' => 'datetime', + 'disabled_at' => 'datetime', + ]; + + protected $hidden = [ + 'secret', + ]; + + /** + * Create a new webhook endpoint with auto-generated secret. + */ + public static function createForWorkspace( + int $workspaceId, + string $url, + array $events, + ?string $description = null + ): static { + $signatureService = app(WebhookSignature::class); + + return static::create([ + 'workspace_id' => $workspaceId, + 'url' => $url, + 'secret' => $signatureService->generateSecret(), + 'events' => $events, + 'description' => $description, + 'active' => true, + ]); + } + + /** + * 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, int $timestamp): string + { + $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); + } + + /** + * Check if endpoint should receive an event. + */ + public function shouldReceive(string $eventType): bool + { + if (! $this->active) { + return false; + } + + if ($this->disabled_at !== null) { + return false; + } + + return in_array($eventType, $this->events, true) + || in_array('*', $this->events, true); + } + + /** + * Record successful delivery. + */ + public function recordSuccess(): void + { + $this->update([ + 'last_triggered_at' => now(), + 'failure_count' => 0, + ]); + } + + /** + * Record failed delivery. + * Auto-disables after 10 consecutive failures. + */ + public function recordFailure(): void + { + $failureCount = $this->failure_count + 1; + + $updates = [ + 'failure_count' => $failureCount, + 'last_triggered_at' => now(), + ]; + + // Auto-disable after 10 consecutive failures + if ($failureCount >= 10) { + $updates['disabled_at'] = now(); + $updates['active'] = false; + } + + $this->update($updates); + } + + /** + * Re-enable a disabled endpoint. + */ + public function enable(): void + { + $this->update([ + 'active' => true, + 'disabled_at' => null, + 'failure_count' => 0, + ]); + } + + /** + * 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 + { + $signatureService = app(WebhookSignature::class); + $newSecret = $signatureService->generateSecret(); + $this->update(['secret' => $newSecret]); + + return $newSecret; + } + + // Relationships + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class, 'workspace_id'); + } + + public function deliveries(): HasMany + { + return $this->hasMany(WebhookDelivery::class); + } + + // Scopes + public function scopeActive($query) + { + return $query->where('active', true) + ->whereNull('disabled_at'); + } + + public function scopeForWorkspace($query, int $workspaceId) + { + return $query->where('workspace_id', $workspaceId); + } + + public function scopeForEvent($query, string $eventType) + { + return $query->where(function ($q) use ($eventType) { + $q->whereJsonContains('events', $eventType) + ->orWhereJsonContains('events', '*'); + }); + } +} diff --git a/src/Mod/Api/Notifications/HighApiUsageNotification.php b/src/Mod/Api/Notifications/HighApiUsageNotification.php new file mode 100644 index 0000000..ae8c44a --- /dev/null +++ b/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/src/Mod/Api/RateLimit/RateLimit.php b/src/Mod/Api/RateLimit/RateLimit.php new file mode 100644 index 0000000..b49e099 --- /dev/null +++ b/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/src/Mod/Api/RateLimit/RateLimitService.php b/src/Mod/Api/RateLimit/RateLimitService.php new file mode 100644 index 0000000..c85aebd --- /dev/null +++ b/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/src/Mod/Api/Resources/ApiKeyResource.php b/src/Mod/Api/Resources/ApiKeyResource.php new file mode 100644 index 0000000..51c4f6f --- /dev/null +++ b/src/Mod/Api/Resources/ApiKeyResource.php @@ -0,0 +1,59 @@ +plainKey = $plainKey; + + return $instance; + } + + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'prefix' => $this->prefix, + 'scopes' => $this->scopes, + 'last_used_at' => $this->last_used_at?->toIso8601String(), + 'expires_at' => $this->expires_at?->toIso8601String(), + 'created_at' => $this->created_at->toIso8601String(), + + // Only included on creation + 'key' => $this->when($this->plainKey !== null, $this->plainKey), + + // Masked display key + 'display_key' => $this->masked_key, + ]; + } +} diff --git a/src/Mod/Api/Resources/ErrorResource.php b/src/Mod/Api/Resources/ErrorResource.php new file mode 100644 index 0000000..a32bc45 --- /dev/null +++ b/src/Mod/Api/Resources/ErrorResource.php @@ -0,0 +1,93 @@ + ['The name field is required.'], + * ])->response()->setStatusCode(422); + */ +class ErrorResource extends JsonResource +{ + protected string $errorCode; + + protected string $message; + + protected ?array $details; + + public function __construct(string $errorCode, string $message, ?array $details = null) + { + $this->errorCode = $errorCode; + $this->message = $message; + $this->details = $details; + + parent::__construct(null); + } + + public static function make(...$args): static + { + return new static(...$args); + } + + /** + * Common error factory methods. + */ + public static function unauthorized(string $message = 'Unauthorized'): static + { + return new static('unauthorized', $message); + } + + public static function forbidden(string $message = 'Forbidden'): static + { + return new static('forbidden', $message); + } + + public static function notFound(string $message = 'Resource not found'): static + { + return new static('not_found', $message); + } + + public static function validation(array $errors): static + { + return new static('validation_error', 'The given data was invalid.', $errors); + } + + public static function rateLimited(int $retryAfter): static + { + return new static('rate_limit_exceeded', 'Too many requests. Please slow down.', [ + 'retry_after' => $retryAfter, + ]); + } + + public static function entitlementExceeded(string $feature): static + { + return new static('entitlement_exceeded', "Plan limit reached for: {$feature}"); + } + + public static function serverError(string $message = 'An unexpected error occurred'): static + { + return new static('internal_error', $message); + } + + public function toArray(Request $request): array + { + $response = [ + 'error' => $this->errorCode, + 'message' => $this->message, + ]; + + if ($this->details !== null) { + $response['details'] = $this->details; + } + + return $response; + } +} diff --git a/src/Mod/Api/Resources/PaginatedCollection.php b/src/Mod/Api/Resources/PaginatedCollection.php new file mode 100644 index 0000000..5d878db --- /dev/null +++ b/src/Mod/Api/Resources/PaginatedCollection.php @@ -0,0 +1,49 @@ +resourceClass = $resourceClass; + parent::__construct($resource); + } + + public function toArray(Request $request): array + { + return [ + 'data' => $this->resourceClass::collection($this->collection), + 'meta' => [ + 'current_page' => $this->currentPage(), + 'from' => $this->firstItem(), + 'last_page' => $this->lastPage(), + 'per_page' => $this->perPage(), + 'to' => $this->lastItem(), + 'total' => $this->total(), + ], + 'links' => [ + 'first' => $this->url(1), + 'last' => $this->url($this->lastPage()), + 'prev' => $this->previousPageUrl(), + 'next' => $this->nextPageUrl(), + ], + ]; + } +} diff --git a/src/Mod/Api/Resources/WebhookEndpointResource.php b/src/Mod/Api/Resources/WebhookEndpointResource.php new file mode 100644 index 0000000..a5e3840 --- /dev/null +++ b/src/Mod/Api/Resources/WebhookEndpointResource.php @@ -0,0 +1,67 @@ +includeSecret = true; + + return $instance; + } + + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'url' => $this->url, + 'events' => $this->events, + 'active' => $this->active, + 'description' => $this->description, + 'last_triggered_at' => $this->last_triggered_at?->toIso8601String(), + 'failure_count' => $this->failure_count, + 'disabled_at' => $this->disabled_at?->toIso8601String(), + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + + // Only on creation + 'secret' => $this->when($this->includeSecret, $this->secret), + + // Links + 'links' => [ + 'self' => route('api.v1.webhooks.show', $this->id, false), + 'deliveries' => route('api.v1.webhooks.deliveries', $this->id, false), + ], + ]; + } +} diff --git a/src/Mod/Api/Resources/WorkspaceResource.php b/src/Mod/Api/Resources/WorkspaceResource.php new file mode 100644 index 0000000..7df357e --- /dev/null +++ b/src/Mod/Api/Resources/WorkspaceResource.php @@ -0,0 +1,68 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'slug' => $this->slug, + 'icon' => $this->icon, + 'color' => $this->color, + 'description' => $this->description, + 'type' => $this->type, + 'is_active' => $this->is_active, + + // Stats + 'users_count' => $this->whenCounted('users'), + 'bio_pages_count' => $this->whenCounted('bioPages'), + + // Role (when available via pivot) + 'role' => $this->whenPivotLoaded('user_workspace', fn () => $this->pivot->role), + 'is_default' => $this->whenPivotLoaded('user_workspace', fn () => $this->pivot->is_default), + + // Settings (public only) + 'settings' => $this->when($this->settings, fn () => $this->getPublicSettings()), + + // Timestamps + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } + + /** + * Get public settings (filter sensitive data). + */ + protected function getPublicSettings(): array + { + $settings = $this->settings ?? []; + + // Remove sensitive keys + unset( + $settings['wp_connector_secret'], + $settings['api_secrets'] + ); + + return $settings; + } +} diff --git a/src/Mod/Api/Routes/api.php b/src/Mod/Api/Routes/api.php new file mode 100644 index 0000000..2190728 --- /dev/null +++ b/src/Mod/Api/Routes/api.php @@ -0,0 +1,103 @@ +prefix('seo')->group(function () { + Route::post('/report', [SeoReportController::class, 'receive']) + ->name('api.seo.report'); + + Route::get('/issues/{workspace}', [SeoReportController::class, 'issues']) + ->name('api.seo.issues'); + + Route::post('/task/generate', [SeoReportController::class, 'generateTask']) + ->name('api.seo.generate-task'); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Unified Pixel API (public - high rate limit for tracking) +// ───────────────────────────────────────────────────────────────────────────── + +Route::middleware('throttle:300,1')->prefix('pixel')->group(function () { + Route::get('/config', [UnifiedPixelController::class, 'config']) + ->name('api.pixel.config'); + Route::post('/track', [UnifiedPixelController::class, 'track']) + ->name('api.pixel.track'); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Entitlements API (authenticated) +// ───────────────────────────────────────────────────────────────────────────── + +Route::middleware('auth')->prefix('entitlements')->group(function () { + // Check feature access (for external apps) + Route::get('/check', [EntitlementApiController::class, 'check']) + ->name('api.entitlements.check'); + + // Record usage (for external apps) + Route::post('/usage', [EntitlementApiController::class, 'recordUsage']) + ->name('api.entitlements.usage'); + + // Get usage summary for current user's workspace + Route::get('/summary', [EntitlementApiController::class, 'mySummary']) + ->name('api.entitlements.summary'); + + // Get usage summary for a specific workspace (admin) + Route::get('/summary/{workspace}', [EntitlementApiController::class, 'summary']) + ->name('api.entitlements.summary.workspace'); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// MCP HTTP Bridge (API key auth) +// ───────────────────────────────────────────────────────────────────────────── + +Route::middleware(['throttle:120,1', McpApiKeyAuth::class, 'api.scope.enforce']) + ->prefix('mcp') + ->name('api.mcp.') + ->group(function () { + // Scope enforcement: GET=read, POST=write + // Server discovery (read) + Route::get('/servers', [McpApiController::class, 'servers']) + ->name('servers'); + Route::get('/servers/{id}', [McpApiController::class, 'server']) + ->name('servers.show'); + Route::get('/servers/{id}/tools', [McpApiController::class, 'tools']) + ->name('servers.tools'); + + // Tool version history (read) + Route::get('/servers/{server}/tools/{tool}/versions', [McpApiController::class, 'toolVersions']) + ->name('tools.versions'); + + // Specific tool version (read) + Route::get('/servers/{server}/tools/{tool}/versions/{version}', [McpApiController::class, 'toolVersion']) + ->name('tools.version'); + + // Tool execution (write) + Route::post('/tools/call', [McpApiController::class, 'callTool']) + ->name('tools.call'); + + // Resource access (read) + Route::get('/resources/{uri}', [McpApiController::class, 'resource']) + ->where('uri', '.*') + ->name('resources.show'); + }); diff --git a/src/Mod/Api/Services/ApiKeyService.php b/src/Mod/Api/Services/ApiKeyService.php new file mode 100644 index 0000000..2175826 --- /dev/null +++ b/src/Mod/Api/Services/ApiKeyService.php @@ -0,0 +1,217 @@ +active()->count(); + + if ($currentCount >= $maxKeys) { + throw new \RuntimeException( + "Workspace has reached the maximum number of API keys ({$maxKeys})" + ); + } + + $result = ApiKey::generate($workspaceId, $userId, $name, $scopes, $expiresAt); + + // Set server scopes if provided + if ($serverScopes !== null) { + $result['api_key']->update(['server_scopes' => $serverScopes]); + } + + Log::info('API key created', [ + 'key_id' => $result['api_key']->id, + 'workspace_id' => $workspaceId, + 'user_id' => $userId, + 'name' => $name, + ]); + + return $result; + } + + /** + * Rotate an existing API key. + * + * Creates a new key with the same settings, keeping the old key + * valid for a grace period to allow migration. + * + * @param int $gracePeriodHours Hours the old key remains valid (default: 24) + * @return array{api_key: ApiKey, plain_key: string, old_key: ApiKey} + */ + public function rotate(ApiKey $apiKey, int $gracePeriodHours = ApiKey::DEFAULT_GRACE_PERIOD_HOURS): array + { + // Don't rotate keys that are already being rotated out + if ($apiKey->isInGracePeriod()) { + throw new \RuntimeException( + 'This key is already being rotated. Wait for the grace period to end or end it manually.' + ); + } + + // Don't rotate revoked keys + if ($apiKey->trashed()) { + throw new \RuntimeException('Cannot rotate a revoked key.'); + } + + $result = $apiKey->rotate($gracePeriodHours); + + Log::info('API key rotated', [ + 'old_key_id' => $apiKey->id, + 'new_key_id' => $result['api_key']->id, + 'workspace_id' => $apiKey->workspace_id, + 'grace_period_hours' => $gracePeriodHours, + 'grace_period_ends_at' => $apiKey->fresh()->grace_period_ends_at?->toIso8601String(), + ]); + + return $result; + } + + /** + * Revoke an API key immediately. + */ + public function revoke(ApiKey $apiKey): void + { + $apiKey->revoke(); + + Log::info('API key revoked', [ + 'key_id' => $apiKey->id, + 'workspace_id' => $apiKey->workspace_id, + ]); + } + + /** + * End the grace period for a rotating key and revoke it. + */ + public function endGracePeriod(ApiKey $apiKey): void + { + if (! $apiKey->isInGracePeriod()) { + throw new \RuntimeException('This key is not in a grace period.'); + } + + $apiKey->endGracePeriod(); + + Log::info('API key grace period ended', [ + 'key_id' => $apiKey->id, + 'workspace_id' => $apiKey->workspace_id, + ]); + } + + /** + * Clean up keys with expired grace periods. + * + * This should be called by a scheduled command to revoke + * old keys after their grace period has ended. + * + * @return int Number of keys cleaned up + */ + public function cleanupExpiredGracePeriods(): int + { + $keys = ApiKey::gracePeriodExpired() + ->whereNull('deleted_at') + ->get(); + + $count = 0; + + foreach ($keys as $key) { + $key->revoke(); + $count++; + + Log::info('Cleaned up API key after grace period', [ + 'key_id' => $key->id, + 'workspace_id' => $key->workspace_id, + ]); + } + + return $count; + } + + /** + * Update API key scopes. + */ + public function updateScopes(ApiKey $apiKey, array $scopes): void + { + // Validate scopes + $validScopes = array_intersect($scopes, ApiKey::ALL_SCOPES); + + if (empty($validScopes)) { + throw new \InvalidArgumentException('At least one valid scope must be provided.'); + } + + $apiKey->update(['scopes' => array_values($validScopes)]); + + Log::info('API key scopes updated', [ + 'key_id' => $apiKey->id, + 'scopes' => $validScopes, + ]); + } + + /** + * Update API key server scopes. + */ + public function updateServerScopes(ApiKey $apiKey, ?array $serverScopes): void + { + $apiKey->update(['server_scopes' => $serverScopes]); + + Log::info('API key server scopes updated', [ + 'key_id' => $apiKey->id, + 'server_scopes' => $serverScopes, + ]); + } + + /** + * Rename an API key. + */ + public function rename(ApiKey $apiKey, string $name): void + { + $apiKey->update(['name' => $name]); + + Log::info('API key renamed', [ + 'key_id' => $apiKey->id, + 'name' => $name, + ]); + } + + /** + * Get statistics for a workspace's API keys. + */ + public function getStats(int $workspaceId): array + { + $keys = ApiKey::forWorkspace($workspaceId); + + return [ + 'total' => (clone $keys)->count(), + 'active' => (clone $keys)->active()->count(), + 'expired' => (clone $keys)->expired()->count(), + 'in_grace_period' => (clone $keys)->inGracePeriod()->count(), + 'revoked' => ApiKey::withTrashed() + ->forWorkspace($workspaceId) + ->whereNotNull('deleted_at') + ->count(), + ]; + } +} diff --git a/src/Mod/Api/Services/ApiSnippetService.php b/src/Mod/Api/Services/ApiSnippetService.php new file mode 100644 index 0000000..6a89a02 --- /dev/null +++ b/src/Mod/Api/Services/ApiSnippetService.php @@ -0,0 +1,427 @@ + 'cURL', + 'php' => 'PHP', + 'javascript' => 'JavaScript', + 'python' => 'Python', + 'ruby' => 'Ruby', + 'go' => 'Go', + 'java' => 'Java', + 'csharp' => 'C#', + 'swift' => 'Swift', + 'kotlin' => 'Kotlin', + 'rust' => 'Rust', + ]; + + /** + * Generate snippets for all supported languages. + */ + public function generateAll( + string $method, + string $endpoint, + array $headers = [], + ?array $body = null, + string $baseUrl = 'https://api.host.uk.com' + ): array { + $snippets = []; + + foreach (array_keys(self::LANGUAGES) as $language) { + $snippets[$language] = $this->generate($language, $method, $endpoint, $headers, $body, $baseUrl); + } + + return $snippets; + } + + /** + * Generate a snippet for a specific language. + */ + public function generate( + string $language, + string $method, + string $endpoint, + array $headers = [], + ?array $body = null, + string $baseUrl = 'https://api.host.uk.com' + ): string { + $url = rtrim($baseUrl, '/').'/'.ltrim($endpoint, '/'); + + // Add default headers + $headers = array_merge([ + 'Authorization' => 'Bearer YOUR_API_KEY', + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ], $headers); + + return match ($language) { + 'curl' => $this->generateCurl($method, $url, $headers, $body), + 'php' => $this->generatePhp($method, $url, $headers, $body), + 'javascript' => $this->generateJavaScript($method, $url, $headers, $body), + 'python' => $this->generatePython($method, $url, $headers, $body), + 'ruby' => $this->generateRuby($method, $url, $headers, $body), + 'go' => $this->generateGo($method, $url, $headers, $body), + 'java' => $this->generateJava($method, $url, $headers, $body), + 'csharp' => $this->generateCSharp($method, $url, $headers, $body), + 'swift' => $this->generateSwift($method, $url, $headers, $body), + 'kotlin' => $this->generateKotlin($method, $url, $headers, $body), + 'rust' => $this->generateRust($method, $url, $headers, $body), + default => "# Language '{$language}' not supported", + }; + } + + protected function generateCurl(string $method, string $url, array $headers, ?array $body): string + { + $lines = ["curl -X {$method} '{$url}' \\"]; + + foreach ($headers as $key => $value) { + $lines[] = " -H '{$key}: {$value}' \\"; + } + + if ($body) { + $json = json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $lines[] = " -d '{$json}'"; + } else { + // Remove trailing backslash from last header + $lastIndex = count($lines) - 1; + $lines[$lastIndex] = rtrim($lines[$lastIndex], ' \\'); + } + + return implode("\n", $lines); + } + + protected function generatePhp(string $method, string $url, array $headers, ?array $body): string + { + $headerStr = ''; + foreach ($headers as $key => $value) { + $headerStr .= " '{$key}' => '{$value}',\n"; + } + + $bodyStr = $body ? json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : 'null'; + + return <<phpMethod($method)}('{$url}', [ + 'headers' => [ +{$headerStr} ], + 'json' => {$bodyStr}, +]); + +\$data = \$response->json(); +PHP; + } + + protected function generateJavaScript(string $method, string $url, array $headers, ?array $body): string + { + $headerJson = json_encode($headers, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $bodyJson = $body ? json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : 'null'; + + return << $value) { + $headerLines[] = " \"{$key}\": \"{$value}\""; + } + $headerStr = implode(",\n", $headerLines); + + $bodyStr = $body ? json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : 'None'; + + return <<pythonMethod($method)}( + "{$url}", + headers={ +{$headerStr} + }, + json={$bodyStr} +) + +data = response.json() +PYTHON; + } + + protected function generateRuby(string $method, string $url, array $headers, ?array $body): string + { + $headerLines = []; + foreach ($headers as $key => $value) { + $headerLines[] = " \"{$key}\" => \"{$value}\""; + } + $headerStr = implode(",\n", $headerLines); + + $bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : 'nil'; + + return <<rubyMethod($method)}( + "{$url}", + headers: { +{$headerStr} + }, + body: {$bodyStr} +) + +data = JSON.parse(response.body) +RUBY; + } + + protected function generateGo(string $method, string $url, array $headers, ?array $body): string + { + $bodySetup = $body + ? 'jsonData, _ := json.Marshal(map[string]interface{}{'.$this->goMapEntries($body)."})\\n\\treq, _ := http.NewRequest(\"{$method}\", \"{$url}\", bytes.NewBuffer(jsonData))" + : "req, _ := http.NewRequest(\"{$method}\", \"{$url}\", nil)"; + + $headerLines = []; + foreach ($headers as $key => $value) { + $headerLines[] = "\treq.Header.Set(\"{$key}\", \"{$value}\")"; + } + $headerStr = implode("\n", $headerLines); + + return << $value) { + $headerLines[] = " .header(\"{$key}\", \"{$value}\")"; + } + $headerStr = implode("\n", $headerLines); + + $bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : '""'; + + return << response = client.send(request, HttpResponse.BodyHandlers.ofString()); +String body = response.body(); +JAVA; + } + + protected function generateCSharp(string $method, string $url, array $headers, ?array $body): string + { + $headerLines = []; + foreach ($headers as $key => $value) { + if ($key === 'Content-Type') { + continue; + } + $headerLines[] = "client.DefaultRequestHeaders.Add(\"{$key}\", \"{$value}\");"; + } + $headerStr = implode("\n", $headerLines); + + $bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : '""'; + + return <<csharpMethod($method)}Async("{$url}", content); + +var body = await response.Content.ReadAsStringAsync(); +CSHARP; + } + + protected function generateSwift(string $method, string $url, array $headers, ?array $body): string + { + $headerLines = []; + foreach ($headers as $key => $value) { + $headerLines[] = "request.setValue(\"{$value}\", forHTTPHeaderField: \"{$key}\")"; + } + $headerStr = implode("\n", $headerLines); + + $bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : 'nil'; + + return << $value) { + $headerLines[] = " .addHeader(\"{$key}\", \"{$value}\")"; + } + $headerStr = implode("\n", $headerLines); + + $bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : '""'; + + return << $value) { + $headerLines[] = " .header(\"{$key}\", \"{$value}\")"; + } + $headerStr = implode("\n", $headerLines); + + $bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : '""'; + + return <<rustMethod($method)}("{$url}") +{$headerStr} + .body("{$bodyStr}") + .send()?; + +let json: serde_json::Value = response.json()?; +RUST; + } + + // Helper methods for language-specific syntax + protected function phpMethod(string $method): string + { + return strtolower($method); + } + + protected function pythonMethod(string $method): string + { + return strtolower($method); + } + + protected function rubyMethod(string $method): string + { + return strtolower($method); + } + + protected function csharpMethod(string $method): string + { + return match (strtoupper($method)) { + 'GET' => 'Get', + 'POST' => 'Post', + 'PUT' => 'Put', + 'PATCH' => 'Patch', + 'DELETE' => 'Delete', + default => 'Send', + }; + } + + protected function rustMethod(string $method): string + { + return strtolower($method); + } + + protected function goMapEntries(array $data): string + { + $entries = []; + foreach ($data as $key => $value) { + $val = is_string($value) ? "\"{$value}\"" : json_encode($value); + $entries[] = "\"{$key}\": {$val}"; + } + + return implode(', ', $entries); + } + + /** + * Get language metadata for UI display. + */ + public static function getLanguages(): array + { + return collect(self::LANGUAGES)->map(fn ($name, $code) => [ + 'code' => $code, + 'name' => $name, + 'icon' => self::getLanguageIcon($code), + ])->values()->all(); + } + + /** + * Get icon class for a language. + */ + public static function getLanguageIcon(string $code): string + { + return match ($code) { + 'curl' => 'terminal', + 'php' => 'code-bracket', + 'javascript' => 'code-bracket-square', + 'python' => 'code-bracket', + 'ruby' => 'sparkles', + 'go' => 'cube', + 'java' => 'fire', + 'csharp' => 'window', + 'swift' => 'bolt', + 'kotlin' => 'beaker', + 'rust' => 'cog', + default => 'code-bracket', + }; + } +} diff --git a/src/Mod/Api/Services/ApiUsageService.php b/src/Mod/Api/Services/ApiUsageService.php new file mode 100644 index 0000000..204f444 --- /dev/null +++ b/src/Mod/Api/Services/ApiUsageService.php @@ -0,0 +1,361 @@ +normaliseEndpoint($endpoint); + + // Record individual usage + $usage = ApiUsage::record( + $apiKeyId, + $workspaceId, + $normalisedEndpoint, + $method, + $statusCode, + $responseTimeMs, + $requestSize, + $responseSize, + $ipAddress, + $userAgent + ); + + // Update daily aggregation + ApiUsageDaily::recordFromUsage($usage); + + return $usage; + } + + /** + * Get usage summary for a workspace. + */ + public function getWorkspaceSummary( + int $workspaceId, + ?Carbon $startDate = null, + ?Carbon $endDate = null + ): array { + $startDate = $startDate ?? now()->subDays(30); + $endDate = $endDate ?? now(); + + $query = ApiUsageDaily::forWorkspace($workspaceId) + ->between($startDate, $endDate); + + $totals = (clone $query)->selectRaw(' + SUM(request_count) as total_requests, + SUM(success_count) as total_success, + SUM(error_count) as total_errors, + SUM(total_response_time_ms) as total_response_time, + MIN(min_response_time_ms) as min_response_time, + MAX(max_response_time_ms) as max_response_time, + SUM(total_request_size) as total_request_size, + SUM(total_response_size) as total_response_size + ')->first(); + + $totalRequests = (int) ($totals->total_requests ?? 0); + $totalSuccess = (int) ($totals->total_success ?? 0); + + return [ + 'period' => [ + 'start' => $startDate->toIso8601String(), + 'end' => $endDate->toIso8601String(), + ], + 'totals' => [ + 'requests' => $totalRequests, + 'success' => $totalSuccess, + 'errors' => (int) ($totals->total_errors ?? 0), + 'success_rate' => $totalRequests > 0 + ? round(($totalSuccess / $totalRequests) * 100, 2) + : 100, + ], + 'response_time' => [ + 'average_ms' => $totalRequests > 0 + ? round((int) $totals->total_response_time / $totalRequests, 2) + : 0, + 'min_ms' => (int) ($totals->min_response_time ?? 0), + 'max_ms' => (int) ($totals->max_response_time ?? 0), + ], + 'data_transfer' => [ + 'request_bytes' => (int) ($totals->total_request_size ?? 0), + 'response_bytes' => (int) ($totals->total_response_size ?? 0), + ], + ]; + } + + /** + * Get usage summary for a specific API key. + */ + public function getKeySummary( + int $apiKeyId, + ?Carbon $startDate = null, + ?Carbon $endDate = null + ): array { + $startDate = $startDate ?? now()->subDays(30); + $endDate = $endDate ?? now(); + + $query = ApiUsageDaily::forKey($apiKeyId) + ->between($startDate, $endDate); + + $totals = (clone $query)->selectRaw(' + SUM(request_count) as total_requests, + SUM(success_count) as total_success, + SUM(error_count) as total_errors, + SUM(total_response_time_ms) as total_response_time, + MIN(min_response_time_ms) as min_response_time, + MAX(max_response_time_ms) as max_response_time + ')->first(); + + $totalRequests = (int) ($totals->total_requests ?? 0); + $totalSuccess = (int) ($totals->total_success ?? 0); + + return [ + 'period' => [ + 'start' => $startDate->toIso8601String(), + 'end' => $endDate->toIso8601String(), + ], + 'totals' => [ + 'requests' => $totalRequests, + 'success' => $totalSuccess, + 'errors' => (int) ($totals->total_errors ?? 0), + 'success_rate' => $totalRequests > 0 + ? round(($totalSuccess / $totalRequests) * 100, 2) + : 100, + ], + 'response_time' => [ + 'average_ms' => $totalRequests > 0 + ? round((int) $totals->total_response_time / $totalRequests, 2) + : 0, + 'min_ms' => (int) ($totals->min_response_time ?? 0), + 'max_ms' => (int) ($totals->max_response_time ?? 0), + ], + ]; + } + + /** + * Get daily usage chart data. + */ + public function getDailyChart( + int $workspaceId, + ?Carbon $startDate = null, + ?Carbon $endDate = null + ): array { + $startDate = $startDate ?? now()->subDays(30); + $endDate = $endDate ?? now(); + + $data = ApiUsageDaily::forWorkspace($workspaceId) + ->between($startDate, $endDate) + ->selectRaw(' + date, + SUM(request_count) as requests, + SUM(success_count) as success, + SUM(error_count) as errors, + SUM(total_response_time_ms) / NULLIF(SUM(request_count), 0) as avg_response_time + ') + ->groupBy('date') + ->orderBy('date') + ->get(); + + return $data->map(fn ($row) => [ + 'date' => $row->date->toDateString(), + 'requests' => (int) $row->requests, + 'success' => (int) $row->success, + 'errors' => (int) $row->errors, + 'avg_response_time_ms' => round((float) ($row->avg_response_time ?? 0), 2), + ])->all(); + } + + /** + * Get top endpoints by request count. + */ + public function getTopEndpoints( + int $workspaceId, + int $limit = 10, + ?Carbon $startDate = null, + ?Carbon $endDate = null + ): array { + $startDate = $startDate ?? now()->subDays(30); + $endDate = $endDate ?? now(); + + return ApiUsageDaily::forWorkspace($workspaceId) + ->between($startDate, $endDate) + ->selectRaw(' + endpoint, + method, + SUM(request_count) as requests, + SUM(success_count) as success, + SUM(error_count) as errors, + SUM(total_response_time_ms) / NULLIF(SUM(request_count), 0) as avg_response_time + ') + ->groupBy('endpoint', 'method') + ->orderByDesc('requests') + ->limit($limit) + ->get() + ->map(fn ($row) => [ + 'endpoint' => $row->endpoint, + 'method' => $row->method, + 'requests' => (int) $row->requests, + 'success' => (int) $row->success, + 'errors' => (int) $row->errors, + 'success_rate' => $row->requests > 0 + ? round(($row->success / $row->requests) * 100, 2) + : 100, + 'avg_response_time_ms' => round((float) ($row->avg_response_time ?? 0), 2), + ]) + ->all(); + } + + /** + * Get error breakdown by status code. + */ + public function getErrorBreakdown( + int $workspaceId, + ?Carbon $startDate = null, + ?Carbon $endDate = null + ): array { + $startDate = $startDate ?? now()->subDays(30); + $endDate = $endDate ?? now(); + + return ApiUsage::forWorkspace($workspaceId) + ->between($startDate, $endDate) + ->where('status_code', '>=', 400) + ->selectRaw('status_code, COUNT(*) as count') + ->groupBy('status_code') + ->orderByDesc('count') + ->get() + ->map(fn ($row) => [ + 'status_code' => $row->status_code, + 'count' => (int) $row->count, + 'description' => $this->getStatusCodeDescription($row->status_code), + ]) + ->all(); + } + + /** + * Get API key usage comparison. + */ + public function getKeyComparison( + int $workspaceId, + ?Carbon $startDate = null, + ?Carbon $endDate = null + ): array { + $startDate = $startDate ?? now()->subDays(30); + $endDate = $endDate ?? now(); + + $aggregated = ApiUsageDaily::forWorkspace($workspaceId) + ->between($startDate, $endDate) + ->selectRaw(' + api_key_id, + SUM(request_count) as requests, + SUM(success_count) as success, + SUM(error_count) as errors, + SUM(total_response_time_ms) / NULLIF(SUM(request_count), 0) as avg_response_time + ') + ->groupBy('api_key_id') + ->orderByDesc('requests') + ->get(); + + // Fetch API keys separately to avoid broken eager loading with aggregation + $apiKeyIds = $aggregated->pluck('api_key_id')->filter()->unique()->all(); + $apiKeys = \Mod\Api\Models\ApiKey::whereIn('id', $apiKeyIds) + ->select('id', 'name', 'prefix') + ->get() + ->keyBy('id'); + + return $aggregated->map(fn ($row) => [ + 'api_key_id' => $row->api_key_id, + 'api_key_name' => $apiKeys->get($row->api_key_id)?->name ?? 'Unknown', + 'api_key_prefix' => $apiKeys->get($row->api_key_id)?->prefix ?? 'N/A', + 'requests' => (int) $row->requests, + 'success' => (int) $row->success, + 'errors' => (int) $row->errors, + 'success_rate' => $row->requests > 0 + ? round(($row->success / $row->requests) * 100, 2) + : 100, + 'avg_response_time_ms' => round((float) ($row->avg_response_time ?? 0), 2), + ])->all(); + } + + /** + * Normalise endpoint path for aggregation. + * + * Replaces dynamic IDs with placeholders for consistent grouping. + */ + protected function normaliseEndpoint(string $endpoint): string + { + // Remove query string + $path = parse_url($endpoint, PHP_URL_PATH) ?? $endpoint; + + // Replace numeric IDs with {id} placeholder + $normalised = preg_replace('/\/\d+/', '/{id}', $path); + + // Replace UUIDs with {uuid} placeholder + $normalised = preg_replace( + '/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i', + '/{uuid}', + $normalised + ); + + return $normalised ?? $path; + } + + /** + * Get human-readable status code description. + */ + protected function getStatusCodeDescription(int $statusCode): string + { + return match ($statusCode) { + 400 => 'Bad Request', + 401 => 'Unauthorised', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 422 => 'Validation Failed', + 429 => 'Rate Limit Exceeded', + 500 => 'Internal Server Error', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + default => 'Error', + }; + } + + /** + * Prune old detailed usage records. + * + * Keeps aggregated daily data but removes detailed logs older than retention period. + * + * @return int Number of records deleted + */ + public function pruneOldRecords(int $retentionDays = 30): int + { + $cutoff = now()->subDays($retentionDays); + + return ApiUsage::where('created_at', '<', $cutoff)->delete(); + } +} diff --git a/src/Mod/Api/Services/WebhookService.php b/src/Mod/Api/Services/WebhookService.php new file mode 100644 index 0000000..4a77d5c --- /dev/null +++ b/src/Mod/Api/Services/WebhookService.php @@ -0,0 +1,192 @@ + The created delivery records + */ + public function dispatch(int $workspaceId, string $eventType, array $data): array + { + // Find all active endpoints for this workspace that subscribe to this event + $endpoints = WebhookEndpoint::query() + ->forWorkspace($workspaceId) + ->active() + ->forEvent($eventType) + ->get(); + + if ($endpoints->isEmpty()) { + Log::debug('No webhook endpoints found for event', [ + 'workspace_id' => $workspaceId, + 'event_type' => $eventType, + ]); + + return []; + } + + $deliveries = []; + + // Wrap all deliveries in a transaction to ensure atomicity + DB::transaction(function () use ($endpoints, $eventType, $data, $workspaceId, &$deliveries) { + foreach ($endpoints as $endpoint) { + // Create delivery record + $delivery = WebhookDelivery::createForEvent( + $endpoint, + $eventType, + $data, + $workspaceId + ); + + $deliveries[] = $delivery; + + // Queue the delivery job after the transaction commits + DeliverWebhookJob::dispatch($delivery)->afterCommit(); + + Log::info('Webhook delivery queued', [ + 'delivery_id' => $delivery->id, + 'endpoint_id' => $endpoint->id, + 'event_type' => $eventType, + ]); + } + }); + + return $deliveries; + } + + /** + * Retry a specific failed delivery. + * + * @return bool True if retry was queued, false if not eligible + */ + public function retry(WebhookDelivery $delivery): bool + { + if (! $delivery->canRetry()) { + return false; + } + + DB::transaction(function () use ($delivery) { + // Reset status for manual retry but preserve attempt history + $delivery->update([ + 'status' => WebhookDelivery::STATUS_PENDING, + 'next_retry_at' => null, + ]); + + DeliverWebhookJob::dispatch($delivery)->afterCommit(); + + Log::info('Manual webhook retry queued', [ + 'delivery_id' => $delivery->id, + 'attempt' => $delivery->attempt, + ]); + }); + + return true; + } + + /** + * Process all pending and retryable deliveries. + * + * This method is typically called by a scheduled command. + * + * @return int Number of deliveries queued + */ + public function processQueue(): int + { + $count = 0; + + // Process deliveries one at a time with row locking to prevent race conditions + $deliveryIds = WebhookDelivery::query() + ->needsDelivery() + ->limit(100) + ->pluck('id'); + + foreach ($deliveryIds as $deliveryId) { + DB::transaction(function () use ($deliveryId, &$count) { + // Lock the row for update to prevent concurrent processing + $delivery = WebhookDelivery::query() + ->with('endpoint') + ->where('id', $deliveryId) + ->lockForUpdate() + ->first(); + + if (! $delivery) { + return; + } + + // Skip if already being processed (status changed since initial query) + if (! in_array($delivery->status, [WebhookDelivery::STATUS_PENDING, WebhookDelivery::STATUS_RETRYING])) { + return; + } + + // Handle inactive endpoints by cancelling the delivery + if (! $delivery->endpoint?->shouldReceive($delivery->event_type)) { + $delivery->update(['status' => WebhookDelivery::STATUS_CANCELLED]); + + return; + } + + // Mark as queued to prevent duplicate processing + $delivery->update(['status' => WebhookDelivery::STATUS_QUEUED]); + + DeliverWebhookJob::dispatch($delivery)->afterCommit(); + $count++; + }); + } + + if ($count > 0) { + Log::info('Processed webhook queue', ['count' => $count]); + } + + return $count; + } + + /** + * Get delivery statistics for a workspace. + */ + public function getStats(int $workspaceId): array + { + $endpointIds = WebhookEndpoint::query() + ->forWorkspace($workspaceId) + ->pluck('id'); + + if ($endpointIds->isEmpty()) { + return [ + 'total' => 0, + 'pending' => 0, + 'success' => 0, + 'failed' => 0, + 'retrying' => 0, + ]; + } + + $deliveries = WebhookDelivery::query() + ->whereIn('webhook_endpoint_id', $endpointIds); + + return [ + 'total' => (clone $deliveries)->count(), + 'pending' => (clone $deliveries)->where('status', WebhookDelivery::STATUS_PENDING)->count(), + 'success' => (clone $deliveries)->where('status', WebhookDelivery::STATUS_SUCCESS)->count(), + 'failed' => (clone $deliveries)->where('status', WebhookDelivery::STATUS_FAILED)->count(), + 'retrying' => (clone $deliveries)->where('status', WebhookDelivery::STATUS_RETRYING)->count(), + ]; + } +} diff --git a/src/Mod/Api/Services/WebhookSignature.php b/src/Mod/Api/Services/WebhookSignature.php new file mode 100644 index 0000000..400f032 --- /dev/null +++ b/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/src/Mod/Api/Tests/Feature/ApiKeyRotationTest.php b/src/Mod/Api/Tests/Feature/ApiKeyRotationTest.php new file mode 100644 index 0000000..86c2f5c --- /dev/null +++ b/src/Mod/Api/Tests/Feature/ApiKeyRotationTest.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, + ]); + $this->service = app(ApiKeyService::class); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// API Key Rotation +// ───────────────────────────────────────────────────────────────────────────── + +describe('API Key Rotation', function () { + it('rotates a key creating new key with same settings', function () { + $original = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Original Key', + [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE] + ); + + $result = $this->service->rotate($original['api_key']); + + expect($result)->toHaveKeys(['api_key', 'plain_key', 'old_key']); + expect($result['api_key']->name)->toBe('Original Key'); + expect($result['api_key']->scopes)->toBe([ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]); + expect($result['api_key']->workspace_id)->toBe($this->workspace->id); + expect($result['api_key']->rotated_from_id)->toBe($original['api_key']->id); + }); + + it('sets grace period on old key during rotation', function () { + $original = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Grace Period Key' + ); + + $result = $this->service->rotate($original['api_key'], 24); + + $oldKey = $result['old_key']->fresh(); + expect($oldKey->grace_period_ends_at)->not->toBeNull(); + expect($oldKey->isInGracePeriod())->toBeTrue(); + }); + + it('old key remains valid during grace period', function () { + $original = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Still Valid Key' + ); + + $this->service->rotate($original['api_key'], 24); + + // Old key should still be findable + $foundKey = ApiKey::findByPlainKey($original['plain_key']); + expect($foundKey)->not->toBeNull(); + expect($foundKey->id)->toBe($original['api_key']->id); + }); + + it('old key becomes invalid after grace period expires', function () { + $original = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Expired Grace Key' + ); + + $original['api_key']->update([ + 'grace_period_ends_at' => now()->subHour(), + ]); + + $foundKey = ApiKey::findByPlainKey($original['plain_key']); + expect($foundKey)->toBeNull(); + }); + + it('prevents rotating key already in grace period', function () { + $original = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Already Rotating Key' + ); + + $this->service->rotate($original['api_key']); + + expect(fn () => $this->service->rotate($original['api_key']->fresh())) + ->toThrow(\RuntimeException::class); + }); + + it('can end grace period early', function () { + $original = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Early End Key' + ); + + $this->service->rotate($original['api_key'], 24); + $this->service->endGracePeriod($original['api_key']->fresh()); + + expect($original['api_key']->fresh()->trashed())->toBeTrue(); + }); + + it('preserves server scopes during rotation', function () { + $original = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Server Scoped Key' + ); + $original['api_key']->update(['server_scopes' => ['commerce', 'biohost']]); + + $result = $this->service->rotate($original['api_key']->fresh()); + + expect($result['api_key']->server_scopes)->toBe(['commerce', 'biohost']); + }); + + it('cleans up keys with expired grace periods', function () { + // Create keys with expired grace periods + $key1 = ApiKey::generate($this->workspace->id, $this->user->id, 'Expired 1'); + $key1['api_key']->update(['grace_period_ends_at' => now()->subDay()]); + + $key2 = ApiKey::generate($this->workspace->id, $this->user->id, 'Expired 2'); + $key2['api_key']->update(['grace_period_ends_at' => now()->subHour()]); + + // Create key still in grace period + $key3 = ApiKey::generate($this->workspace->id, $this->user->id, 'Still Active'); + $key3['api_key']->update(['grace_period_ends_at' => now()->addDay()]); + + $cleaned = $this->service->cleanupExpiredGracePeriods(); + + expect($cleaned)->toBe(2); + expect($key1['api_key']->fresh()->trashed())->toBeTrue(); + expect($key2['api_key']->fresh()->trashed())->toBeTrue(); + expect($key3['api_key']->fresh()->trashed())->toBeFalse(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// API Key Scopes via Service +// ───────────────────────────────────────────────────────────────────────────── + +describe('API Key Service Scopes', function () { + it('updates key scopes', function () { + $result = $this->service->create( + $this->workspace->id, + $this->user->id, + 'Scoped Key' + ); + + $this->service->updateScopes($result['api_key'], [ApiKey::SCOPE_READ]); + + expect($result['api_key']->fresh()->scopes)->toBe([ApiKey::SCOPE_READ]); + }); + + it('requires at least one valid scope', function () { + $result = $this->service->create( + $this->workspace->id, + $this->user->id, + 'Invalid Scopes Key' + ); + + expect(fn () => $this->service->updateScopes($result['api_key'], ['invalid'])) + ->toThrow(\InvalidArgumentException::class); + }); + + it('updates server scopes', function () { + $result = $this->service->create( + $this->workspace->id, + $this->user->id, + 'Server Scoped Key' + ); + + $this->service->updateServerScopes($result['api_key'], ['commerce']); + + expect($result['api_key']->fresh()->server_scopes)->toBe(['commerce']); + }); + + it('clears server scopes with null', function () { + $result = $this->service->create( + $this->workspace->id, + $this->user->id, + 'Clear Server Scopes Key', + serverScopes: ['commerce'] + ); + + $this->service->updateServerScopes($result['api_key'], null); + + expect($result['api_key']->fresh()->server_scopes)->toBeNull(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// API Key Service Limits +// ───────────────────────────────────────────────────────────────────────────── + +describe('API Key Service Limits', function () { + it('enforces max keys per workspace limit', function () { + config(['api.keys.max_per_workspace' => 2]); + + $this->service->create($this->workspace->id, $this->user->id, 'Key 1'); + $this->service->create($this->workspace->id, $this->user->id, 'Key 2'); + + expect(fn () => $this->service->create($this->workspace->id, $this->user->id, 'Key 3')) + ->toThrow(\RuntimeException::class); + }); + + it('returns workspace key statistics', function () { + $key1 = $this->service->create($this->workspace->id, $this->user->id, 'Active Key'); + $key2 = $this->service->create($this->workspace->id, $this->user->id, 'Expired Key'); + $key2['api_key']->update(['expires_at' => now()->subDay()]); + + $key3 = $this->service->create($this->workspace->id, $this->user->id, 'Rotating Key'); + $this->service->rotate($key3['api_key']); + + $stats = $this->service->getStats($this->workspace->id); + + expect($stats)->toHaveKeys(['total', 'active', 'expired', 'in_grace_period', 'revoked']); + expect($stats['total'])->toBe(4); // 3 original + 1 rotated + expect($stats['expired'])->toBe(1); + expect($stats['in_grace_period'])->toBe(1); + }); +}); diff --git a/src/Mod/Api/Tests/Feature/ApiKeySecurityTest.php b/src/Mod/Api/Tests/Feature/ApiKeySecurityTest.php new file mode 100644 index 0000000..d9f0545 --- /dev/null +++ b/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/src/Mod/Api/Tests/Feature/ApiKeyTest.php b/src/Mod/Api/Tests/Feature/ApiKeyTest.php new file mode 100644 index 0000000..109811c --- /dev/null +++ b/src/Mod/Api/Tests/Feature/ApiKeyTest.php @@ -0,0 +1,617 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// API Key Creation +// ───────────────────────────────────────────────────────────────────────────── + +describe('API Key Creation', function () { + it('generates a new API key with correct format', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Test API Key' + ); + + expect($result)->toHaveKeys(['api_key', 'plain_key']); + expect($result['api_key'])->toBeInstanceOf(ApiKey::class); + expect($result['plain_key'])->toStartWith('hk_'); + + // Plain key format: hk_xxxxxxxx_xxxx... + $parts = explode('_', $result['plain_key']); + expect($parts)->toHaveCount(3); + expect($parts[0])->toBe('hk'); + expect(strlen($parts[1]))->toBe(8); + expect(strlen($parts[2]))->toBe(48); + }); + + it('creates key with default read and write scopes', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Default Scopes Key' + ); + + expect($result['api_key']->scopes)->toBe([ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]); + }); + + it('creates key with custom scopes', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Full Access Key', + [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE, ApiKey::SCOPE_DELETE] + ); + + expect($result['api_key']->scopes)->toBe(ApiKey::ALL_SCOPES); + }); + + it('creates key with expiry date', function () { + $expiresAt = now()->addDays(30); + + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Expiring Key', + [ApiKey::SCOPE_READ], + $expiresAt + ); + + expect($result['api_key']->expires_at)->not->toBeNull(); + expect($result['api_key']->expires_at->timestamp)->toBe($expiresAt->timestamp); + }); + + it('stores key as bcrypt hashed value', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Hashed Key' + ); + + // Extract the key part from plain key + $parts = explode('_', $result['plain_key'], 3); + $keyPart = $parts[2]; + + // 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(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// API Key Authentication +// ───────────────────────────────────────────────────────────────────────────── + +describe('API Key Authentication', function () { + it('finds key by valid plain key', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Findable Key' + ); + + $foundKey = ApiKey::findByPlainKey($result['plain_key']); + + expect($foundKey)->not->toBeNull(); + expect($foundKey->id)->toBe($result['api_key']->id); + }); + + it('returns null for invalid key format', function () { + expect(ApiKey::findByPlainKey('invalid-key'))->toBeNull(); + expect(ApiKey::findByPlainKey('hk_only_two_parts'))->toBeNull(); + expect(ApiKey::findByPlainKey(''))->toBeNull(); + }); + + it('returns null for non-existent key', function () { + $result = ApiKey::findByPlainKey('hk_nonexist_'.str_repeat('x', 48)); + + expect($result)->toBeNull(); + }); + + it('returns null for expired key', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Expired Key', + [ApiKey::SCOPE_READ], + now()->subDay() // Already expired + ); + + $foundKey = ApiKey::findByPlainKey($result['plain_key']); + + expect($foundKey)->toBeNull(); + }); + + it('returns null for revoked (soft-deleted) key', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Revoked Key' + ); + + $result['api_key']->revoke(); + + $foundKey = ApiKey::findByPlainKey($result['plain_key']); + + expect($foundKey)->toBeNull(); + }); + + it('records usage on authentication', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Tracking Key' + ); + + expect($result['api_key']->last_used_at)->toBeNull(); + + $result['api_key']->recordUsage(); + + expect($result['api_key']->fresh()->last_used_at)->not->toBeNull(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scope Checking +// ───────────────────────────────────────────────────────────────────────────── + +describe('Scope Checking', function () { + it('checks for single scope', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Scoped Key', + [ApiKey::SCOPE_READ] + ); + + $key = $result['api_key']; + + expect($key->hasScope(ApiKey::SCOPE_READ))->toBeTrue(); + expect($key->hasScope(ApiKey::SCOPE_WRITE))->toBeFalse(); + expect($key->hasScope(ApiKey::SCOPE_DELETE))->toBeFalse(); + }); + + it('checks for multiple scopes', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Multi-Scoped Key', + [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE] + ); + + $key = $result['api_key']; + + expect($key->hasScopes([ApiKey::SCOPE_READ]))->toBeTrue(); + expect($key->hasScopes([ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]))->toBeTrue(); + expect($key->hasScopes([ApiKey::SCOPE_READ, ApiKey::SCOPE_DELETE]))->toBeFalse(); + }); + + it('returns available scope constants', function () { + expect(ApiKey::SCOPE_READ)->toBe('read'); + expect(ApiKey::SCOPE_WRITE)->toBe('write'); + expect(ApiKey::SCOPE_DELETE)->toBe('delete'); + expect(ApiKey::ALL_SCOPES)->toBe(['read', 'write', 'delete']); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Expiry Handling +// ───────────────────────────────────────────────────────────────────────────── + +describe('Expiry Handling', function () { + it('detects expired key', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Past Expiry Key', + [ApiKey::SCOPE_READ], + now()->subDay() + ); + + expect($result['api_key']->isExpired())->toBeTrue(); + }); + + it('detects non-expired key', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Future Expiry Key', + [ApiKey::SCOPE_READ], + now()->addDay() + ); + + expect($result['api_key']->isExpired())->toBeFalse(); + }); + + it('keys without expiry are never expired', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'No Expiry Key' + ); + + expect($result['api_key']->expires_at)->toBeNull(); + expect($result['api_key']->isExpired())->toBeFalse(); + }); + + it('scopes expired keys correctly', function () { + // Create expired key + ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Expired Key 1', + [ApiKey::SCOPE_READ], + now()->subDays(2) + ); + + // Create active key + ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Active Key', + [ApiKey::SCOPE_READ], + now()->addDays(30) + ); + + // Create no-expiry key + ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'No Expiry Key' + ); + + $expired = ApiKey::expired()->count(); + $active = ApiKey::active()->count(); + + expect($expired)->toBe(1); + expect($active)->toBe(2); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Server Scopes (MCP Access) +// ───────────────────────────────────────────────────────────────────────────── + +describe('Server Scopes', function () { + it('allows all servers when server_scopes is null', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'All Servers Key' + ); + + $key = $result['api_key']; + + expect($key->server_scopes)->toBeNull(); + expect($key->hasServerAccess('commerce'))->toBeTrue(); + expect($key->hasServerAccess('biohost'))->toBeTrue(); + expect($key->hasServerAccess('anything'))->toBeTrue(); + }); + + it('restricts to specific servers when server_scopes is set', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Limited Servers Key' + ); + + $key = $result['api_key']; + $key->update(['server_scopes' => ['commerce', 'biohost']]); + + expect($key->hasServerAccess('commerce'))->toBeTrue(); + expect($key->hasServerAccess('biohost'))->toBeTrue(); + expect($key->hasServerAccess('analytics'))->toBeFalse(); + }); + + it('returns allowed servers list', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Specific Servers Key' + ); + + $key = $result['api_key']; + $key->update(['server_scopes' => ['commerce']]); + + expect($key->getAllowedServers())->toBe(['commerce']); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Key Revocation +// ───────────────────────────────────────────────────────────────────────────── + +describe('Key Revocation', function () { + it('revokes key via soft delete', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'To Be Revoked' + ); + + $key = $result['api_key']; + $keyId = $key->id; + + $key->revoke(); + + // Should be soft deleted + expect(ApiKey::find($keyId))->toBeNull(); + expect(ApiKey::withTrashed()->find($keyId))->not->toBeNull(); + }); + + it('revoked keys are excluded from workspace scope', function () { + // Create active key + ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Active Key' + ); + + // Create and revoke a key + $revokedResult = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Revoked Key' + ); + $revokedResult['api_key']->revoke(); + + $keys = ApiKey::forWorkspace($this->workspace->id)->get(); + + expect($keys)->toHaveCount(1); + expect($keys->first()->name)->toBe('Active Key'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Masked Key Display +// ───────────────────────────────────────────────────────────────────────────── + +describe('Masked Key Display', function () { + it('provides masked key for display', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Masked Key' + ); + + $key = $result['api_key']; + $maskedKey = $key->masked_key; + + expect($maskedKey)->toStartWith($key->prefix); + expect($maskedKey)->toEndWith('_****'); + expect($maskedKey)->toBe("{$key->prefix}_****"); + }); + + it('hides raw key in JSON serialization', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Hidden Key' + ); + + $json = $result['api_key']->toArray(); + + expect($json)->not->toHaveKey('key'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Relationships +// ───────────────────────────────────────────────────────────────────────────── + +describe('Relationships', function () { + it('belongs to workspace', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Workspace Key' + ); + + expect($result['api_key']->workspace->id)->toBe($this->workspace->id); + }); + + it('belongs to user', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'User Key' + ); + + expect($result['api_key']->user->id)->toBe($this->user->id); + }); + + it('is deleted when workspace is deleted', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Cascade Key' + ); + + $keyId = $result['api_key']->id; + + $this->workspace->delete(); + + expect(ApiKey::withTrashed()->find($keyId))->toBeNull(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Factory Tests +// ───────────────────────────────────────────────────────────────────────────── + +describe('Factory', function () { + it('creates key via factory', function () { + $key = ApiKey::factory() + ->for($this->workspace) + ->for($this->user) + ->create(); + + expect($key)->toBeInstanceOf(ApiKey::class); + expect($key->workspace_id)->toBe($this->workspace->id); + expect($key->user_id)->toBe($this->user->id); + }); + + it('creates read-only key via factory', function () { + $key = ApiKey::factory() + ->for($this->workspace) + ->for($this->user) + ->readOnly() + ->create(); + + expect($key->scopes)->toBe([ApiKey::SCOPE_READ]); + }); + + it('creates full access key via factory', function () { + $key = ApiKey::factory() + ->for($this->workspace) + ->for($this->user) + ->fullAccess() + ->create(); + + expect($key->scopes)->toBe(ApiKey::ALL_SCOPES); + }); + + it('creates expired key via factory', function () { + $key = ApiKey::factory() + ->for($this->workspace) + ->for($this->user) + ->expired() + ->create(); + + expect($key->isExpired())->toBeTrue(); + }); + + it('creates key with known credentials via helper', function () { + $result = ApiKeyFactory::createWithPlainKey( + $this->workspace, + $this->user, + [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE] + ); + + expect($result)->toHaveKeys(['api_key', 'plain_key']); + + // Verify the plain key works for lookup + $foundKey = ApiKey::findByPlainKey($result['plain_key']); + expect($foundKey)->not->toBeNull(); + expect($foundKey->id)->toBe($result['api_key']->id); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Rate Limiting (Integration) +// ───────────────────────────────────────────────────────────────────────────── + +describe('Rate Limiting Configuration', function () { + it('has default rate limits configured', function () { + $default = config('api.rate_limits.default'); + + expect($default)->toHaveKeys(['requests', 'per_minutes']); + expect($default['requests'])->toBeInt(); + expect($default['per_minutes'])->toBeInt(); + }); + + it('has authenticated rate limits configured', function () { + $authenticated = config('api.rate_limits.authenticated'); + + expect($authenticated)->toHaveKeys(['requests', 'per_minutes']); + expect($authenticated['requests'])->toBeGreaterThan(config('api.rate_limits.default.requests')); + }); + + it('has tier-based rate limits configured', function () { + $tiers = ['starter', 'pro', 'agency', 'enterprise']; + + foreach ($tiers as $tier) { + $limits = config("api.rate_limits.by_tier.{$tier}"); + expect($limits)->toHaveKeys(['requests', 'per_minutes']); + } + }); + + it('tier limits increase with tier level', function () { + $starter = config('api.rate_limits.by_tier.starter.requests'); + $pro = config('api.rate_limits.by_tier.pro.requests'); + $agency = config('api.rate_limits.by_tier.agency.requests'); + $enterprise = config('api.rate_limits.by_tier.enterprise.requests'); + + expect($pro)->toBeGreaterThan($starter); + expect($agency)->toBeGreaterThan($pro); + expect($enterprise)->toBeGreaterThan($agency); + }); + + it('has route-level rate limit names configured', function () { + $routeLimits = config('api.rate_limits.routes'); + + expect($routeLimits)->toBeArray(); + expect($routeLimits)->toHaveKeys(['mcp', 'pixel']); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// HTTP Authentication Tests +// ───────────────────────────────────────────────────────────────────────────── + +describe('HTTP Authentication', function () { + it('requires authorization header', function () { + $response = $this->getJson('/api/mcp/servers'); + + expect($response->status())->toBe(401); + expect($response->json('error'))->toBe('unauthorized'); + }); + + it('rejects invalid API key', function () { + $response = $this->getJson('/api/mcp/servers', [ + 'Authorization' => 'Bearer hk_invalid_'.str_repeat('x', 48), + ]); + + expect($response->status())->toBe(401); + }); + + it('rejects expired API key via HTTP', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Expired HTTP Key', + [ApiKey::SCOPE_READ], + now()->subDay() + ); + + $response = $this->getJson('/api/mcp/servers', [ + 'Authorization' => "Bearer {$result['plain_key']}", + ]); + + expect($response->status())->toBe(401); + }); +}); diff --git a/src/Mod/Api/Tests/Feature/ApiScopeEnforcementTest.php b/src/Mod/Api/Tests/Feature/ApiScopeEnforcementTest.php new file mode 100644 index 0000000..ec6f630 --- /dev/null +++ b/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/src/Mod/Api/Tests/Feature/ApiUsageTest.php b/src/Mod/Api/Tests/Feature/ApiUsageTest.php new file mode 100644 index 0000000..20c3f0d --- /dev/null +++ b/src/Mod/Api/Tests/Feature/ApiUsageTest.php @@ -0,0 +1,362 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + + $result = ApiKey::generate($this->workspace->id, $this->user->id, 'Test Key'); + $this->apiKey = $result['api_key']; + + $this->service = app(ApiUsageService::class); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Recording Usage +// ───────────────────────────────────────────────────────────────────────────── + +describe('Recording API Usage', function () { + it('records individual usage entries', function () { + $usage = $this->service->record( + apiKeyId: $this->apiKey->id, + workspaceId: $this->workspace->id, + endpoint: '/api/v1/workspaces', + method: 'GET', + statusCode: 200, + responseTimeMs: 150, + requestSize: 0, + responseSize: 1024 + ); + + expect($usage)->toBeInstanceOf(ApiUsage::class); + expect($usage->api_key_id)->toBe($this->apiKey->id); + expect($usage->endpoint)->toBe('/api/v1/workspaces'); + expect($usage->method)->toBe('GET'); + expect($usage->status_code)->toBe(200); + expect($usage->response_time_ms)->toBe(150); + }); + + it('normalises endpoint paths with IDs', function () { + $usage = $this->service->record( + apiKeyId: $this->apiKey->id, + workspaceId: $this->workspace->id, + endpoint: '/api/v1/workspaces/123/users/456', + method: 'GET', + statusCode: 200, + responseTimeMs: 100 + ); + + expect($usage->endpoint)->toBe('/api/v1/workspaces/{id}/users/{id}'); + }); + + it('normalises endpoint paths with UUIDs', function () { + $usage = $this->service->record( + apiKeyId: $this->apiKey->id, + workspaceId: $this->workspace->id, + endpoint: '/api/v1/resources/550e8400-e29b-41d4-a716-446655440000', + method: 'GET', + statusCode: 200, + responseTimeMs: 100 + ); + + expect($usage->endpoint)->toBe('/api/v1/resources/{uuid}'); + }); + + it('updates daily aggregation on record', function () { + $this->service->record( + apiKeyId: $this->apiKey->id, + workspaceId: $this->workspace->id, + endpoint: '/api/v1/test', + method: 'GET', + statusCode: 200, + responseTimeMs: 100 + ); + + $daily = ApiUsageDaily::forKey($this->apiKey->id) + ->where('date', now()->toDateString()) + ->first(); + + expect($daily)->not->toBeNull(); + expect($daily->request_count)->toBe(1); + expect($daily->success_count)->toBe(1); + }); + + it('increments daily counts correctly', function () { + // Record multiple requests + for ($i = 0; $i < 5; $i++) { + $this->service->record( + apiKeyId: $this->apiKey->id, + workspaceId: $this->workspace->id, + endpoint: '/api/v1/test', + method: 'GET', + statusCode: 200, + responseTimeMs: 100 + ($i * 10) + ); + } + + // Record some errors + for ($i = 0; $i < 2; $i++) { + $this->service->record( + apiKeyId: $this->apiKey->id, + workspaceId: $this->workspace->id, + endpoint: '/api/v1/test', + method: 'GET', + statusCode: 500, + responseTimeMs: 50 + ); + } + + $daily = ApiUsageDaily::forKey($this->apiKey->id) + ->where('date', now()->toDateString()) + ->first(); + + expect($daily->request_count)->toBe(7); + expect($daily->success_count)->toBe(5); + expect($daily->error_count)->toBe(2); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Usage Summaries +// ───────────────────────────────────────────────────────────────────────────── + +describe('Usage Summaries', function () { + beforeEach(function () { + // Create some usage data + for ($i = 0; $i < 10; $i++) { + $this->service->record( + apiKeyId: $this->apiKey->id, + workspaceId: $this->workspace->id, + endpoint: '/api/v1/workspaces', + method: 'GET', + statusCode: 200, + responseTimeMs: 100 + $i + ); + } + + for ($i = 0; $i < 3; $i++) { + $this->service->record( + apiKeyId: $this->apiKey->id, + workspaceId: $this->workspace->id, + endpoint: '/api/v1/workspaces', + method: 'POST', + statusCode: 422, + responseTimeMs: 50 + ); + } + }); + + it('returns workspace summary', function () { + $summary = $this->service->getWorkspaceSummary($this->workspace->id); + + expect($summary)->toHaveKeys(['period', 'totals', 'response_time', 'data_transfer']); + expect($summary['totals']['requests'])->toBe(13); + expect($summary['totals']['success'])->toBe(10); + expect($summary['totals']['errors'])->toBe(3); + }); + + it('returns key summary', function () { + $summary = $this->service->getKeySummary($this->apiKey->id); + + expect($summary['totals']['requests'])->toBe(13); + expect($summary['totals']['success_rate'])->toBeGreaterThan(70); + }); + + it('calculates average response time', function () { + $summary = $this->service->getWorkspaceSummary($this->workspace->id); + + // (100+101+102+...+109 + 50*3) / 13 + expect($summary['response_time']['average_ms'])->toBeGreaterThan(0); + }); + + it('filters by date range', function () { + // Create usage for 2 days ago with correct timestamp upfront + $oldDate = now()->subDays(2); + $usage = ApiUsage::create([ + 'api_key_id' => $this->apiKey->id, + 'workspace_id' => $this->workspace->id, + 'endpoint' => '/api/v1/old', + 'method' => 'GET', + 'status_code' => 200, + 'response_time_ms' => 100, + 'created_at' => $oldDate, + 'updated_at' => $oldDate, + ]); + + // Also create a backdated daily aggregate for consistency + ApiUsageDaily::updateOrCreate( + [ + 'api_key_id' => $this->apiKey->id, + 'date' => $oldDate->toDateString(), + ], + [ + 'request_count' => 1, + 'success_count' => 1, + 'error_count' => 0, + 'total_response_time_ms' => 100, + 'total_request_size' => 0, + 'total_response_size' => 0, + ] + ); + + // Summary for last 24 hours should not include old data + $summary = $this->service->getWorkspaceSummary( + $this->workspace->id, + now()->subDay(), + now() + ); + + expect($summary['totals']['requests'])->toBe(13); // Only today's requests + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Charts and Reports +// ───────────────────────────────────────────────────────────────────────────── + +describe('Charts and Reports', function () { + beforeEach(function () { + // Create usage spread across days + for ($day = 0; $day < 7; $day++) { + $date = now()->subDays($day); + $requests = 10 - $day; + + for ($i = 0; $i < $requests; $i++) { + $usage = ApiUsage::record( + $this->apiKey->id, + $this->workspace->id, + '/api/v1/test', + 'GET', + 200, + 100 + ); + $usage->update(['created_at' => $date]); + + ApiUsageDaily::recordFromUsage($usage); + } + } + }); + + it('returns daily chart data', function () { + $chart = $this->service->getDailyChart($this->workspace->id); + + expect($chart)->toBeArray(); + expect(count($chart))->toBeGreaterThan(0); + expect($chart[0])->toHaveKeys(['date', 'requests', 'success', 'errors', 'avg_response_time_ms']); + }); + + it('returns top endpoints', function () { + // Add some variety + $this->service->record( + $this->apiKey->id, + $this->workspace->id, + '/api/v1/popular', + 'GET', + 200, + 100 + ); + + $endpoints = $this->service->getTopEndpoints($this->workspace->id, 5); + + expect($endpoints)->toBeArray(); + expect($endpoints[0])->toHaveKeys(['endpoint', 'method', 'requests', 'success_rate', 'avg_response_time_ms']); + }); + + it('returns error breakdown', function () { + // Add some errors + $this->service->record($this->apiKey->id, $this->workspace->id, '/api/v1/test', 'GET', 401, 50); + $this->service->record($this->apiKey->id, $this->workspace->id, '/api/v1/test', 'GET', 404, 50); + $this->service->record($this->apiKey->id, $this->workspace->id, '/api/v1/test', 'GET', 500, 50); + + $errors = $this->service->getErrorBreakdown($this->workspace->id); + + expect($errors)->toBeArray(); + expect(count($errors))->toBe(3); + expect($errors[0])->toHaveKeys(['status_code', 'count', 'description']); + }); + + it('returns key comparison', function () { + // Create another key with usage + $key2 = ApiKey::generate($this->workspace->id, $this->user->id, 'Second Key'); + $this->service->record($key2['api_key']->id, $this->workspace->id, '/api/v1/test', 'GET', 200, 100); + + $comparison = $this->service->getKeyComparison($this->workspace->id); + + expect($comparison)->toBeArray(); + expect(count($comparison))->toBe(2); + expect($comparison[0])->toHaveKeys(['api_key_id', 'api_key_name', 'requests', 'success_rate']); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Data Retention +// ───────────────────────────────────────────────────────────────────────────── + +describe('Data Retention', function () { + it('prunes old detailed records', function () { + // Create old records + for ($i = 0; $i < 5; $i++) { + $usage = ApiUsage::record( + $this->apiKey->id, + $this->workspace->id, + '/api/v1/old', + 'GET', + 200, + 100 + ); + $usage->update(['created_at' => now()->subDays(60)]); + } + + // Create recent records + for ($i = 0; $i < 3; $i++) { + ApiUsage::record( + $this->apiKey->id, + $this->workspace->id, + '/api/v1/recent', + 'GET', + 200, + 100 + ); + } + + $deleted = $this->service->pruneOldRecords(30); + + expect($deleted)->toBe(5); + expect(ApiUsage::count())->toBe(3); + }); + + it('keeps daily aggregates when pruning detailed records', function () { + // Create and aggregate old record + $usage = ApiUsage::record( + $this->apiKey->id, + $this->workspace->id, + '/api/v1/old', + 'GET', + 200, + 100 + ); + $usage->update(['created_at' => now()->subDays(60)]); + ApiUsageDaily::recordFromUsage($usage); + + $dailyCountBefore = ApiUsageDaily::count(); + + $this->service->pruneOldRecords(30); + + // Daily aggregates should remain + expect(ApiUsageDaily::count())->toBe($dailyCountBefore); + }); +}); diff --git a/src/Mod/Api/Tests/Feature/OpenApiDocumentationTest.php b/src/Mod/Api/Tests/Feature/OpenApiDocumentationTest.php new file mode 100644 index 0000000..b8f31d9 --- /dev/null +++ b/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/src/Mod/Api/Tests/Feature/RateLimitTest.php b/src/Mod/Api/Tests/Feature/RateLimitTest.php new file mode 100644 index 0000000..b6a3300 --- /dev/null +++ b/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/src/Mod/Api/Tests/Feature/WebhookDeliveryTest.php b/src/Mod/Api/Tests/Feature/WebhookDeliveryTest.php new file mode 100644 index 0000000..3ee6c02 --- /dev/null +++ b/src/Mod/Api/Tests/Feature/WebhookDeliveryTest.php @@ -0,0 +1,770 @@ +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 () { + $endpoint = WebhookEndpoint::createForWorkspace( + $this->workspace->id, + 'https://example.com/webhook', + ['bio.created'] + ); + + $deliveries = $this->service->dispatch( + $this->workspace->id, + 'bio.created', + ['bio_id' => 123, 'name' => 'Test Bio'] + ); + + expect($deliveries)->toHaveCount(1); + expect($deliveries[0]->event_type)->toBe('bio.created'); + expect($deliveries[0]->webhook_endpoint_id)->toBe($endpoint->id); + expect($deliveries[0]->status)->toBe(WebhookDelivery::STATUS_PENDING); + }); + + it('does not dispatch to endpoints not subscribed to event', function () { + WebhookEndpoint::createForWorkspace( + $this->workspace->id, + 'https://example.com/webhook', + ['bio.updated'] // Different event + ); + + $deliveries = $this->service->dispatch( + $this->workspace->id, + 'bio.created', + ['bio_id' => 123] + ); + + expect($deliveries)->toBeEmpty(); + }); + + it('dispatches to wildcard subscribed endpoints', function () { + $endpoint = WebhookEndpoint::createForWorkspace( + $this->workspace->id, + 'https://example.com/webhook', + ['*'] // Subscribe to all events + ); + + $deliveries = $this->service->dispatch( + $this->workspace->id, + 'any.event.type', + ['data' => 'test'] + ); + + expect($deliveries)->toHaveCount(1); + }); + + it('does not dispatch to inactive endpoints', function () { + $endpoint = WebhookEndpoint::createForWorkspace( + $this->workspace->id, + 'https://example.com/webhook', + ['bio.created'] + ); + $endpoint->update(['active' => false]); + + $deliveries = $this->service->dispatch( + $this->workspace->id, + 'bio.created', + ['bio_id' => 123] + ); + + expect($deliveries)->toBeEmpty(); + }); + + it('does not dispatch to disabled endpoints', function () { + $endpoint = WebhookEndpoint::createForWorkspace( + $this->workspace->id, + 'https://example.com/webhook', + ['bio.created'] + ); + $endpoint->update(['disabled_at' => now()]); + + $deliveries = $this->service->dispatch( + $this->workspace->id, + 'bio.created', + ['bio_id' => 123] + ); + + expect($deliveries)->toBeEmpty(); + }); + + it('returns webhook stats for workspace', function () { + $endpoint = WebhookEndpoint::createForWorkspace( + $this->workspace->id, + 'https://example.com/webhook', + ['bio.created'] + ); + + // Create some deliveries + WebhookDelivery::createForEvent($endpoint, 'bio.created', ['id' => 1]); + $delivery2 = WebhookDelivery::createForEvent($endpoint, 'bio.created', ['id' => 2]); + $delivery2->markSuccess(200); + $delivery3 = WebhookDelivery::createForEvent($endpoint, 'bio.created', ['id' => 3]); + $delivery3->markFailed(500, 'Server Error'); + + $stats = $this->service->getStats($this->workspace->id); + + expect($stats['total'])->toBe(3); + expect($stats['pending'])->toBe(1); + expect($stats['success'])->toBe(1); + expect($stats['retrying'])->toBe(1); // Failed with retries remaining + }); +}); + +// ----------------------------------------------------------------------------- +// Webhook Delivery Job +// ----------------------------------------------------------------------------- + +describe('Webhook Delivery Job', function () { + it('marks delivery as success on 2xx response', function () { + Http::fake([ + 'example.com/*' => Http::response(['received' => 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(); + + $delivery->refresh(); + expect($delivery->status)->toBe(WebhookDelivery::STATUS_SUCCESS); + expect($delivery->response_code)->toBe(200); + expect($delivery->delivered_at)->not->toBeNull(); + }); + + it('marks delivery as retrying on 5xx response', function () { + Http::fake([ + 'example.com/*' => Http::response('Server Error', 500), + ]); + + $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(); + + $delivery->refresh(); + expect($delivery->status)->toBe(WebhookDelivery::STATUS_RETRYING); + expect($delivery->response_code)->toBe(500); + expect($delivery->attempt)->toBe(2); + expect($delivery->next_retry_at)->not->toBeNull(); + }); + + it('marks delivery as failed after max retries', function () { + Http::fake([ + 'example.com/*' => Http::response('Server Error', 500), + ]); + + $endpoint = WebhookEndpoint::createForWorkspace( + $this->workspace->id, + 'https://example.com/webhook', + ['bio.created'] + ); + + $delivery = WebhookDelivery::createForEvent( + $endpoint, + 'bio.created', + ['bio_id' => 123] + ); + $delivery->update(['attempt' => WebhookDelivery::MAX_RETRIES]); + + $job = new DeliverWebhookJob($delivery); + $job->handle(); + + $delivery->refresh(); + expect($delivery->status)->toBe(WebhookDelivery::STATUS_FAILED); + }); + + it('includes correct signature and timestamp headers', function () { + Http::fake(function ($request) { + // 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); + }); + + $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(); + + Http::assertSent(function ($request) { + return $request->url() === 'https://example.com/webhook'; + }); + }); + + 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, + 'https://example.com/webhook', + ['bio.created'] + ); + + $delivery = WebhookDelivery::createForEvent( + $endpoint, + 'bio.created', + ['bio_id' => 123] + ); + + // Deactivate endpoint after delivery created + $endpoint->update(['active' => false]); + + $job = new DeliverWebhookJob($delivery); + $job->handle(); + + // Should not have made any HTTP requests + Http::assertNothingSent(); + + // Delivery should remain pending (skipped) + $delivery->refresh(); + expect($delivery->status)->toBe(WebhookDelivery::STATUS_PENDING); + }); +}); + +// ----------------------------------------------------------------------------- +// Webhook Endpoint Auto-Disable +// ----------------------------------------------------------------------------- + +describe('Webhook Endpoint Auto-Disable', function () { + it('disables endpoint after consecutive failures', function () { + $endpoint = WebhookEndpoint::createForWorkspace( + $this->workspace->id, + 'https://example.com/webhook', + ['bio.created'] + ); + + // Simulate 10 consecutive failures + for ($i = 0; $i < 10; $i++) { + $endpoint->recordFailure(); + } + + $endpoint->refresh(); + expect($endpoint->active)->toBeFalse(); + expect($endpoint->disabled_at)->not->toBeNull(); + expect($endpoint->failure_count)->toBe(10); + }); + + it('resets failure count on success', function () { + $endpoint = WebhookEndpoint::createForWorkspace( + $this->workspace->id, + 'https://example.com/webhook', + ['bio.created'] + ); + + // Record some failures + $endpoint->recordFailure(); + $endpoint->recordFailure(); + $endpoint->recordFailure(); + expect($endpoint->fresh()->failure_count)->toBe(3); + + // Record success + $endpoint->recordSuccess(); + + $endpoint->refresh(); + expect($endpoint->failure_count)->toBe(0); + }); + + it('can be re-enabled after being disabled', function () { + $endpoint = WebhookEndpoint::createForWorkspace( + $this->workspace->id, + 'https://example.com/webhook', + ['bio.created'] + ); + + // Disable it + $endpoint->update([ + 'active' => false, + 'disabled_at' => now(), + 'failure_count' => 10, + ]); + + // Re-enable + $endpoint->enable(); + + $endpoint->refresh(); + expect($endpoint->active)->toBeTrue(); + expect($endpoint->disabled_at)->toBeNull(); + 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/src/Mod/Api/config.php b/src/Mod/Api/config.php new file mode 100644 index 0000000..701ee76 --- /dev/null +++ b/src/Mod/Api/config.php @@ -0,0 +1,237 @@ + env('API_VERSION', '1'), + + /* + |-------------------------------------------------------------------------- + | Rate Limiting + |-------------------------------------------------------------------------- + | + | Configure rate limits for API requests. + | + | 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, + ], + + // 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, + 'per_minutes' => 1, + ], + 'pro' => [ + 'requests' => 5000, + 'per_minutes' => 1, + ], + 'agency' => [ + 'requests' => 20000, + 'per_minutes' => 1, + ], + 'enterprise' => [ + 'requests' => 100000, + '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, + ], + + /* + |-------------------------------------------------------------------------- + | API Key Settings + |-------------------------------------------------------------------------- + | + | Configuration for API key generation and validation. + | + */ + + 'keys' => [ + // Prefix for all API keys + 'prefix' => 'hk_', + + // Default scopes for new API keys + 'default_scopes' => ['read', 'write'], + + // Maximum API keys per workspace + 'max_per_workspace' => 10, + + // Auto-expire keys after this many days (null = never) + 'default_expiry_days' => null, + ], + + /* + |-------------------------------------------------------------------------- + | Webhooks + |-------------------------------------------------------------------------- + | + | Webhook delivery settings. + | + */ + + 'webhooks' => [ + // Maximum webhook endpoints per workspace + 'max_per_workspace' => 5, + + // Timeout for webhook delivery in seconds + 'timeout' => 30, + + // Max retries for failed deliveries + 'max_retries' => 5, + + // Disable endpoint after this many consecutive failures + 'disable_after_failures' => 10, + + // Events that are high-volume and opt-in only + 'high_volume_events' => [ + 'link.clicked', + 'qrcode.scanned', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Pagination + |-------------------------------------------------------------------------- + | + | Default pagination settings for API responses. + | + */ + + 'pagination' => [ + 'default_per_page' => 25, + 'max_per_page' => 100, + ], + +]; diff --git a/src/Website/Api/Boot.php b/src/Website/Api/Boot.php new file mode 100644 index 0000000..9baa06c --- /dev/null +++ b/src/Website/Api/Boot.php @@ -0,0 +1,35 @@ +registerViews(); + $this->registerRoutes(); + } + + protected function registerViews(): void + { + View::addNamespace('api', __DIR__.'/View/Blade'); + } + + protected function registerRoutes(): void + { + // Skip domain binding during console commands (no request available) + if ($this->app->runningInConsole()) { + return; + } + + Route::middleware('web') + ->domain(request()->getHost()) + ->group(__DIR__.'/Routes/web.php'); + } +} diff --git a/src/Website/Api/Controllers/DocsController.php b/src/Website/Api/Controllers/DocsController.php new file mode 100644 index 0000000..05de0f4 --- /dev/null +++ b/src/Website/Api/Controllers/DocsController.php @@ -0,0 +1,72 @@ +json($generator->generate()); + } +} diff --git a/src/Website/Api/Routes/web.php b/src/Website/Api/Routes/web.php new file mode 100644 index 0000000..b90954b --- /dev/null +++ b/src/Website/Api/Routes/web.php @@ -0,0 +1,34 @@ +name('api.docs'); + +// Guides +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/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'); + +// API Reference +Route::get('/reference', [DocsController::class, 'reference'])->name('api.reference'); + +// Swagger UI +Route::get('/swagger', [DocsController::class, 'swagger'])->name('api.swagger'); + +// Scalar (modern API reference with sidebar) +Route::get('/scalar', [DocsController::class, 'scalar'])->name('api.scalar'); + +// ReDoc (three-panel API reference) +Route::get('/redoc', [DocsController::class, 'redoc'])->name('api.redoc'); + +// OpenAPI spec (rate limited - expensive to generate) +Route::get('/openapi.json', [DocsController::class, 'openapi']) + ->middleware('throttle:60,1') + ->name('api.openapi.json'); diff --git a/src/Website/Api/Services/OpenApiGenerator.php b/src/Website/Api/Services/OpenApiGenerator.php new file mode 100644 index 0000000..93d74ca --- /dev/null +++ b/src/Website/Api/Services/OpenApiGenerator.php @@ -0,0 +1,348 @@ +isProduction() ? 3600 : 0; + } + + /** + * Generate OpenAPI 3.0 specification from Laravel routes. + */ + public function generate(): array + { + $duration = $this->getCacheDuration(); + + if ($duration === 0) { + return $this->buildSpec(); + } + + return Cache::remember('openapi:spec', $duration, fn () => $this->buildSpec()); + } + + /** + * Clear the cached OpenAPI spec. + */ + public function clearCache(): void + { + Cache::forget('openapi:spec'); + } + + /** + * Build the full OpenAPI specification. + */ + protected function buildSpec(): array + { + return [ + 'openapi' => '3.0.0', + 'info' => $this->buildInfo(), + 'servers' => $this->buildServers(), + 'tags' => $this->buildTags(), + 'paths' => $this->buildPaths(), + 'components' => $this->buildComponents(), + ]; + } + + protected function buildInfo(): array + { + return [ + 'title' => config('app.name').' API', + 'description' => 'Unified API for Host UK services including commerce, analytics, push notifications, support, and MCP.', + 'version' => config('api.version', '1.0.0'), + 'contact' => [ + 'name' => config('app.name').' Support', + 'url' => config('app.url').'/contact', + 'email' => config('mail.from.address', 'support@host.uk.com'), + ], + ]; + } + + protected function buildServers(): array + { + return [ + [ + 'url' => config('app.url').'/api', + 'description' => 'Production API', + ], + ]; + } + + protected function buildTags(): array + { + return [ + ['name' => 'Analytics', 'description' => 'Website analytics and tracking'], + ['name' => 'Bio', 'description' => 'Bio link pages, blocks, and QR codes'], + ['name' => 'Chat Widget', 'description' => 'Public chat widget API'], + ['name' => 'Commerce', 'description' => 'Billing, orders, invoices, subscriptions, and provisioning'], + ['name' => 'Content', 'description' => 'AI content generation and briefs'], + ['name' => 'Entitlements', 'description' => 'Feature entitlements and usage'], + ['name' => 'MCP', 'description' => 'Model Context Protocol HTTP bridge'], + ['name' => 'Notify', 'description' => 'Push notification management'], + ['name' => 'Pixel', 'description' => 'Unified pixel tracking'], + ['name' => 'SEO', 'description' => 'SEO report and analysis endpoints'], + ['name' => 'Social', 'description' => 'Social media management'], + ['name' => 'Support', 'description' => 'Helpdesk API'], + ['name' => 'Tenant', 'description' => 'Workspaces and multi-tenancy'], + ['name' => 'Trees', 'description' => 'Trees for Agents statistics'], + ['name' => 'Trust', 'description' => 'Social proof widgets'], + ['name' => 'Webhooks', 'description' => 'Incoming webhook endpoints for external services'], + ]; + } + + protected function buildPaths(): array + { + $paths = []; + + foreach (RouteFacade::getRoutes() as $route) { + /** @var Route $route */ + if (! $this->isApiRoute($route)) { + continue; + } + + $path = $this->normalisePath($route->uri()); + $methods = array_filter($route->methods(), fn ($m) => $m !== 'HEAD'); + + foreach ($methods as $method) { + $method = strtolower($method); + $paths[$path][$method] = $this->buildOperation($route, $method); + } + } + + ksort($paths); + + return $paths; + } + + protected function isApiRoute(Route $route): bool + { + $uri = $route->uri(); + + // Must start with 'api/' or be exactly 'api' + if (! str_starts_with($uri, 'api/') && $uri !== 'api') { + return false; + } + + // Skip sanctum routes + if (str_contains($uri, 'sanctum')) { + return false; + } + + return true; + } + + protected function normalisePath(string $uri): string + { + // Remove 'api' prefix, keep leading slash + $path = '/'.ltrim(Str::after($uri, 'api/'), '/'); + + // Convert Laravel route parameters to OpenAPI format + $path = preg_replace('/\{([^}]+)\}/', '{$1}', $path); + + return $path === '/' ? '/' : rtrim($path, '/'); + } + + protected function buildOperation(Route $route, string $method): array + { + $name = $route->getName() ?? ''; + $tag = $this->inferTag($route); + + $operation = [ + 'tags' => [$tag], + 'summary' => $this->generateSummary($route, $method), + 'operationId' => $name ?: Str::camel($method.'_'.str_replace('/', '_', $route->uri())), + 'responses' => [ + '200' => ['description' => 'Successful response'], + ], + ]; + + // Add parameters for path variables + $parameters = $this->buildParameters($route); + if (! empty($parameters)) { + $operation['parameters'] = $parameters; + } + + // Add request body for POST/PUT/PATCH + if (in_array($method, ['post', 'put', 'patch'])) { + $operation['requestBody'] = [ + 'required' => true, + 'content' => [ + 'application/json' => [ + 'schema' => ['type' => 'object'], + ], + ], + ]; + } + + // Add security based on middleware + $security = $this->inferSecurity($route); + if (! empty($security)) { + $operation['security'] = $security; + } + + return $operation; + } + + protected function inferTag(Route $route): string + { + $uri = $route->uri(); + $name = $route->getName() ?? ''; + + // Match by route name prefix + $tagMap = [ + 'api.webhook' => 'Webhooks', + 'api.trees' => 'Trees', + 'api.seo' => 'SEO', + 'api.pixel' => 'Pixel', + 'api.commerce' => 'Commerce', + 'api.entitlements' => 'Entitlements', + 'api.support.chat' => 'Chat Widget', + 'api.support' => 'Support', + 'api.mcp' => 'MCP', + 'api.social' => 'Social', + 'api.notify' => 'Notify', + 'api.bio' => 'Bio', + 'api.blocks' => 'Bio', + 'api.shortlinks' => 'Bio', + 'api.qr' => 'Bio', + 'api.workspaces' => 'Tenant', + 'api.key.workspaces' => 'Tenant', + 'api.key.bio' => 'Bio', + 'api.key.blocks' => 'Bio', + 'api.key.shortlinks' => 'Bio', + 'api.key.qr' => 'Bio', + 'api.content' => 'Content', + 'api.key.content' => 'Content', + 'api.trust' => 'Trust', + ]; + + foreach ($tagMap as $prefix => $tag) { + if (str_starts_with($name, $prefix)) { + return $tag; + } + } + + // Match by URI prefix (check start of path after 'api/') + $path = preg_replace('#^api/#', '', $uri); + $uriTagMap = [ + 'webhooks' => 'Webhooks', + 'trees' => 'Trees', + 'pixel' => 'Pixel', + 'provisioning' => 'Commerce', + 'commerce' => 'Commerce', + 'entitlements' => 'Entitlements', + 'support/chat' => 'Chat Widget', + 'support' => 'Support', + 'mcp' => 'MCP', + 'bio' => 'Bio', + 'shortlinks' => 'Bio', + 'qr' => 'Bio', + 'blocks' => 'Bio', + 'workspaces' => 'Tenant', + 'analytics' => 'Analytics', + 'social' => 'Social', + 'trust' => 'Trust', + 'notify' => 'Notify', + 'content' => 'Content', + ]; + + foreach ($uriTagMap as $prefix => $tag) { + if (str_starts_with($path, $prefix)) { + return $tag; + } + } + + return 'General'; + } + + protected function generateSummary(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)); + } + + protected function buildParameters(Route $route): array + { + $parameters = []; + preg_match_all('/\{([^}]+)\}/', $route->uri(), $matches); + + foreach ($matches[1] as $param) { + $optional = str_ends_with($param, '?'); + $paramName = rtrim($param, '?'); + + $parameters[] = [ + 'name' => $paramName, + 'in' => 'path', + 'required' => ! $optional, + 'schema' => ['type' => 'string'], + ]; + } + + return $parameters; + } + + protected function inferSecurity(Route $route): array + { + $middleware = $route->middleware(); + + if (in_array('auth', $middleware) || in_array('auth:sanctum', $middleware)) { + return [['bearerAuth' => []]]; + } + + if (in_array('commerce.api', $middleware)) { + return [['apiKeyAuth' => []]]; + } + + foreach ($middleware as $m) { + if (str_contains($m, 'McpApiKeyAuth')) { + return [['apiKeyAuth' => []]]; + } + } + + return []; + } + + protected function buildComponents(): array + { + return [ + 'securitySchemes' => [ + 'bearerAuth' => [ + 'type' => 'http', + 'scheme' => 'bearer', + 'bearerFormat' => 'JWT', + 'description' => 'Sanctum authentication token', + ], + 'apiKeyAuth' => [ + 'type' => 'apiKey', + 'in' => 'header', + 'name' => 'X-API-Key', + 'description' => 'API key for service-to-service authentication', + ], + ], + ]; + } +} diff --git a/src/Website/Api/View/Blade/docs.blade.php b/src/Website/Api/View/Blade/docs.blade.php new file mode 100644 index 0000000..5f702a7 --- /dev/null +++ b/src/Website/Api/View/Blade/docs.blade.php @@ -0,0 +1,111 @@ + + + + + + Host UK API Documentation + + + + +
+

+ + + + Host UK API +

+ +
+ +
+ + + + + + diff --git a/src/Website/Api/View/Blade/guides/authentication.blade.php b/src/Website/Api/View/Blade/guides/authentication.blade.php new file mode 100644 index 0000000..5c27993 --- /dev/null +++ b/src/Website/Api/View/Blade/guides/authentication.blade.php @@ -0,0 +1,187 @@ +@extends('api::layouts.docs') + +@section('title', 'Authentication') + +@section('content') +
+ + {{-- Sidebar --}} + + + {{-- Main content --}} +
+
+ + {{-- Breadcrumb --}} + + +

Authentication

+

+ Learn how to authenticate your API requests using API keys. +

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

Overview

+

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

+
+ + {{-- API Keys --}} +
+

API Keys

+

+ To create an API key: +

+
    +
  1. Log in to your account
  2. +
  3. Navigate to Settings → API Keys
  4. +
  5. Click Create API Key
  6. +
  7. Enter a descriptive name (e.g., "Production", "Development")
  8. +
  9. Select the required scopes
  10. +
  11. Copy the generated key immediately
  12. +
+ +
+
+ + + +

+ Important: API keys are only shown once when created. Store them securely as they cannot be retrieved later. +

+
+
+
+ + {{-- Using Keys --}} +
+

Using API Keys

+

+ Include your API key in the Authorization header as a Bearer token: +

+ +
+
+ HTTP Header +
+
Authorization: Bearer hk_your_api_key_here
+
+ +

+ Example request with cURL: +

+ +
+
+ cURL +
+
curl --request GET \
+  --url 'https://api.host.uk.com/api/v1/bio' \
+  --header 'Authorization: Bearer hk_your_api_key'
+
+
+ + {{-- Scopes --}} +
+

Scopes

+

+ API keys can have different scopes to limit their permissions: +

+ +
+ + + + + + + + + + + + + + + + + + + + + +
ScopeDescription
readRead access to resources (GET requests)
writeCreate and update resources (POST, PUT requests)
deleteDelete resources (DELETE requests)
+
+
+ + {{-- Security --}} +
+

Security Best Practices

+
    +
  • Never commit API keys to version control
  • +
  • Use environment variables to store keys
  • +
  • Rotate keys periodically
  • +
  • Use the minimum required scopes
  • +
  • Revoke unused keys immediately
  • +
  • Never expose keys in client-side code
  • +
+
+ + {{-- Next steps --}} + + +
+
+ +
+@endsection diff --git a/src/Website/Api/View/Blade/guides/errors.blade.php b/src/Website/Api/View/Blade/guides/errors.blade.php new file mode 100644 index 0000000..2bb9770 --- /dev/null +++ b/src/Website/Api/View/Blade/guides/errors.blade.php @@ -0,0 +1,211 @@ +@extends('api::layouts.docs') + +@section('title', 'Error Handling') + +@section('content') +
+ + {{-- Sidebar --}} + + + {{-- Main content --}} +
+
+ + {{-- Breadcrumb --}} + + +

Error Handling

+

+ Understand API error codes and how to handle them gracefully. +

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

Overview

+

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

+
+ + {{-- HTTP Codes --}} +
+

HTTP Status Codes

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CodeMeaning
200Success - Request completed successfully
201Created - Resource was created successfully
400Bad Request - Invalid request parameters
401Unauthorised - Invalid or missing API key
403Forbidden - Insufficient permissions
404Not Found - Resource doesn't exist
422Unprocessable - Validation failed
429Too Many Requests - Rate limit exceeded
500Server Error - Something went wrong on our end
+
+
+ + {{-- Error Format --}} +
+

Error Format

+

+ Error responses include a JSON body with details: +

+ +
+
{
+  "message": "The given data was invalid.",
+  "errors": {
+    "url": [
+      "The url has already been taken."
+    ]
+  }
+}
+
+
+ + {{-- Common Errors --}} +
+

Common Errors

+ +
+
+

Invalid API Key

+

+ Returned when the API key is missing, malformed, or revoked. +

+ 401 Unauthorised +
+ +
+

Resource Not Found

+

+ The requested resource (biolink, workspace, etc.) doesn't exist or you don't have access. +

+ 404 Not Found +
+ +
+

Validation Failed

+

+ Request data failed validation. Check the errors object for specific fields. +

+ 422 Unprocessable Entity +
+
+
+ + {{-- Rate Limiting --}} +
+

Rate Limiting

+

+ API requests are rate limited to ensure fair usage. Rate limit headers are included in all responses: +

+ +
+
X-RateLimit-Limit: 60
+X-RateLimit-Remaining: 58
+X-RateLimit-Reset: 1705320000
+
+ +

+ When rate limited, you'll receive a 429 response. Wait until the reset timestamp before retrying. +

+ +
+

+ Tip: Implement exponential backoff in your retry logic. Start with a 1-second delay and double it with each retry, up to a maximum of 32 seconds. +

+
+
+ + {{-- Next steps --}} + + +
+
+ +
+@endsection diff --git a/src/Website/Api/View/Blade/guides/index.blade.php b/src/Website/Api/View/Blade/guides/index.blade.php new file mode 100644 index 0000000..ef77a68 --- /dev/null +++ b/src/Website/Api/View/Blade/guides/index.blade.php @@ -0,0 +1,88 @@ +@extends('api::layouts.docs') + +@section('title', 'Guides') + +@section('content') + +@endsection diff --git a/src/Website/Api/View/Blade/guides/qrcodes.blade.php b/src/Website/Api/View/Blade/guides/qrcodes.blade.php new file mode 100644 index 0000000..6d08861 --- /dev/null +++ b/src/Website/Api/View/Blade/guides/qrcodes.blade.php @@ -0,0 +1,202 @@ +@extends('api::layouts.docs') + +@section('title', 'QR Code Generation') + +@section('content') +
+ + {{-- Sidebar --}} + + + {{-- Main content --}} +
+
+ + {{-- Breadcrumb --}} + + +

QR Code Generation

+

+ Generate customisable QR codes for your biolinks or any URL. +

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

Overview

+

+ The API provides two ways to generate QR codes: +

+
    +
  • Biolink QR codes - Generate QR codes for your existing biolinks
  • +
  • Custom URL QR codes - Generate QR codes for any URL
  • +
+
+ + {{-- Biolink QR --}} + + + {{-- Custom QR --}} +
+

Custom URL QR Codes

+

+ Generate a QR code for any URL: +

+ +
+
+ cURL +
+
curl --request POST \
+  --url 'https://api.host.uk.com/api/v1/qr/generate' \
+  --header 'Authorization: Bearer YOUR_API_KEY' \
+  --header 'Content-Type: application/json' \
+  --data '{
+    "url": "https://example.com",
+    "format": "svg",
+    "size": 300
+  }'
+
+
+ + {{-- Options --}} +
+

Customisation Options

+

+ Available customisation parameters: +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeDescription
formatstringOutput format: svg or png
sizeintegerSize in pixels (100-2000)
colorstringForeground colour (hex)
backgroundstringBackground colour (hex)
+
+
+ + {{-- Download --}} +
+

Download Formats

+

+ Download QR codes directly as image files: +

+ +
+
+ cURL +
+
curl --request GET \
+  --url 'https://api.host.uk.com/api/v1/bio/1/qr/download?format=png&size=500' \
+  --header 'Authorization: Bearer YOUR_API_KEY' \
+  --output qrcode.png
+
+ +

+ The response is binary image data with appropriate Content-Type header. +

+
+ + {{-- Next steps --}} + + +
+
+ +
+@endsection diff --git a/src/Website/Api/View/Blade/guides/quickstart.blade.php b/src/Website/Api/View/Blade/guides/quickstart.blade.php new file mode 100644 index 0000000..c71acdf --- /dev/null +++ b/src/Website/Api/View/Blade/guides/quickstart.blade.php @@ -0,0 +1,193 @@ +@extends('api::layouts.docs') + +@section('title', 'Quick Start') + +@section('content') +
+ + {{-- Sidebar --}} + + + {{-- Main content --}} +
+
+ + {{-- Breadcrumb --}} + + +

Quick Start

+

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

+ + {{-- Prerequisites --}} +
+

Prerequisites

+

+ Before you begin, you'll need: +

+
    +
  • An account with API access
  • +
  • A workspace (created automatically on signup)
  • +
  • cURL or any HTTP client
  • +
+
+ + {{-- Create API Key --}} +
+

Create an API Key

+

+ Navigate to your workspace settings and create a new API key: +

+
    +
  1. Go to Settings → API Keys
  2. +
  3. Click Create API Key
  4. +
  5. Give it a name (e.g., "Development")
  6. +
  7. Select the scopes you need (read, write, delete)
  8. +
  9. Copy the key - it won't be shown again!
  10. +
+ + {{-- Note box --}} +
+
+ + + +

+ Important: Store your API key securely. Never commit it to version control or expose it in client-side code. +

+
+
+
+ + {{-- First Request --}} +
+

Make Your First Request

+

+ Let's verify your API key by listing your workspaces: +

+ +
+
+ cURL +
+
curl --request GET \
+  --url 'https://api.host.uk.com/api/v1/workspaces/current' \
+  --header 'Authorization: Bearer YOUR_API_KEY'
+
+ +

+ You should receive a response like: +

+ +
+
+ Response +
+
{
+  "data": {
+    "id": 1,
+    "name": "My Workspace",
+    "slug": "my-workspace-abc123",
+    "is_active": true
+  }
+}
+
+
+ + {{-- Create Biolink --}} + + + {{-- Next Steps --}} +
+

Next Steps

+

+ Now that you've made your first API calls, explore more: +

+ + +
+ +
+
+ +
+@endsection diff --git a/src/Website/Api/View/Blade/guides/webhooks.blade.php b/src/Website/Api/View/Blade/guides/webhooks.blade.php new file mode 100644 index 0000000..31323fa --- /dev/null +++ b/src/Website/Api/View/Blade/guides/webhooks.blade.php @@ -0,0 +1,586 @@ +@extends('api::layouts.docs') + +@section('title', 'Webhooks') + +@section('content') +
+ + {{-- Sidebar --}} + + + {{-- Main content --}} +
+
+ + {{-- Breadcrumb --}} + + +

Webhooks

+

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

+
+
+ + + +

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

+
+
+
+ + {{-- Setup --}} +
+

Setup

+

+ To configure webhooks: +

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

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

+
+
+
+ + {{-- Events --}} +
+

Event Types

+

+ Available webhook events: +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EventDescription
bio.createdA new biolink was created
bio.updatedA biolink was updated
bio.deletedA biolink was deleted
link.createdA new link was created
link.clickedA link was clicked (high volume)
qrcode.createdA QR code was generated
qrcode.scannedA QR code was scanned (high volume)
*Subscribe to all events (wildcard)
+
+
+ + {{-- Payload --}} +
+

Payload Format

+

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

+ +
+
{
+  "id": "evt_abc123xyz456",
+  "type": "bio.created",
+  "created_at": "2024-01-15T10:30:00Z",
+  "workspace_id": 1,
+  "data": {
+    "id": 123,
+    "url": "mypage",
+    "type": "biolink"
+  }
+}
+
+
+ + {{-- Headers --}} +
+

Request Headers

+

+ Every webhook request includes the following headers: +

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

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

+ +

Verification Algorithm

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

PHP

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

Node.js

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

Python

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

Ruby

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

Go

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

Retry Policy

+

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

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

+ After 5 failed attempts, the delivery is marked as failed. If your endpoint fails 10 consecutive deliveries, it will be automatically disabled. You can re-enable it from your webhook settings. +

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

Best Practices

+
    +
  • + + + + Always verify signatures - Never process webhooks without verification +
  • +
  • + + + + Respond quickly - Return 200 within 30 seconds to avoid timeouts +
  • +
  • + + + + Process asynchronously - Queue webhook processing for long-running tasks +
  • +
  • + + + + Handle duplicates - Use X-Webhook-Id for idempotency +
  • +
  • + + + + Use HTTPS - Always use HTTPS endpoints in production +
  • +
  • + + + + Rotate secrets regularly - Rotate your webhook secret periodically +
  • +
+
+ + {{-- Next steps --}} + + +
+
+ +
+@endsection diff --git a/src/Website/Api/View/Blade/index.blade.php b/src/Website/Api/View/Blade/index.blade.php new file mode 100644 index 0000000..b9d82fd --- /dev/null +++ b/src/Website/Api/View/Blade/index.blade.php @@ -0,0 +1,136 @@ +@extends('api::layouts.docs') + +@section('title', 'API Documentation') +@section('description', 'Build powerful integrations with the Host UK API. Access biolinks, workspaces, QR codes, and more.') + +@section('content') +
+ + {{-- Hero --}} +
+
+ Developer Documentation +
+

Build with the Host UK API

+

+ Integrate biolinks, workspaces, QR codes, and analytics into your applications. + Full REST API with comprehensive documentation and SDK support. +

+ +
+ + {{-- Features grid --}} +
+ + {{-- Authentication --}} +
+
+ + + +
+

Authentication

+

+ Secure API key authentication with scoped permissions. Generate keys from your workspace settings. +

+ + Learn more → + +
+ + {{-- Biolinks --}} +
+
+ + + +
+

Biolinks

+

+ Create, update, and manage biolink pages with blocks, themes, and analytics programmatically. +

+ + Learn more → + +
+ + {{-- QR Codes --}} +
+
+ + + +
+

QR Codes

+

+ Generate customisable QR codes with colours, logos, and multiple formats for any URL. +

+ + Learn more → + +
+ +
+ + {{-- Quick start code example --}} +
+

Quick Start

+
+
+ cURL + +
+
curl --request GET \
+  --url 'https://api.host.uk.com/api/v1/bio' \
+  --header 'Authorization: Bearer hk_your_api_key'
+
+ + +
+ + {{-- API endpoints preview --}} +
+

API Endpoints

+
+ @foreach([ + ['method' => 'GET', 'path' => '/api/v1/workspaces', 'desc' => 'List all workspaces'], + ['method' => 'GET', 'path' => '/api/v1/bio', 'desc' => 'List all biolinks'], + ['method' => 'POST', 'path' => '/api/v1/bio', 'desc' => 'Create a biolink'], + ['method' => 'GET', 'path' => '/api/v1/bio/{id}/qr', 'desc' => 'Generate QR code'], + ['method' => 'GET', 'path' => '/api/v1/shortlinks', 'desc' => 'List short links'], + ['method' => 'POST', 'path' => '/api/v1/qr/generate', 'desc' => 'Generate QR for any URL'], + ] as $endpoint) + + + {{ $endpoint['method'] }} + +
+ {{ $endpoint['path'] }} + {{ $endpoint['desc'] }} +
+
+ @endforeach +
+ + +
+ +
+@endsection diff --git a/src/Website/Api/View/Blade/layouts/docs.blade.php b/src/Website/Api/View/Blade/layouts/docs.blade.php new file mode 100644 index 0000000..e4b10c9 --- /dev/null +++ b/src/Website/Api/View/Blade/layouts/docs.blade.php @@ -0,0 +1,166 @@ + + + + + @yield('title', 'API Documentation') - Host UK + + + + + + + + + @include('layouts::partials.fonts') + + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + + + + @stack('head') + + + +
+ + {{-- Site header --}} +
+ +
+
+ + {{-- Site branding --}} +
+
+ {{-- Logo --}} + + + + + Host UK API + + + {{-- Search --}} +
+ + + {{-- Search modal placeholder --}} + +
+
+
+ + {{-- Desktop nav --}} + + +
+
+
+ + {{-- Page content --}} +
+ @yield('content') +
+ + {{-- Site footer --}} + + +
+ + @stack('scripts') + + diff --git a/src/Website/Api/View/Blade/partials/endpoint.blade.php b/src/Website/Api/View/Blade/partials/endpoint.blade.php new file mode 100644 index 0000000..b8ca518 --- /dev/null +++ b/src/Website/Api/View/Blade/partials/endpoint.blade.php @@ -0,0 +1,37 @@ +@props(['method', 'path', 'description', 'body' => null, 'response']) + +
+ {{-- Header --}} +
+ + {{ $method }} + + {{ $path }} +
+ + {{-- Body --}} +
+

{{ $description }}

+ + @if($body) +
+

Request Body

+
+
{{ $body }}
+
+
+ @endif + +
+

Response

+
+
{{ $response }}
+
+
+
+
diff --git a/src/Website/Api/View/Blade/redoc.blade.php b/src/Website/Api/View/Blade/redoc.blade.php new file mode 100644 index 0000000..a9c98a2 --- /dev/null +++ b/src/Website/Api/View/Blade/redoc.blade.php @@ -0,0 +1,73 @@ + + + + + + API Reference - Host UK + + + + + + + +
+ + + + diff --git a/src/Website/Api/View/Blade/reference.blade.php b/src/Website/Api/View/Blade/reference.blade.php new file mode 100644 index 0000000..22360e4 --- /dev/null +++ b/src/Website/Api/View/Blade/reference.blade.php @@ -0,0 +1,261 @@ +@extends('api::layouts.docs') + +@section('title', 'API Reference') + +@section('content') +
+ + {{-- Sidebar --}} + + + {{-- Main content --}} +
+
+ +

API Reference

+

+ Complete reference for all Host UK API endpoints. +

+

+ Base URL: https://api.host.uk.com/api/v1 +

+ + {{-- Workspaces --}} +
+

Workspaces

+

+ Workspaces are containers for your biolinks, short links, and other resources. +

+ + @include('api::partials.endpoint', [ + 'method' => 'GET', + 'path' => '/workspaces', + 'description' => 'List all workspaces you have access to.', + 'response' => '{"data": [{"id": 1, "name": "My Workspace", "slug": "my-workspace"}]}' + ]) + + @include('api::partials.endpoint', [ + 'method' => 'GET', + 'path' => '/workspaces/current', + 'description' => 'Get the current workspace (from API key context).', + 'response' => '{"data": {"id": 1, "name": "My Workspace", "slug": "my-workspace"}}' + ]) + + @include('api::partials.endpoint', [ + 'method' => 'GET', + 'path' => '/workspaces/{id}', + 'description' => 'Get a specific workspace by ID.', + 'response' => '{"data": {"id": 1, "name": "My Workspace", "slug": "my-workspace"}}' + ]) +
+ + {{-- Biolinks --}} + + + {{-- Blocks --}} +
+

Blocks

+

+ Blocks are content elements within a biolink page. +

+ + @include('api::partials.endpoint', [ + 'method' => 'GET', + 'path' => '/bio/{bioId}/blocks', + 'description' => 'List all blocks for a biolink.', + 'response' => '{"data": [{"id": 1, "type": "link", "data": {"title": "My Link"}}]}' + ]) + + @include('api::partials.endpoint', [ + 'method' => 'POST', + 'path' => '/bio/{bioId}/blocks', + 'description' => 'Add a new block to a biolink.', + 'body' => '{"type": "link", "data": {"title": "My Link", "url": "https://example.com"}}', + 'response' => '{"data": {"id": 1, "type": "link", "data": {"title": "My Link"}}}' + ]) + + @include('api::partials.endpoint', [ + 'method' => 'PUT', + 'path' => '/bio/{bioId}/blocks/{id}', + 'description' => 'Update a block.', + 'body' => '{"data": {"title": "Updated Link"}}', + 'response' => '{"data": {"id": 1, "type": "link", "data": {"title": "Updated Link"}}}' + ]) + + @include('api::partials.endpoint', [ + 'method' => 'DELETE', + 'path' => '/bio/{bioId}/blocks/{id}', + 'description' => 'Delete a block.', + 'response' => '{"message": "Deleted successfully"}' + ]) +
+ + {{-- Short Links --}} + + + {{-- QR Codes --}} +
+

QR Codes

+

+ Generate customisable QR codes for biolinks or any URL. +

+ + @include('api::partials.endpoint', [ + 'method' => 'GET', + 'path' => '/bio/{id}/qr', + 'description' => 'Get QR code data for a biolink.', + 'response' => '{"data": {"svg": "...", "url": "https://lt.hn/mypage"}}' + ]) + + @include('api::partials.endpoint', [ + 'method' => 'GET', + 'path' => '/bio/{id}/qr/download', + 'description' => 'Download QR code as PNG/SVG. Query params: format (png|svg), size (100-2000).', + 'response' => 'Binary image data' + ]) + + @include('api::partials.endpoint', [ + 'method' => 'POST', + 'path' => '/qr/generate', + 'description' => 'Generate QR code for any URL.', + 'body' => '{"url": "https://example.com", "format": "svg", "size": 300}', + 'response' => '{"data": {"svg": "..."}}' + ]) + + @include('api::partials.endpoint', [ + 'method' => 'GET', + 'path' => '/qr/options', + 'description' => 'Get available QR code customisation options.', + 'response' => '{"data": {"formats": ["png", "svg"], "sizes": {"min": 100, "max": 2000}}}' + ]) +
+ + {{-- Analytics --}} +
+

Analytics

+

+ View analytics data for your biolinks. +

+ + @include('api::partials.endpoint', [ + 'method' => 'GET', + 'path' => '/bio/{id}/analytics', + 'description' => 'Get analytics for a biolink. Query params: period (7d|30d|90d).', + 'response' => '{"data": {"views": 1234, "clicks": 567, "unique_visitors": 890}}' + ]) +
+ + {{-- CTA --}} +
+

Try it out

+

Test endpoints interactively with Swagger UI.

+ + Open Swagger UI + +
+ +
+
+ +
+@endsection diff --git a/src/Website/Api/View/Blade/scalar.blade.php b/src/Website/Api/View/Blade/scalar.blade.php new file mode 100644 index 0000000..f996834 --- /dev/null +++ b/src/Website/Api/View/Blade/scalar.blade.php @@ -0,0 +1,71 @@ + + + + + + API Reference - Host UK + + + + + + +
+ + +
+ + diff --git a/src/Website/Api/View/Blade/swagger.blade.php b/src/Website/Api/View/Blade/swagger.blade.php new file mode 100644 index 0000000..89424af --- /dev/null +++ b/src/Website/Api/View/Blade/swagger.blade.php @@ -0,0 +1,58 @@ +@extends('api::layouts.docs') + +@section('title', 'Swagger UI') + +@push('head') + + +@endpush + +@section('content') +
+
+

Swagger UI

+

+ Interactive API explorer. Try out endpoints directly from your browser. +

+
+ +
+
+@endsection + +@push('scripts') + + + +@endpush