diff --git a/packages/core-api/src/Mod/Api/Controllers/Concerns/HasApiResponses.php b/packages/core-api/src/Mod/Api/Concerns/HasApiResponses.php similarity index 100% rename from packages/core-api/src/Mod/Api/Controllers/Concerns/HasApiResponses.php rename to packages/core-api/src/Mod/Api/Concerns/HasApiResponses.php diff --git a/packages/core-api/src/Mod/Api/Controllers/Concerns/ResolvesWorkspace.php b/packages/core-api/src/Mod/Api/Concerns/ResolvesWorkspace.php similarity index 100% rename from packages/core-api/src/Mod/Api/Controllers/Concerns/ResolvesWorkspace.php rename to packages/core-api/src/Mod/Api/Concerns/ResolvesWorkspace.php diff --git a/packages/core-api/src/Mod/Api/Console/Commands/CleanupExpiredGracePeriods.php b/packages/core-api/src/Mod/Api/Console/Commands/CleanupExpiredGracePeriods.php new file mode 100644 index 0000000..2cf5f26 --- /dev/null +++ b/packages/core-api/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/packages/core-api/src/Mod/Api/Controllers/ProductApiController.php b/packages/core-api/src/Mod/Api/Controllers/ProductApiController.php deleted file mode 100644 index 45b74cb..0000000 --- a/packages/core-api/src/Mod/Api/Controllers/ProductApiController.php +++ /dev/null @@ -1,84 +0,0 @@ -orderBy('sort_order') - ->orderBy('name') - ->get() - ->map(fn (Package $package) => [ - 'code' => $package->code, - 'name' => $package->name, - 'description' => $package->description, - 'is_base_package' => $package->is_base_package, - 'is_stackable' => $package->is_stackable, - 'feature_count' => $package->features()->count(), - ]); - - return response()->json([ - 'success' => true, - 'products' => $packages, - ]); - } - - /** - * Get a single product by code. - */ - public function show(string $code): JsonResponse - { - $package = Package::where('code', $code) - ->with('features') - ->first(); - - if (! $package) { - return response()->json([ - 'success' => false, - 'error' => "Product '{$code}' not found", - ], 404); - } - - return response()->json([ - 'success' => true, - 'product' => [ - 'code' => $package->code, - 'name' => $package->name, - 'description' => $package->description, - 'is_base_package' => $package->is_base_package, - 'is_stackable' => $package->is_stackable, - 'is_active' => $package->is_active, - 'features' => $package->features->map(fn ($feature) => [ - 'code' => $feature->code, - 'name' => $feature->name, - 'type' => $feature->type, - 'limit_value' => $feature->pivot->limit_value, - ]), - ], - ]); - } - - /** - * Connection test endpoint. - */ - public function ping(): JsonResponse - { - return response()->json([ - 'success' => true, - 'message' => 'Host Hub API is operational', - 'version' => '1.0', - 'timestamp' => now()->toIso8601String(), - ]); - } -} diff --git a/packages/core-api/src/Mod/Api/Controllers/SeoReportController.php b/packages/core-api/src/Mod/Api/Controllers/SeoReportController.php deleted file mode 100644 index 47df5ef..0000000 --- a/packages/core-api/src/Mod/Api/Controllers/SeoReportController.php +++ /dev/null @@ -1,213 +0,0 @@ -validate([ - 'url' => 'required|url', - 'issues' => 'required|array', - 'suggestions' => 'nullable|array', - 'score' => 'nullable|integer|min:0|max:100', - 'workspace' => 'nullable|string', - ]); - - // Find content item by URL - $item = $this->findContentByUrl($validated['url'], $validated['workspace'] ?? null); - - if (! $item) { - Log::info('SEO report received for unknown URL', [ - 'url' => $validated['url'], - ]); - - return response()->json([ - 'status' => 'ignored', - 'reason' => 'Content not found for URL', - ]); - } - - // Update SEO metadata - $item->updateSeo([ - 'seo_score' => $validated['score'] ?? null, - 'seo_issues' => $validated['issues'], - 'seo_suggestions' => $validated['suggestions'] ?? [], - ]); - - Log::info('SEO report processed', [ - 'content_id' => $item->id, - 'score' => $validated['score'], - 'issue_count' => count($validated['issues']), - ]); - - return response()->json([ - 'status' => 'received', - 'content_id' => $item->id, - ]); - } - - /** - * Get SEO issues for a workspace. - */ - public function issues(Request $request, string $workspaceSlug): JsonResponse - { - $workspace = Workspace::where('slug', $workspaceSlug)->first(); - - if (! $workspace) { - return response()->json(['error' => 'Unknown workspace'], 404); - } - - $minScore = $request->input('min_score'); - $maxScore = $request->input('max_score'); - $hasIssues = $request->boolean('has_issues', true); - - $items = ContentItem::query() - ->where('workspace_id', $workspace->id) - ->whereHas('seoMetadata', function ($query) use ($minScore, $maxScore, $hasIssues) { - if ($hasIssues) { - $query->whereNotNull('seo_issues') - ->whereJsonLength('seo_issues', '>', 0); - } - - if ($minScore !== null) { - $query->where('seo_score', '>=', (int) $minScore); - } - - if ($maxScore !== null) { - $query->where('seo_score', '<=', (int) $maxScore); - } - }) - ->with('seoMetadata') - ->orderBy('updated_at', 'desc') - ->paginate(20); - - return response()->json([ - 'data' => $items->map(fn ($item) => [ - 'id' => $item->id, - 'title' => $item->title, - 'slug' => $item->slug, - 'type' => $item->type, - 'status' => $item->status, - 'seo' => [ - 'score' => $item->seoMetadata?->seo_score, - 'issue_count' => count($item->seoMetadata?->seo_issues ?? []), - 'issues' => $item->seoMetadata?->seo_issues, - 'suggestions' => $item->seoMetadata?->seo_suggestions, - ], - 'updated_at' => $item->updated_at->toIso8601String(), - ]), - 'meta' => [ - 'current_page' => $items->currentPage(), - 'last_page' => $items->lastPage(), - 'per_page' => $items->perPage(), - 'total' => $items->total(), - ], - ]); - } - - /** - * Generate an AI task to fix SEO issues. - */ - public function generateTask(Request $request): JsonResponse - { - $validated = $request->validate([ - 'content_item_id' => 'required|exists:content_items,id', - 'improvement_type' => 'required|in:title,description,content,schema,all', - ]); - - $item = ContentItem::with('seoMetadata')->findOrFail($validated['content_item_id']); - - // Find the appropriate SEO prompt - $promptName = match ($validated['improvement_type']) { - 'title' => 'seo-title-optimizer', - 'description' => 'seo-description-optimizer', - 'content' => 'seo-content-optimizer', - 'schema' => 'seo-schema-generator', - 'all' => 'seo-full-optimization', - }; - - $prompt = Prompt::where('name', $promptName)->active()->first(); - - if (! $prompt) { - return response()->json([ - 'error' => "Prompt '{$promptName}' not found or inactive", - ], 404); - } - - // Create the task - $task = ContentTask::create([ - 'workspace_id' => $item->workspace_id, - 'prompt_id' => $prompt->id, - 'status' => ContentTask::STATUS_PENDING, - 'priority' => ContentTask::PRIORITY_NORMAL, - 'input_data' => [ - 'title' => $item->title, - 'slug' => $item->slug, - 'excerpt' => $item->excerpt, - 'content' => $item->content_html_clean, - 'current_seo_title' => $item->seoMetadata?->title, - 'current_seo_description' => $item->seoMetadata?->description, - 'seo_issues' => $item->seoMetadata?->seo_issues ?? [], - 'seo_suggestions' => $item->seoMetadata?->seo_suggestions ?? [], - 'focus_keyword' => $item->seoMetadata?->focus_keyword, - ], - 'target_type' => ContentItem::class, - 'target_id' => $item->id, - ]); - - // Dispatch for processing - ProcessContentTask::dispatch($task); - - Log::info('SEO improvement task created', [ - 'task_id' => $task->id, - 'content_id' => $item->id, - 'type' => $validated['improvement_type'], - ]); - - return response()->json([ - 'success' => true, - 'task_id' => $task->id, - 'status' => 'queued', - ]); - } - - /** - * Find content item by URL. - */ - protected function findContentByUrl(string $url, ?string $workspaceSlug = null): ?ContentItem - { - // Extract slug from URL - $path = parse_url($url, PHP_URL_PATH); - $slug = basename($path); - - // Remove common URL patterns - $slug = preg_replace('/\.(html?|php)$/', '', $slug); - - $query = ContentItem::query()->where('slug', $slug); - - if ($workspaceSlug) { - $workspace = Workspace::where('slug', $workspaceSlug)->first(); - if ($workspace) { - $query->where('workspace_id', $workspace->id); - } - } - - return $query->first(); - } -} diff --git a/packages/core-api/src/Mod/Api/Controllers/Social/AddPostToQueueController.php b/packages/core-api/src/Mod/Api/Controllers/Social/AddPostToQueueController.php deleted file mode 100644 index 1a6e209..0000000 --- a/packages/core-api/src/Mod/Api/Controllers/Social/AddPostToQueueController.php +++ /dev/null @@ -1,12 +0,0 @@ -query('pixel_key'); - - if (! $pixelKey) { - return response()->json([ - 'ok' => false, - 'error' => 'Missing pixel_key parameter', - ], 400); - } - - // Cache config for 5 minutes to reduce database lookups - $cacheKey = "pixel_config:{$pixelKey}"; - $config = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($pixelKey) { - return $this->buildConfig($pixelKey); - }); - - if (! $config) { - return response()->json([ - 'ok' => false, - 'error' => 'Invalid or disabled pixel key', - ], 404); - } - - return response()->json([ - 'ok' => true, - 'config' => $config, - ]); - } - - /** - * Build configuration for a pixel key. - */ - protected function buildConfig(string $pixelKey): ?array - { - $config = [ - 'analytics' => false, - 'push' => false, - 'socialproof' => false, - ]; - - $foundAny = false; - - // Check analytics - $analyticsWebsite = Website::where('pixel_key', $pixelKey) - ->active() - ->first(); - - if ($analyticsWebsite) { - $foundAny = true; - $config['analytics'] = true; - $config['analytics_settings'] = [ - 'tracking_type' => $analyticsWebsite->tracking_type ?? 'lightweight', - 'track_clicks' => $analyticsWebsite->settings['track_clicks'] ?? false, - 'track_scroll' => $analyticsWebsite->settings['track_scroll'] ?? false, - 'track_outbound' => $analyticsWebsite->settings['track_outbound'] ?? false, - 'session_timeout' => $analyticsWebsite->settings['session_timeout'] ?? 30, - ]; - } - - // Check push notifications - $pushWebsite = PushWebsite::where('pixel_key', $pixelKey) - ->where('is_enabled', true) - ->first(); - - if ($pushWebsite) { - $foundAny = true; - $config['push'] = true; - $widgetSettings = $pushWebsite->widget_settings ?? []; - $config['push_settings'] = [ - 'auto_prompt' => $widgetSettings['auto_prompt'] ?? true, - 'prompt_delay' => $widgetSettings['prompt_delay'] ?? 3, - 'widget_position' => $widgetSettings['widget_position'] ?? 'top-right', - ]; - } - - // Check social proof - $socialProofCampaign = Campaign::where('pixel_key', $pixelKey) - ->enabled() - ->first(); - - if ($socialProofCampaign) { - $foundAny = true; - $config['socialproof'] = true; - $config['socialproof_settings'] = [ - 'primary_color' => $socialProofCampaign->primary_color, - 'logo' => $socialProofCampaign->logo, - ]; - } - - // If no services found for this pixel key, return null - if (! $foundAny) { - return null; - } - - return $config; - } - - /** - * Unified tracking endpoint. - * - * Accepts tracking data and routes it to the appropriate service - * based on the event type. - */ - public function track(Request $request): JsonResponse - { - $validated = $request->validate([ - 'pixel_key' => 'required|string|max:64', - 'type' => 'required|string|in:pageview,event,goal,session_end', - 'visitor_id' => 'sometimes|string|max:64', - 'session_id' => 'sometimes|string|max:64', - 'timestamp' => 'sometimes|date', - 'path' => 'sometimes|string|max:512', - 'title' => 'sometimes|string|max:256', - 'url' => 'sometimes|string|max:512', - 'referrer' => 'sometimes|nullable|string|max:512', - 'referrer_host' => 'sometimes|nullable|string|max:256', - 'device_type' => 'sometimes|string|max:16', - 'browser' => 'sometimes|string|max:32', - 'os' => 'sometimes|string|max:32', - 'screen' => 'sometimes|array', - 'utm_source' => 'sometimes|nullable|string|max:128', - 'utm_medium' => 'sometimes|nullable|string|max:128', - 'utm_campaign' => 'sometimes|nullable|string|max:128', - 'utm_term' => 'sometimes|nullable|string|max:128', - 'utm_content' => 'sometimes|nullable|string|max:128', - 'event_name' => 'sometimes|string|max:128', - 'event_data' => 'sometimes|array', - 'goal_key' => 'sometimes|string|max:64', - 'value' => 'sometimes|numeric', - 'duration' => 'sometimes|integer|min:0', - ]); - - // Find the analytics website for this pixel key - $website = Website::where('pixel_key', $validated['pixel_key']) - ->active() - ->first(); - - if (! $website) { - return response()->json([ - 'ok' => false, - 'error' => 'Invalid or disabled pixel key', - ], 404); - } - - // Map the unified format to the analytics tracking format - $trackingData = [ - 'type' => $validated['type'] === 'event' ? 'custom' : $validated['type'], - 'visitor_id' => $validated['visitor_id'] ?? null, - 'session_id' => $validated['session_id'] ?? null, - 'path' => $validated['path'] ?? '/', - 'title' => $validated['title'] ?? null, - 'referrer' => $validated['referrer'] ?? null, - 'utm_source' => $validated['utm_source'] ?? null, - 'utm_medium' => $validated['utm_medium'] ?? null, - 'utm_campaign' => $validated['utm_campaign'] ?? null, - 'utm_term' => $validated['utm_term'] ?? null, - 'utm_content' => $validated['utm_content'] ?? null, - 'screen_width' => $validated['screen']['width'] ?? null, - 'screen_height' => $validated['screen']['height'] ?? null, - ]; - - // Add event-specific data - if ($validated['type'] === 'event') { - $trackingData['event_name'] = $validated['event_name'] ?? 'custom'; - $trackingData['properties'] = $validated['event_data'] ?? []; - } - - // Add goal-specific data - if ($validated['type'] === 'goal') { - $trackingData['event_name'] = $validated['goal_key'] ?? 'goal'; - $trackingData['properties'] = ['value' => $validated['value'] ?? null]; - } - - // Track the event - $event = $this->analyticsTracking->track($website, $trackingData, $request); - - return response()->json([ - 'ok' => true, - 'event_id' => $event?->id, - 'visitor_id' => $event?->visitor?->visitor_uuid, - 'session_id' => $event?->session?->session_uuid, - ]); - } - - /** - * Clear cached configuration for a pixel key. - * - * Called when website/campaign settings are updated. - */ - public static function clearConfigCache(string $pixelKey): void - { - Cache::forget("pixel_config:{$pixelKey}"); - } -} diff --git a/packages/core-api/src/Mod/Api/Database/Factories/ApiKeyFactory.php b/packages/core-api/src/Mod/Api/Database/Factories/ApiKeyFactory.php new file mode 100644 index 0000000..6b44cdf --- /dev/null +++ b/packages/core-api/src/Mod/Api/Database/Factories/ApiKeyFactory.php @@ -0,0 +1,169 @@ + + */ +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. + * + * @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('sha256', $plainKey), + 'prefix' => $prefix, + 'scopes' => [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE], + 'server_scopes' => null, + 'last_used_at' => null, + 'expires_at' => 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. + * + * @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 + ); + } + + /** + * 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(), + ]); + } +} diff --git a/packages/core-api/src/Mod/Api/Jobs/DeliverWebhookJob.php b/packages/core-api/src/Mod/Api/Jobs/DeliverWebhookJob.php new file mode 100644 index 0000000..fa7cdc7 --- /dev/null +++ b/packages/core-api/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/packages/core-api/src/Mod/Api/Middleware/CommerceApiAuth.php b/packages/core-api/src/Mod/Api/Middleware/CommerceApiAuth.php deleted file mode 100644 index ccf6950..0000000 --- a/packages/core-api/src/Mod/Api/Middleware/CommerceApiAuth.php +++ /dev/null @@ -1,55 +0,0 @@ -bearerToken(); - - if (! $token) { - return $this->unauthorized('API token required. Use Authorization: Bearer '); - } - - $expectedToken = config('services.commerce.api_secret'); - - if (! $expectedToken) { - return response()->json([ - 'error' => 'configuration_error', - 'message' => 'Commerce API not configured', - ], 500); - } - - if (! hash_equals($expectedToken, $token)) { - return $this->unauthorized('Invalid API token'); - } - - $request->attributes->set('auth_type', 'commerce_api'); - - return $next($request); - } - - /** - * Return 401 Unauthorized response. - */ - protected function unauthorized(string $message): Response - { - return response()->json([ - 'error' => 'unauthorized', - 'message' => $message, - ], 401); - } -} diff --git a/packages/core-api/src/Mod/Api/Middleware/PublicApiCors.php b/packages/core-api/src/Mod/Api/Middleware/PublicApiCors.php new file mode 100644 index 0000000..21083f3 --- /dev/null +++ b/packages/core-api/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/packages/core-api/src/Mod/Api/Middleware/TrackApiUsage.php b/packages/core-api/src/Mod/Api/Middleware/TrackApiUsage.php new file mode 100644 index 0000000..a40ecc2 --- /dev/null +++ b/packages/core-api/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/packages/core-api/src/Mod/Api/Models/ApiUsage.php b/packages/core-api/src/Mod/Api/Models/ApiUsage.php new file mode 100644 index 0000000..3bae241 --- /dev/null +++ b/packages/core-api/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/packages/core-api/src/Mod/Api/Models/ApiUsageDaily.php b/packages/core-api/src/Mod/Api/Models/ApiUsageDaily.php new file mode 100644 index 0000000..9dd15cb --- /dev/null +++ b/packages/core-api/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/packages/core-api/src/Mod/Api/Services/ApiKeyService.php b/packages/core-api/src/Mod/Api/Services/ApiKeyService.php new file mode 100644 index 0000000..2175826 --- /dev/null +++ b/packages/core-api/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/packages/core-api/src/Mod/Api/Services/ApiUsageService.php b/packages/core-api/src/Mod/Api/Services/ApiUsageService.php new file mode 100644 index 0000000..204f444 --- /dev/null +++ b/packages/core-api/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/packages/core-api/src/Mod/Api/Services/WebhookService.php b/packages/core-api/src/Mod/Api/Services/WebhookService.php new file mode 100644 index 0000000..1300213 --- /dev/null +++ b/packages/core-api/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/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeyRotationTest.php b/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeyRotationTest.php new file mode 100644 index 0000000..86c2f5c --- /dev/null +++ b/packages/core-api/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/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeyTest.php b/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeyTest.php new file mode 100644 index 0000000..1fbd478 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Tests/Feature/ApiKeyTest.php @@ -0,0 +1,602 @@ +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 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 the SHA-256 hash + expect($result['api_key']->key)->toBe(hash('sha256', $keyPart)); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 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/packages/core-api/src/Mod/Api/Tests/Feature/ApiUsageTest.php b/packages/core-api/src/Mod/Api/Tests/Feature/ApiUsageTest.php new file mode 100644 index 0000000..20c3f0d --- /dev/null +++ b/packages/core-api/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/packages/core-api/src/Mod/Api/Tests/Feature/WebhookDeliveryTest.php b/packages/core-api/src/Mod/Api/Tests/Feature/WebhookDeliveryTest.php new file mode 100644 index 0000000..88be540 --- /dev/null +++ b/packages/core-api/src/Mod/Api/Tests/Feature/WebhookDeliveryTest.php @@ -0,0 +1,340 @@ +workspace = Workspace::factory()->create(); + $this->service = app(WebhookService::class); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 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 header', function () { + Http::fake(function ($request) { + // Verify signature header exists + expect($request->hasHeader('X-HostHub-Signature'))->toBeTrue(); + expect($request->hasHeader('X-HostHub-Event'))->toBeTrue(); + expect($request->hasHeader('X-HostHub-Delivery'))->toBeTrue(); + + 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('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); + }); +}); diff --git a/packages/core-mcp/src/Mod/Mcp/Console/Commands/CleanupToolCallLogsCommand.php b/packages/core-mcp/src/Mod/Mcp/Console/Commands/CleanupToolCallLogsCommand.php new file mode 100644 index 0000000..c6d73dc --- /dev/null +++ b/packages/core-mcp/src/Mod/Mcp/Console/Commands/CleanupToolCallLogsCommand.php @@ -0,0 +1,111 @@ +option('dry-run'); + $logRetentionDays = (int) ($this->option('days') ?? config('mcp.log_retention.days', 90)); + $statsRetentionDays = (int) ($this->option('stats-days') ?? config('mcp.log_retention.stats_days', 365)); + + $this->info('MCP Log Cleanup'.($dryRun ? ' (DRY RUN)' : '')); + $this->line(''); + $this->line("Detailed logs retention: {$logRetentionDays} days"); + $this->line("Statistics retention: {$statsRetentionDays} days"); + $this->line(''); + + $logsCutoff = now()->subDays($logRetentionDays); + $statsCutoff = now()->subDays($statsRetentionDays); + + // Clean up tool call logs + $toolCallsCount = McpToolCall::where('created_at', '<', $logsCutoff)->count(); + if ($toolCallsCount > 0) { + if ($dryRun) { + $this->line("Would delete {$toolCallsCount} tool call log(s) older than {$logsCutoff->toDateString()}"); + } else { + // Delete in chunks to avoid memory issues and lock contention + $deleted = $this->deleteInChunks(McpToolCall::class, 'created_at', $logsCutoff); + $this->info("Deleted {$deleted} tool call log(s)"); + } + } else { + $this->line('No tool call logs to clean up'); + } + + // Clean up API request logs + $apiRequestsCount = McpApiRequest::where('created_at', '<', $logsCutoff)->count(); + if ($apiRequestsCount > 0) { + if ($dryRun) { + $this->line("Would delete {$apiRequestsCount} API request log(s) older than {$logsCutoff->toDateString()}"); + } else { + $deleted = $this->deleteInChunks(McpApiRequest::class, 'created_at', $logsCutoff); + $this->info("Deleted {$deleted} API request log(s)"); + } + } else { + $this->line('No API request logs to clean up'); + } + + // Clean up aggregated statistics (longer retention) + $statsCount = McpToolCallStat::where('date', '<', $statsCutoff->toDateString())->count(); + if ($statsCount > 0) { + if ($dryRun) { + $this->line("Would delete {$statsCount} tool call stat(s) older than {$statsCutoff->toDateString()}"); + } else { + $deleted = McpToolCallStat::where('date', '<', $statsCutoff->toDateString())->delete(); + $this->info("Deleted {$deleted} tool call stat(s)"); + } + } else { + $this->line('No tool call stats to clean up'); + } + + $this->line(''); + $this->info('Cleanup complete.'); + + return self::SUCCESS; + } + + /** + * Delete records in chunks to avoid memory issues. + */ + protected function deleteInChunks(string $model, string $column, \DateTimeInterface $cutoff, int $chunkSize = 1000): int + { + $totalDeleted = 0; + + do { + $deleted = $model::where($column, '<', $cutoff) + ->limit($chunkSize) + ->delete(); + + $totalDeleted += $deleted; + + // Small pause to reduce database pressure + if ($deleted > 0) { + usleep(10000); // 10ms + } + } while ($deleted > 0); + + return $totalDeleted; + } +} diff --git a/packages/core-mcp/src/Mod/Mcp/Console/Commands/McpMonitorCommand.php b/packages/core-mcp/src/Mod/Mcp/Console/Commands/McpMonitorCommand.php new file mode 100644 index 0000000..1b3f83b --- /dev/null +++ b/packages/core-mcp/src/Mod/Mcp/Console/Commands/McpMonitorCommand.php @@ -0,0 +1,199 @@ +argument('action'); + + return match ($action) { + 'status' => $this->showStatus($monitoring), + 'alerts' => $this->checkAlerts($monitoring), + 'export' => $this->exportMetrics($monitoring), + 'report' => $this->showReport($monitoring), + 'prometheus' => $this->showPrometheus($monitoring), + default => $this->showHelp(), + }; + } + + protected function showStatus(McpMonitoringService $monitoring): int + { + $health = $monitoring->getHealthStatus(); + + if ($this->option('json')) { + $this->line(json_encode($health, JSON_PRETTY_PRINT)); + + return 0; + } + + $statusColor = match ($health['status']) { + 'healthy' => 'green', + 'degraded' => 'yellow', + 'critical' => 'red', + default => 'white', + }; + + $this->newLine(); + $this->line("MCP Health Status: ".strtoupper($health['status']).''); + $this->newLine(); + + $this->table( + ['Metric', 'Value'], + [ + ['Total Calls (24h)', number_format($health['metrics']['total_calls'])], + ['Success Rate', $health['metrics']['success_rate'].'%'], + ['Error Rate', $health['metrics']['error_rate'].'%'], + ['Avg Duration', $health['metrics']['avg_duration_ms'].'ms'], + ] + ); + + if (count($health['issues']) > 0) { + $this->newLine(); + $this->warn('Issues Detected:'); + + foreach ($health['issues'] as $issue) { + $icon = $issue['severity'] === 'critical' ? '!!' : '!'; + $this->line(" [{$icon}] {$issue['message']}"); + } + } + + $this->newLine(); + $this->line('Checked at: '.$health['checked_at'].''); + + return $health['status'] === 'critical' ? 1 : 0; + } + + protected function checkAlerts(McpMonitoringService $monitoring): int + { + $alerts = $monitoring->checkAlerts(); + + if ($this->option('json')) { + $this->line(json_encode($alerts, JSON_PRETTY_PRINT)); + + return count($alerts) > 0 ? 1 : 0; + } + + if (count($alerts) === 0) { + $this->info('No alerts detected.'); + + return 0; + } + + $this->warn(count($alerts).' alert(s) detected:'); + $this->newLine(); + + foreach ($alerts as $alert) { + $severityColor = $alert['severity'] === 'critical' ? 'red' : 'yellow'; + $this->line("[{$alert['severity']}] {$alert['message']}"); + } + + return 1; + } + + protected function exportMetrics(McpMonitoringService $monitoring): int + { + $monitoring->exportMetrics(); + $this->info('Metrics exported to monitoring channel.'); + + return 0; + } + + protected function showReport(McpMonitoringService $monitoring): int + { + $days = (int) $this->option('days'); + $report = $monitoring->getSummaryReport($days); + + if ($this->option('json')) { + $this->line(json_encode($report, JSON_PRETTY_PRINT)); + + return 0; + } + + $this->newLine(); + $this->line("MCP Summary Report ({$days} days)"); + $this->line("Period: {$report['period']['from']} to {$report['period']['to']}"); + $this->newLine(); + + // Overview + $this->line('Overview:'); + $this->table( + ['Metric', 'Value'], + [ + ['Total Calls', number_format($report['overview']['total_calls'])], + ['Success Rate', $report['overview']['success_rate'].'%'], + ['Avg Duration', $report['overview']['avg_duration_ms'].'ms'], + ['Unique Tools', $report['overview']['unique_tools']], + ['Unique Servers', $report['overview']['unique_servers']], + ] + ); + + // Top tools + if (count($report['top_tools']) > 0) { + $this->newLine(); + $this->line('Top Tools:'); + + $toolRows = []; + foreach ($report['top_tools'] as $tool) { + $toolRows[] = [ + $tool->tool_name, + number_format($tool->total_calls), + $tool->success_rate.'%', + round($tool->avg_duration ?? 0).'ms', + ]; + } + + $this->table(['Tool', 'Calls', 'Success Rate', 'Avg Duration'], $toolRows); + } + + // Anomalies + if (count($report['anomalies']) > 0) { + $this->newLine(); + $this->warn('Anomalies Detected:'); + + foreach ($report['anomalies'] as $anomaly) { + $this->line(" - [{$anomaly['tool']}] {$anomaly['message']}"); + } + } + + $this->newLine(); + $this->line('Generated: '.$report['generated_at'].''); + + return 0; + } + + protected function showPrometheus(McpMonitoringService $monitoring): int + { + $metrics = $monitoring->getPrometheusMetrics(); + $this->line($metrics); + + return 0; + } + + protected function showHelp(): int + { + $this->error('Unknown action. Available actions: status, alerts, export, report, prometheus'); + + return 1; + } +} diff --git a/packages/core-mcp/src/Mod/Mcp/Controllers/McpApiController.php b/packages/core-mcp/src/Mod/Mcp/Controllers/McpApiController.php new file mode 100644 index 0000000..643ab19 --- /dev/null +++ b/packages/core-mcp/src/Mod/Mcp/Controllers/McpApiController.php @@ -0,0 +1,470 @@ +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 + */ + public function tools(Request $request, string $id): JsonResponse + { + $server = $this->loadServerFull($id); + + if (! $server) { + return response()->json(['error' => 'Server not found'], 404); + } + + return response()->json([ + 'server' => $id, + 'tools' => $server['tools'] ?? [], + 'count' => count($server['tools'] ?? []), + ]); + } + + /** + * Execute a tool on an MCP server. + * + * POST /api/v1/mcp/tools/call + */ + public function callTool(Request $request): JsonResponse + { + $validated = $request->validate([ + 'server' => 'required|string|max:64', + 'tool' => 'required|string|max:128', + 'arguments' => 'nullable|array', + ]); + + $server = $this->loadServerFull($validated['server']); + if (! $server) { + return response()->json(['error' => 'Server not found'], 404); + } + + // Verify tool exists + $toolDef = collect($server['tools'] ?? [])->firstWhere('name', $validated['tool']); + if (! $toolDef) { + return response()->json(['error' => 'Tool not found'], 404); + } + + // Validate arguments against tool's input schema + $validationErrors = $this->validateToolArguments($toolDef, $validated['arguments'] ?? []); + if (! empty($validationErrors)) { + return response()->json([ + 'error' => 'validation_failed', + 'message' => 'Tool arguments do not match input schema', + 'validation_errors' => $validationErrors, + ], 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'], + 'result' => $result, + 'duration_ms' => $durationMs, + ]; + + // Log full request for debugging/replay + $this->logApiRequest($request, $validated, 200, $response, $durationMs, $apiKey); + + return response()->json($response); + } 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'], + ]; + + // Log full request for debugging/replay + $this->logApiRequest($request, $validated, 500, $response, $durationMs, $apiKey, $e->getMessage()); + + return response()->json($response, 500); + } + } + + /** + * Read a resource from an MCP server. + * + * GET /api/v1/mcp/resources/{uri} + * + * NOTE: Resource reading is not yet implemented. Returns 501 Not Implemented. + */ + 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]; + + $server = $this->loadServerFull($serverId); + if (! $server) { + return response()->json(['error' => 'Server not found'], 404); + } + + // Resource reading not yet implemented + return response()->json([ + 'error' => 'not_implemented', + 'message' => 'MCP resource reading is not yet implemented. Use tool calls instead.', + 'uri' => $uri, + ], 501); + } + + /** + * Execute tool via artisan MCP server command. + */ + protected function executeToolViaArtisan(string $server, string $tool, array $arguments): mixed + { + $commandMap = config('api.mcp.server_commands', []); + + $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; + } + + /** + * 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 + ); + } + + /** + * Validate tool arguments against the tool's input schema. + * + * @return array Validation errors (empty if valid) + */ + protected function validateToolArguments(array $toolDef, array $arguments): array + { + $inputSchema = $toolDef['inputSchema'] ?? null; + + // No schema = no validation + 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) { + // Check if argument is defined in schema + if (! isset($properties[$key])) { + // Allow extra properties unless additionalProperties is false + if (isset($inputSchema['additionalProperties']) && $inputSchema['additionalProperties'] === 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}"; + } + + // Validate string constraints + if ($expectedType === 'string' && is_string($value)) { + if (isset($propSchema['minLength']) && strlen($value) < $propSchema['minLength']) { + $errors[] = "Argument '{$key}' must be at least {$propSchema['minLength']} characters"; + } + if (isset($propSchema['maxLength']) && strlen($value) > $propSchema['maxLength']) { + $errors[] = "Argument '{$key}' must be at most {$propSchema['maxLength']} characters"; + } + } + + // Validate numeric constraints + if (in_array($expectedType, ['integer', 'number']) && is_numeric($value)) { + if (isset($propSchema['minimum']) && $value < $propSchema['minimum']) { + $errors[] = "Argument '{$key}' must be at least {$propSchema['minimum']}"; + } + if (isset($propSchema['maximum']) && $value > $propSchema['maximum']) { + $errors[] = "Argument '{$key}' must be at most {$propSchema['maximum']}"; + } + } + } + + 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, // Unknown types pass validation + }; + } + + // 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/packages/core-mcp/src/Mod/Mcp/Exceptions/CircuitOpenException.php b/packages/core-mcp/src/Mod/Mcp/Exceptions/CircuitOpenException.php new file mode 100644 index 0000000..779b065 --- /dev/null +++ b/packages/core-mcp/src/Mod/Mcp/Exceptions/CircuitOpenException.php @@ -0,0 +1,27 @@ + - */ - public function arguments(): array - { - return [ - new Argument( - name: 'biolink_id', - description: 'The ID of the biolink to analyse', - required: true - ), - new Argument( - name: 'period', - description: 'Analysis period: 7d, 30d, 90d (default: 30d)', - required: false - ), - ]; - } - - public function handle(): Response - { - return Response::text(<<<'PROMPT' -# Analyse Bio Link Performance - -This workflow helps you analyse a biolink's performance and provide actionable recommendations. - -## Step 1: Gather Analytics Data - -Fetch detailed analytics: -```json -{ - "action": "get_analytics_detailed", - "biolink_id": , - "period": "30d", - "include": ["geo", "devices", "referrers", "utm", "blocks"] -} -``` - -Also get basic biolink info: -```json -{ - "action": "get", - "biolink_id": -} -``` - -## Step 2: Analyse the Data - -Review these key metrics: - -### Traffic Overview -- **Total clicks**: Overall engagement -- **Unique clicks**: Individual visitors -- **Click rate trend**: Is traffic growing or declining? - -### Geographic Insights -Look at the `geo.countries` data: -- Where is traffic coming from? -- Are target markets represented? -- Any unexpected sources? - -### Device Breakdown -Examine `devices` data: -- Mobile vs desktop ratio -- Browser distribution -- Operating systems - -**Optimisation tip:** If mobile traffic is high (>60%), ensure blocks are mobile-friendly. - -### Traffic Sources -Analyse `referrers`: -- Direct traffic (typed URL, QR codes) -- Social media sources -- Search engines -- Other websites - -### UTM Campaign Performance -If using UTM tracking, review `utm`: -- Which campaigns drive traffic? -- Which sources convert best? - -### Block Performance -The `blocks` data shows: -- Which links get the most clicks -- Click-through rate per block -- Underperforming content - -## Step 3: Identify Issues - -Common issues to look for: - -### Low Click-Through Rate -If total clicks are high but block clicks are low: -- Consider reordering blocks (most important first) -- Review link text clarity -- Check if call-to-action is compelling - -### High Bounce Rate -If unique clicks are close to total clicks with low block engagement: -- Page may not match visitor expectations -- Loading issues on certain devices -- Content not relevant to traffic source - -### Geographic Mismatch -If traffic is from unexpected regions: -- Review where links are being shared -- Consider language/localisation -- Check for bot traffic - -### Mobile Performance Issues -If mobile traffic shows different patterns: -- Test page on mobile devices -- Ensure buttons are tap-friendly -- Check image loading - -## Step 4: Generate Recommendations - -Based on analysis, suggest: - -### Quick Wins -- Reorder blocks by popularity -- Update underperforming link text -- Add missing social platforms - -### Medium-Term Improvements -- Create targeted content for top traffic sources -- Implement A/B testing for key links -- Add tracking for better attribution - -### Strategic Changes -- Adjust marketing spend based on source performance -- Consider custom domains for branding -- Set up notification alerts for engagement milestones - -## Step 5: Present Findings - -Summarise for the user: - -```markdown -## Performance Summary for [Biolink Name] - -### Key Metrics (Last 30 Days) -- Total Clicks: X,XXX -- Unique Visitors: X,XXX -- Top Performing Block: [Name] (XX% of clicks) - -### Traffic Sources -1. [Source 1] - XX% -2. [Source 2] - XX% -3. [Source 3] - XX% - -### Geographic Distribution -- [Country 1] - XX% -- [Country 2] - XX% -- [Country 3] - XX% - -### Recommendations -1. [High Priority Action] -2. [Medium Priority Action] -3. [Low Priority Action] - -### Next Steps -- [Specific action item] -- Schedule follow-up analysis in [timeframe] -``` - ---- - -**Analytics Periods:** -- `7d` - Last 7 days (quick check) -- `30d` - Last 30 days (standard analysis) -- `90d` - Last 90 days (trend analysis) - -**Note:** Analytics retention may be limited based on the workspace's subscription tier. - -**Pro Tips:** -- Compare week-over-week for seasonal patterns -- Cross-reference with marketing calendar -- Export submission data for lead quality analysis -PROMPT - ); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Prompts/ConfigureNotificationsPrompt.php b/packages/core-mcp/src/Mod/Mcp/Prompts/ConfigureNotificationsPrompt.php deleted file mode 100644 index df90803..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Prompts/ConfigureNotificationsPrompt.php +++ /dev/null @@ -1,239 +0,0 @@ - - */ - public function arguments(): array - { - return [ - new Argument( - name: 'biolink_id', - description: 'The ID of the biolink to configure notifications for', - required: true - ), - new Argument( - name: 'notification_type', - description: 'Type of notification: webhook, email, slack, discord, or telegram', - required: false - ), - ]; - } - - public function handle(): Response - { - return Response::text(<<<'PROMPT' -# Configure Biolink Notifications - -Set up real-time notifications when visitors interact with your biolink page. - -## Available Event Types - -| Event | Description | -|-------|-------------| -| `click` | Page view or link click | -| `block_click` | Specific block clicked | -| `form_submit` | Email/phone/contact form submission | -| `payment` | Payment received (if applicable) | - -## Available Handler Types - -### 1. Webhook (Custom Integration) - -Send HTTP POST requests to your own endpoint: -```json -{ - "action": "create_notification_handler", - "biolink_id": , - "name": "My Webhook", - "type": "webhook", - "events": ["form_submit", "payment"], - "settings": { - "url": "https://your-server.com/webhook", - "secret": "optional-hmac-secret" - } -} -``` - -Webhook payload includes: -- Event type and timestamp -- Biolink and block details -- Visitor data (country, device type) -- Form data (for submissions) -- HMAC signature header if secret is set - -### 2. Email Notifications - -Send email alerts: -```json -{ - "action": "create_notification_handler", - "biolink_id": , - "name": "Email Alerts", - "type": "email", - "events": ["form_submit"], - "settings": { - "recipients": ["alerts@example.com", "team@example.com"], - "subject_prefix": "[BioLink]" - } -} -``` - -### 3. Slack Integration - -Post to a Slack channel: -```json -{ - "action": "create_notification_handler", - "biolink_id": , - "name": "Slack Notifications", - "type": "slack", - "events": ["form_submit", "click"], - "settings": { - "webhook_url": "https://hooks.slack.com/services/T.../B.../xxx", - "channel": "#leads", - "username": "BioLink Bot" - } -} -``` - -To get a Slack webhook URL: -1. Go to https://api.slack.com/apps -2. Create or select an app -3. Enable "Incoming Webhooks" -4. Add a webhook to your workspace - -### 4. Discord Integration - -Post to a Discord channel: -```json -{ - "action": "create_notification_handler", - "biolink_id": , - "name": "Discord Notifications", - "type": "discord", - "events": ["form_submit"], - "settings": { - "webhook_url": "https://discord.com/api/webhooks/xxx/yyy", - "username": "BioLink" - } -} -``` - -To get a Discord webhook URL: -1. Open channel settings -2. Go to Integrations > Webhooks -3. Create a new webhook - -### 5. Telegram Integration - -Send messages to a Telegram chat: -```json -{ - "action": "create_notification_handler", - "biolink_id": , - "name": "Telegram Alerts", - "type": "telegram", - "events": ["form_submit"], - "settings": { - "bot_token": "123456:ABC-DEF...", - "chat_id": "-1001234567890" - } -} -``` - -To set up Telegram: -1. Message @BotFather to create a bot -2. Get the bot token -3. Add the bot to your group/channel -4. Get the chat ID (use @userinfobot or API) - -## Managing Handlers - -### List Existing Handlers -```json -{ - "action": "list_notification_handlers", - "biolink_id": -} -``` - -### Update a Handler -```json -{ - "action": "update_notification_handler", - "handler_id": , - "events": ["form_submit"], - "is_enabled": true -} -``` - -### Test a Handler -```json -{ - "action": "test_notification_handler", - "handler_id": -} -``` - -### Disable or Delete -```json -{ - "action": "update_notification_handler", - "handler_id": , - "is_enabled": false -} -``` - -```json -{ - "action": "delete_notification_handler", - "handler_id": -} -``` - -## Auto-Disable Behaviour - -Handlers are automatically disabled after 5 consecutive failures. To re-enable: -```json -{ - "action": "update_notification_handler", - "handler_id": , - "is_enabled": true -} -``` - -This resets the failure counter. - ---- - -**Tips:** -- Use form_submit events for lead generation alerts -- Combine multiple handlers for redundancy -- Test handlers after creation to verify configuration -- Monitor trigger_count and consecutive_failures in list output -PROMPT - ); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Prompts/SetupQrCampaignPrompt.php b/packages/core-mcp/src/Mod/Mcp/Prompts/SetupQrCampaignPrompt.php deleted file mode 100644 index 17ea7c4..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Prompts/SetupQrCampaignPrompt.php +++ /dev/null @@ -1,205 +0,0 @@ - - */ - public function arguments(): array - { - return [ - new Argument( - name: 'destination_url', - description: 'The URL where the QR code should redirect to', - required: true - ), - new Argument( - name: 'campaign_name', - description: 'A name for this campaign (e.g., "Summer Flyer 2024")', - required: true - ), - new Argument( - name: 'tracking_platform', - description: 'Analytics platform to use (google_analytics, facebook, etc.)', - required: false - ), - ]; - } - - public function handle(): Response - { - return Response::text(<<<'PROMPT' -# Set Up a QR Code Campaign - -This workflow creates a trackable short link with a QR code for print materials, packaging, or any offline-to-online campaign. - -## Step 1: Gather Campaign Details - -Ask the user for: -- **Destination URL**: Where should the QR code redirect? -- **Campaign name**: For organisation (e.g., "Spring 2024 Flyers") -- **UTM parameters**: Optional tracking parameters -- **QR code style**: Colour preferences, size requirements - -## Step 2: Create a Short Link - -Create a redirect-type biolink: -```json -{ - "action": "create", - "user_id": , - "url": "", - "type": "link", - "location_url": "?utm_source=qr&utm_campaign=" -} -``` - -**Tip:** Include UTM parameters in the destination URL for better attribution in Google Analytics. - -## Step 3: Set Up Tracking Pixel (Optional) - -If the user wants conversion tracking, create a pixel: -```json -{ - "action": "create_pixel", - "user_id": , - "type": "google_analytics", - "pixel_id": "G-XXXXXXXXXX", - "name": " Tracking" -} -``` - -Available pixel types: -- `google_analytics` - GA4 measurement -- `google_tag_manager` - GTM container -- `facebook` - Meta Pixel -- `tiktok` - TikTok Pixel -- `linkedin` - LinkedIn Insight Tag -- `twitter` - Twitter Pixel - -Attach the pixel to the link: -```json -{ - "action": "attach_pixel", - "biolink_id": , - "pixel_id": -} -``` - -## Step 4: Organise in a Project - -Create or use a campaign project: -```json -{ - "action": "create_project", - "user_id": , - "name": "QR Campaigns 2024", - "color": "#6366f1" -} -``` - -Move the link to the project: -```json -{ - "action": "move_to_project", - "biolink_id": , - "project_id": -} -``` - -## Step 5: Generate the QR Code - -Generate with default settings (black on white, 400px): -```json -{ - "action": "generate_qr", - "biolink_id": -} -``` - -Generate with custom styling: -```json -{ - "action": "generate_qr", - "biolink_id": , - "size": 600, - "foreground_colour": "#1a1a1a", - "background_colour": "#ffffff", - "module_style": "rounded", - "ecc_level": "H" -} -``` - -**QR Code Options:** -- `size`: 100-1000 pixels (default: 400) -- `format`: "png" or "svg" -- `foreground_colour`: Hex colour for QR modules (default: #000000) -- `background_colour`: Hex colour for background (default: #ffffff) -- `module_style`: "square", "rounded", or "dots" -- `ecc_level`: Error correction - "L", "M", "Q", or "H" (higher = more resilient but denser) - -The response includes a `data_uri` that can be used directly in HTML or saved as an image. - -## Step 6: Set Up Notifications (Optional) - -Get notified when someone scans the QR code: -```json -{ - "action": "create_notification_handler", - "biolink_id": , - "name": " Alerts", - "type": "slack", - "events": ["click"], - "settings": { - "webhook_url": "https://hooks.slack.com/services/..." - } -} -``` - -## Step 7: Review and Deliver - -Get the final link details: -```json -{ - "action": "get", - "biolink_id": -} -``` - -Provide the user with: -1. The short URL for reference -2. The QR code image (data URI or downloadable) -3. Instructions for the print designer - ---- - -**Best Practices:** -- Use error correction level "H" for QR codes on curved surfaces or small prints -- Keep foreground/background contrast high for reliable scanning -- Test the QR code on multiple devices before printing -- Include the short URL as text near the QR code as a fallback -- Use different short links for each print run to track effectiveness -PROMPT - ); - } -} diff --git a/packages/core-mcp/src/Mod/Mcp/Servers/HostHub.php b/packages/core-mcp/src/Mod/Mcp/Servers/HostHub.php deleted file mode 100644 index 99071cb..0000000 --- a/packages/core-mcp/src/Mod/Mcp/Servers/HostHub.php +++ /dev/null @@ -1,173 +0,0 @@ -update(['workspace_id' => $workspaceId]); + } + + if (! empty($initialContext)) { + $session->updateContextSummary($initialContext); + } + + // Cache the active session ID for quick lookup + $this->cacheActiveSession($session); + + return $session; + } + + /** + * Get an active session by ID. + */ + public function get(string $sessionId): ?AgentSession + { + return AgentSession::where('session_id', $sessionId)->first(); + } + + /** + * Resume an existing session. + */ + public function resume(string $sessionId): ?AgentSession + { + $session = $this->get($sessionId); + + if (! $session) { + return null; + } + + // Only resume if paused or was handed off + if ($session->status === AgentSession::STATUS_PAUSED) { + $session->resume(); + } + + // Update activity timestamp + $session->touchActivity(); + + // Cache as active + $this->cacheActiveSession($session); + + return $session; + } + + /** + * Get active sessions for a workspace. + */ + public function getActiveSessions(?int $workspaceId = null): Collection + { + $query = AgentSession::active(); + + if ($workspaceId !== null) { + $query->where('workspace_id', $workspaceId); + } + + return $query->orderBy('last_active_at', 'desc')->get(); + } + + /** + * Get sessions for a specific plan. + */ + public function getSessionsForPlan(AgentPlan $plan): Collection + { + return AgentSession::forPlan($plan) + ->orderBy('created_at', 'desc') + ->get(); + } + + /** + * Get the most recent session for a plan. + */ + public function getLatestSessionForPlan(AgentPlan $plan): ?AgentSession + { + return AgentSession::forPlan($plan) + ->orderBy('created_at', 'desc') + ->first(); + } + + /** + * End a session. + */ + public function end(string $sessionId, string $status = AgentSession::STATUS_COMPLETED, ?string $summary = null): ?AgentSession + { + $session = $this->get($sessionId); + + if (! $session) { + return null; + } + + $session->end($status, $summary); + + // Remove from active cache + $this->clearCachedSession($session); + + return $session; + } + + /** + * Pause a session for later resumption. + */ + public function pause(string $sessionId): ?AgentSession + { + $session = $this->get($sessionId); + + if (! $session) { + return null; + } + + $session->pause(); + + return $session; + } + + /** + * Prepare a session for handoff to another agent. + */ + public function prepareHandoff( + string $sessionId, + string $summary, + array $nextSteps = [], + array $blockers = [], + array $contextForNext = [] + ): ?AgentSession { + $session = $this->get($sessionId); + + if (! $session) { + return null; + } + + $session->prepareHandoff($summary, $nextSteps, $blockers, $contextForNext); + + return $session; + } + + /** + * Get handoff context from a session. + */ + public function getHandoffContext(string $sessionId): ?array + { + $session = $this->get($sessionId); + + if (! $session) { + return null; + } + + return $session->getHandoffContext(); + } + + /** + * Create a follow-up session continuing from a previous one. + */ + public function continueFrom(string $previousSessionId, string $newAgentType): ?AgentSession + { + $previousSession = $this->get($previousSessionId); + + if (! $previousSession) { + return null; + } + + // Get the handoff context + $handoffContext = $previousSession->getHandoffContext(); + + // Create new session with context from previous + $newSession = $this->start( + $newAgentType, + $previousSession->plan, + $previousSession->workspace_id, + [ + 'continued_from' => $previousSessionId, + 'previous_agent' => $previousSession->agent_type, + 'handoff_notes' => $handoffContext['handoff_notes'] ?? null, + 'inherited_context' => $handoffContext['context_summary'] ?? null, + ] + ); + + // Mark previous session as handed off + $previousSession->end('handed_off', 'Handed off to '.$newAgentType); + + return $newSession; + } + + /** + * Store custom state in session cache for fast access. + */ + public function setState(string $sessionId, string $key, mixed $value, ?int $ttl = null): void + { + $cacheKey = self::CACHE_PREFIX.$sessionId.':'.$key; + Cache::put($cacheKey, $value, $ttl ?? $this->getCacheTtl()); + } + + /** + * Get custom state from session cache. + */ + public function getState(string $sessionId, string $key, mixed $default = null): mixed + { + $cacheKey = self::CACHE_PREFIX.$sessionId.':'.$key; + + return Cache::get($cacheKey, $default); + } + + /** + * Check if a session exists and is valid. + */ + public function exists(string $sessionId): bool + { + return AgentSession::where('session_id', $sessionId)->exists(); + } + + /** + * Check if a session is active. + */ + public function isActive(string $sessionId): bool + { + $session = $this->get($sessionId); + + return $session !== null && $session->isActive(); + } + + /** + * Get session statistics. + */ + public function getSessionStats(?int $workspaceId = null, int $days = 7): array + { + $query = AgentSession::where('created_at', '>=', now()->subDays($days)); + + if ($workspaceId !== null) { + $query->where('workspace_id', $workspaceId); + } + + $sessions = $query->get(); + + $byStatus = $sessions->groupBy('status')->map->count(); + $byAgent = $sessions->groupBy('agent_type')->map->count(); + + $completedSessions = $sessions->where('status', AgentSession::STATUS_COMPLETED); + $avgDuration = $completedSessions->avg(fn ($s) => $s->getDuration() ?? 0); + + return [ + 'total' => $sessions->count(), + 'active' => $sessions->where('status', AgentSession::STATUS_ACTIVE)->count(), + 'by_status' => $byStatus->toArray(), + 'by_agent_type' => $byAgent->toArray(), + 'avg_duration_minutes' => round($avgDuration, 1), + 'period_days' => $days, + ]; + } + + /** + * Clean up stale sessions (active but not touched in X hours). + */ + public function cleanupStaleSessions(int $hoursInactive = 24): int + { + $cutoff = now()->subHours($hoursInactive); + + $staleSessions = AgentSession::active() + ->where('last_active_at', '<', $cutoff) + ->get(); + + foreach ($staleSessions as $session) { + $session->fail('Session timed out due to inactivity'); + $this->clearCachedSession($session); + } + + return $staleSessions->count(); + } + + /** + * Cache the active session for quick lookup. + */ + protected function cacheActiveSession(AgentSession $session): void + { + $cacheKey = self::CACHE_PREFIX.'active:'.$session->session_id; + Cache::put($cacheKey, [ + 'session_id' => $session->session_id, + 'agent_type' => $session->agent_type, + 'plan_id' => $session->agent_plan_id, + 'workspace_id' => $session->workspace_id, + 'started_at' => $session->started_at?->toIso8601String(), + ], $this->getCacheTtl()); + } + + /** + * Clear cached session data. + */ + protected function clearCachedSession(AgentSession $session): void + { + $cacheKey = self::CACHE_PREFIX.'active:'.$session->session_id; + Cache::forget($cacheKey); + } +} diff --git a/packages/core-mcp/src/Mod/Mcp/Services/AgentToolRegistry.php b/packages/core-mcp/src/Mod/Mcp/Services/AgentToolRegistry.php new file mode 100644 index 0000000..40e9bdf --- /dev/null +++ b/packages/core-mcp/src/Mod/Mcp/Services/AgentToolRegistry.php @@ -0,0 +1,208 @@ + + */ + protected array $tools = []; + + /** + * Register a tool. + */ + public function register(AgentToolInterface $tool): self + { + $this->tools[$tool->name()] = $tool; + + return $this; + } + + /** + * Register multiple tools at once. + * + * @param array $tools + */ + public function registerMany(array $tools): self + { + foreach ($tools as $tool) { + $this->register($tool); + } + + return $this; + } + + /** + * Check if a tool is registered. + */ + public function has(string $name): bool + { + return isset($this->tools[$name]); + } + + /** + * Get a tool by name. + */ + public function get(string $name): ?AgentToolInterface + { + return $this->tools[$name] ?? null; + } + + /** + * Get all registered tools. + * + * @return Collection + */ + public function all(): Collection + { + return collect($this->tools); + } + + /** + * Get tools filtered by category. + * + * @return Collection + */ + public function byCategory(string $category): Collection + { + return $this->all()->filter( + fn (AgentToolInterface $tool) => $tool->category() === $category + ); + } + + /** + * Get tools accessible by an API key. + * + * @return Collection + */ + public function forApiKey(ApiKey $apiKey): Collection + { + return $this->all()->filter(function (AgentToolInterface $tool) use ($apiKey) { + // Check if API key has required scopes + foreach ($tool->requiredScopes() as $scope) { + if (! $apiKey->hasScope($scope)) { + return false; + } + } + + // Check if API key has tool-level permission + return $this->apiKeyCanAccessTool($apiKey, $tool->name()); + }); + } + + /** + * Check if an API key can access a specific tool. + */ + public function apiKeyCanAccessTool(ApiKey $apiKey, string $toolName): bool + { + $allowedTools = $apiKey->tool_scopes ?? null; + + // Null means all tools allowed + if ($allowedTools === null) { + return true; + } + + return in_array($toolName, $allowedTools, true); + } + + /** + * Execute a tool with permission checking. + * + * @param string $name Tool name + * @param array $args Tool arguments + * @param array $context Execution context + * @param ApiKey|null $apiKey Optional API key for permission checking + * @return array Tool result + * + * @throws \InvalidArgumentException If tool not found + * @throws \RuntimeException If permission denied + */ + public function execute(string $name, array $args, array $context = [], ?ApiKey $apiKey = null): array + { + $tool = $this->get($name); + + if (! $tool) { + throw new \InvalidArgumentException("Unknown tool: {$name}"); + } + + // Permission check if API key provided + if ($apiKey !== null) { + // Check scopes + foreach ($tool->requiredScopes() as $scope) { + if (! $apiKey->hasScope($scope)) { + throw new \RuntimeException( + "Permission denied: API key missing scope '{$scope}' for tool '{$name}'" + ); + } + } + + // Check tool-level permission + if (! $this->apiKeyCanAccessTool($apiKey, $name)) { + throw new \RuntimeException( + "Permission denied: API key does not have access to tool '{$name}'" + ); + } + } + + return $tool->handle($args, $context); + } + + /** + * Get all tools as MCP tool definitions. + * + * @param ApiKey|null $apiKey Filter by API key permissions + */ + public function toMcpDefinitions(?ApiKey $apiKey = null): array + { + $tools = $apiKey !== null + ? $this->forApiKey($apiKey) + : $this->all(); + + return $tools->map(fn (AgentToolInterface $tool) => $tool->toMcpDefinition()) + ->values() + ->all(); + } + + /** + * Get tool categories with counts. + */ + public function categories(): Collection + { + return $this->all() + ->groupBy(fn (AgentToolInterface $tool) => $tool->category()) + ->map(fn ($tools) => $tools->count()); + } + + /** + * Get all tool names. + * + * @return array + */ + public function names(): array + { + return array_keys($this->tools); + } + + /** + * Get tool count. + */ + public function count(): int + { + return count($this->tools); + } +} diff --git a/packages/core-mcp/src/Mod/Mcp/Services/CircuitBreaker.php b/packages/core-mcp/src/Mod/Mcp/Services/CircuitBreaker.php new file mode 100644 index 0000000..6190e69 --- /dev/null +++ b/packages/core-mcp/src/Mod/Mcp/Services/CircuitBreaker.php @@ -0,0 +1,442 @@ +getState($service); + + // Fast fail when circuit is open + if ($state === self::STATE_OPEN) { + Log::debug("Circuit breaker open for {$service}, failing fast"); + + if ($fallback !== null) { + return $fallback(); + } + + throw new CircuitOpenException($service); + } + + // Handle half-open state with trial lock to prevent concurrent trial requests + $hasTrialLock = false; + if ($state === self::STATE_HALF_OPEN) { + $hasTrialLock = $this->acquireTrialLock($service); + + if (! $hasTrialLock) { + // Another request is already testing the service, fail fast + Log::debug("Circuit breaker half-open for {$service}, trial in progress, failing fast"); + + if ($fallback !== null) { + return $fallback(); + } + + throw new CircuitOpenException($service, "Service '{$service}' is being tested. Please try again shortly."); + } + } + + // Try the operation + try { + $result = $operation(); + + // Record success and release trial lock if held + $this->recordSuccess($service); + + if ($hasTrialLock) { + $this->releaseTrialLock($service); + } + + return $result; + } catch (Throwable $e) { + // Release trial lock if held + if ($hasTrialLock) { + $this->releaseTrialLock($service); + } + + // Record failure + $this->recordFailure($service, $e); + + // Check if we should trip the circuit + if ($this->shouldTrip($service)) { + $this->tripCircuit($service); + } + + // If fallback provided and this is a recoverable error, use it + if ($fallback !== null && $this->isRecoverableError($e)) { + Log::warning("Circuit breaker using fallback for {$service}", [ + 'error' => $e->getMessage(), + ]); + + return $fallback(); + } + + throw $e; + } + } + + /** + * Get the current state of a circuit. + */ + public function getState(string $service): string + { + $cacheKey = $this->getStateKey($service); + + $state = Cache::get($cacheKey); + + if ($state === null) { + return self::STATE_CLOSED; + } + + // Check if open circuit should transition to half-open + if ($state === self::STATE_OPEN) { + $openedAt = Cache::get($this->getOpenedAtKey($service)); + $resetTimeout = $this->getResetTimeout($service); + + if ($openedAt && (time() - $openedAt) >= $resetTimeout) { + $this->setState($service, self::STATE_HALF_OPEN); + + return self::STATE_HALF_OPEN; + } + } + + return $state; + } + + /** + * Get circuit statistics for monitoring. + */ + public function getStats(string $service): array + { + return [ + 'service' => $service, + 'state' => $this->getState($service), + 'failures' => (int) Cache::get($this->getFailureCountKey($service), 0), + 'successes' => (int) Cache::get($this->getSuccessCountKey($service), 0), + 'last_failure' => Cache::get($this->getLastFailureKey($service)), + 'opened_at' => Cache::get($this->getOpenedAtKey($service)), + 'threshold' => $this->getFailureThreshold($service), + 'reset_timeout' => $this->getResetTimeout($service), + ]; + } + + /** + * Manually reset a circuit to closed state. + */ + public function reset(string $service): void + { + $this->setState($service, self::STATE_CLOSED); + Cache::forget($this->getFailureCountKey($service)); + Cache::forget($this->getSuccessCountKey($service)); + Cache::forget($this->getLastFailureKey($service)); + Cache::forget($this->getOpenedAtKey($service)); + + Log::info("Circuit breaker manually reset for {$service}"); + } + + /** + * Check if a service is available (circuit not open). + */ + public function isAvailable(string $service): bool + { + return $this->getState($service) !== self::STATE_OPEN; + } + + /** + * Record a successful operation. + */ + protected function recordSuccess(string $service): void + { + $state = $this->getState($service); + + // Increment success counter with TTL + $this->atomicIncrement($this->getSuccessCountKey($service), self::COUNTER_TTL); + + // If half-open and we got a success, close the circuit + if ($state === self::STATE_HALF_OPEN) { + $this->closeCircuit($service); + } + + // Decay failures over time (successful calls reduce failure count) + $this->atomicDecrement($this->getFailureCountKey($service)); + } + + /** + * Record a failed operation. + */ + protected function recordFailure(string $service, Throwable $e): void + { + $failureKey = $this->getFailureCountKey($service); + $lastFailureKey = $this->getLastFailureKey($service); + $window = $this->getFailureWindow($service); + + // Atomic increment with TTL refresh using lock + $newCount = $this->atomicIncrement($failureKey, $window); + + // Record last failure details + Cache::put($lastFailureKey, [ + 'message' => $e->getMessage(), + 'class' => get_class($e), + 'time' => now()->toIso8601String(), + ], $window); + + Log::warning("Circuit breaker recorded failure for {$service}", [ + 'error' => $e->getMessage(), + 'failures' => $newCount, + ]); + } + + /** + * Check if the circuit should trip (open). + */ + protected function shouldTrip(string $service): bool + { + $failures = (int) Cache::get($this->getFailureCountKey($service), 0); + $threshold = $this->getFailureThreshold($service); + + return $failures >= $threshold; + } + + /** + * Trip the circuit to open state. + */ + protected function tripCircuit(string $service): void + { + $this->setState($service, self::STATE_OPEN); + Cache::put($this->getOpenedAtKey($service), time(), 86400); // 24h max + + Log::error("Circuit breaker tripped for {$service}", [ + 'failures' => Cache::get($this->getFailureCountKey($service)), + ]); + } + + /** + * Close the circuit after successful recovery. + */ + protected function closeCircuit(string $service): void + { + $this->setState($service, self::STATE_CLOSED); + Cache::forget($this->getFailureCountKey($service)); + Cache::forget($this->getOpenedAtKey($service)); + + Log::info("Circuit breaker closed for {$service} after successful recovery"); + } + + /** + * Set circuit state. + */ + protected function setState(string $service, string $state): void + { + Cache::put($this->getStateKey($service), $state, 86400); // 24h max + } + + /** + * Check if an exception is recoverable (should use fallback). + */ + protected function isRecoverableError(Throwable $e): bool + { + // Database connection errors, table not found, etc. + $recoverablePatterns = [ + 'SQLSTATE', + 'Connection refused', + 'Table .* doesn\'t exist', + 'Base table or view not found', + 'Connection timed out', + 'Too many connections', + ]; + + $message = $e->getMessage(); + + foreach ($recoverablePatterns as $pattern) { + if (preg_match('/'.$pattern.'/i', $message)) { + return true; + } + } + + return false; + } + + /** + * Get the failure threshold from config. + */ + protected function getFailureThreshold(string $service): int + { + return (int) config("mcp.circuit_breaker.{$service}.threshold", + config('mcp.circuit_breaker.default_threshold', 5) + ); + } + + /** + * Get the reset timeout (how long to wait before trying again). + */ + protected function getResetTimeout(string $service): int + { + return (int) config("mcp.circuit_breaker.{$service}.reset_timeout", + config('mcp.circuit_breaker.default_reset_timeout', 60) + ); + } + + /** + * Get the failure window (how long failures are counted). + */ + protected function getFailureWindow(string $service): int + { + return (int) config("mcp.circuit_breaker.{$service}.failure_window", + config('mcp.circuit_breaker.default_failure_window', 120) + ); + } + + /** + * Atomically increment a counter with TTL refresh. + * + * Uses a lock to ensure the increment and TTL refresh are atomic. + */ + protected function atomicIncrement(string $key, int $ttl): int + { + $lock = Cache::lock($key.':lock', 5); + + try { + $lock->block(3); + + $current = (int) Cache::get($key, 0); + $newValue = $current + 1; + Cache::put($key, $newValue, $ttl); + + return $newValue; + } finally { + $lock->release(); + } + } + + /** + * Atomically decrement a counter (only if positive). + * + * Note: We use COUNTER_TTL as a fallback since Laravel's Cache facade + * doesn't expose remaining TTL. The counter will refresh on activity. + */ + protected function atomicDecrement(string $key): int + { + $lock = Cache::lock($key.':lock', 5); + + try { + $lock->block(3); + + $current = (int) Cache::get($key, 0); + if ($current > 0) { + $newValue = $current - 1; + Cache::put($key, $newValue, self::COUNTER_TTL); + + return $newValue; + } + + return 0; + } finally { + $lock->release(); + } + } + + /** + * Acquire a trial lock for half-open state. + * + * Only one request can hold the trial lock at a time, preventing + * concurrent trial requests during half-open state. + */ + protected function acquireTrialLock(string $service): bool + { + $lockKey = $this->getTrialLockKey($service); + + // Try to acquire lock with a short TTL (auto-release if request hangs) + return Cache::add($lockKey, true, 30); + } + + /** + * Release the trial lock. + */ + protected function releaseTrialLock(string $service): void + { + Cache::forget($this->getTrialLockKey($service)); + } + + /** + * Get the trial lock cache key. + */ + protected function getTrialLockKey(string $service): string + { + return self::CACHE_PREFIX.$service.':trial_lock'; + } + + // Cache key helpers + protected function getStateKey(string $service): string + { + return self::CACHE_PREFIX.$service.':state'; + } + + protected function getFailureCountKey(string $service): string + { + return self::CACHE_PREFIX.$service.':failures'; + } + + protected function getSuccessCountKey(string $service): string + { + return self::CACHE_PREFIX.$service.':successes'; + } + + protected function getLastFailureKey(string $service): string + { + return self::CACHE_PREFIX.$service.':last_failure'; + } + + protected function getOpenedAtKey(string $service): string + { + return self::CACHE_PREFIX.$service.':opened_at'; + } +} diff --git a/packages/core-mcp/src/Mod/Mcp/Services/DataRedactor.php b/packages/core-mcp/src/Mod/Mcp/Services/DataRedactor.php new file mode 100644 index 0000000..8910b7e --- /dev/null +++ b/packages/core-mcp/src/Mod/Mcp/Services/DataRedactor.php @@ -0,0 +1,305 @@ +redactArray($data, $maxDepth - 1); + } + + if (is_string($data)) { + return $this->redactString($data); + } + + return $data; + } + + /** + * Redact sensitive values from an array. + */ + protected function redactArray(array $data, int $maxDepth): array + { + $result = []; + + foreach ($data as $key => $value) { + $lowerKey = strtolower((string) $key); + + // Check for fully sensitive keys + if ($this->isSensitiveKey($lowerKey)) { + $result[$key] = self::REDACTED; + + continue; + } + + // Check for PII keys - partially redact + if ($this->isPiiKey($lowerKey) && is_string($value)) { + $result[$key] = $this->partialRedact($value); + + continue; + } + + // Recurse into nested arrays (with depth guard) + if (is_array($value)) { + if ($maxDepth <= 0) { + $result[$key] = '[MAX_DEPTH_EXCEEDED]'; + } else { + $result[$key] = $this->redactArray($value, $maxDepth - 1); + } + + continue; + } + + // Check string values for embedded sensitive patterns + if (is_string($value)) { + $result[$key] = $this->redactString($value); + + continue; + } + + $result[$key] = $value; + } + + return $result; + } + + /** + * Check if a key name indicates sensitive data. + */ + protected function isSensitiveKey(string $key): bool + { + foreach (self::SENSITIVE_KEYS as $sensitiveKey) { + if (str_contains($key, $sensitiveKey)) { + return true; + } + } + + return false; + } + + /** + * Check if a key name indicates PII. + */ + protected function isPiiKey(string $key): bool + { + foreach (self::PII_KEYS as $piiKey) { + if (str_contains($key, $piiKey)) { + return true; + } + } + + return false; + } + + /** + * Redact sensitive patterns from a string value. + */ + protected function redactString(string $value): string + { + // Redact bearer tokens + $value = preg_replace( + '/Bearer\s+[A-Za-z0-9\-_\.]+/i', + 'Bearer '.self::REDACTED, + $value + ) ?? $value; + + // Redact Basic auth + $value = preg_replace( + '/Basic\s+[A-Za-z0-9\+\/=]+/i', + 'Basic '.self::REDACTED, + $value + ) ?? $value; + + // Redact common API key patterns (key_xxx, sk_xxx, pk_xxx) + $value = preg_replace( + '/\b(sk|pk|key|api|token)_[a-zA-Z0-9]{16,}/i', + '$1_'.self::REDACTED, + $value + ) ?? $value; + + // Redact JWT tokens (xxx.xxx.xxx format with base64) + $value = preg_replace( + '/eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/i', + self::REDACTED, + $value + ) ?? $value; + + // Redact UK National Insurance numbers + $value = preg_replace( + '/[A-Z]{2}\s?\d{2}\s?\d{2}\s?\d{2}\s?[A-Z]/i', + self::REDACTED, + $value + ) ?? $value; + + // Redact credit card numbers (basic pattern) + $value = preg_replace( + '/\b\d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b/', + self::REDACTED, + $value + ) ?? $value; + + return $value; + } + + /** + * Partially redact a value, showing first and last characters. + */ + protected function partialRedact(string $value): string + { + $length = strlen($value); + + if ($length <= 4) { + return self::REDACTED; + } + + if ($length <= 8) { + return substr($value, 0, 2).'***'.substr($value, -1); + } + + // For longer values, show more context + $showChars = min(3, (int) floor($length / 4)); + + return substr($value, 0, $showChars).'***'.substr($value, -$showChars); + } + + /** + * Create a summary of array data without sensitive information. + * + * Useful for result_summary where we want structure info without details. + */ + public function summarize(mixed $data, int $maxDepth = 3): mixed + { + if ($maxDepth <= 0) { + return '[...]'; + } + + if (is_array($data)) { + $result = []; + $count = count($data); + + // Limit array size in summary + $limit = 10; + $truncated = $count > $limit; + $items = array_slice($data, 0, $limit, true); + + foreach ($items as $key => $value) { + $lowerKey = strtolower((string) $key); + + // Fully redact sensitive keys + if ($this->isSensitiveKey($lowerKey)) { + $result[$key] = self::REDACTED; + + continue; + } + + // Partially redact PII keys + if ($this->isPiiKey($lowerKey) && is_string($value)) { + $result[$key] = $this->partialRedact($value); + + continue; + } + + // Recurse with reduced depth + $result[$key] = $this->summarize($value, $maxDepth - 1); + } + + if ($truncated) { + $result['_truncated'] = '... and '.($count - $limit).' more items'; + } + + return $result; + } + + if (is_string($data)) { + // Redact first, then truncate (prevents leaking sensitive patterns) + $redacted = $this->redactString($data); + if (strlen($redacted) > 100) { + return substr($redacted, 0, 97).'...'; + } + + return $redacted; + } + + return $data; + } +} diff --git a/packages/core-mcp/src/Mod/Mcp/Services/ToolRateLimiter.php b/packages/core-mcp/src/Mod/Mcp/Services/ToolRateLimiter.php new file mode 100644 index 0000000..909c538 --- /dev/null +++ b/packages/core-mcp/src/Mod/Mcp/Services/ToolRateLimiter.php @@ -0,0 +1,144 @@ + false, 'remaining' => PHP_INT_MAX, 'retry_after' => null]; + } + + $limit = $this->getLimitForTool($toolName); + $decaySeconds = config('mcp.rate_limiting.decay_seconds', 60); + $cacheKey = $this->getCacheKey($identifier, $toolName); + + $current = (int) Cache::get($cacheKey, 0); + + if ($current >= $limit) { + $ttl = Cache::ttl($cacheKey); + + return [ + 'limited' => true, + 'remaining' => 0, + 'retry_after' => $ttl > 0 ? $ttl : $decaySeconds, + ]; + } + + return [ + 'limited' => false, + 'remaining' => $limit - $current - 1, + 'retry_after' => null, + ]; + } + + /** + * Record a tool call against the rate limit. + * + * @param string $identifier Session ID, API key, or other unique identifier + * @param string $toolName The tool being called + */ + public function hit(string $identifier, string $toolName): void + { + if (! config('mcp.rate_limiting.enabled', true)) { + return; + } + + $decaySeconds = config('mcp.rate_limiting.decay_seconds', 60); + $cacheKey = $this->getCacheKey($identifier, $toolName); + + $current = (int) Cache::get($cacheKey, 0); + + if ($current === 0) { + // First call - set with expiration + Cache::put($cacheKey, 1, $decaySeconds); + } else { + // Increment without resetting TTL + Cache::increment($cacheKey); + } + } + + /** + * Clear rate limit for an identifier. + * + * @param string $identifier Session ID, API key, or other unique identifier + * @param string|null $toolName Specific tool, or null to clear all + */ + public function clear(string $identifier, ?string $toolName = null): void + { + if ($toolName !== null) { + Cache::forget($this->getCacheKey($identifier, $toolName)); + } else { + // Clear all tool rate limits for this identifier (requires knowing tools) + // For now, just clear the specific key pattern + Cache::forget($this->getCacheKey($identifier, '*')); + } + } + + /** + * Get the rate limit for a specific tool. + */ + protected function getLimitForTool(string $toolName): int + { + // Check for tool-specific limit + $perToolLimits = config('mcp.rate_limiting.per_tool', []); + + if (isset($perToolLimits[$toolName])) { + return (int) $perToolLimits[$toolName]; + } + + // Use default limit + return (int) config('mcp.rate_limiting.calls_per_minute', 60); + } + + /** + * Generate cache key for rate limiting. + */ + protected function getCacheKey(string $identifier, string $toolName): string + { + // Use general key for overall rate limiting + return self::CACHE_PREFIX.$identifier.':'.$toolName; + } + + /** + * Get rate limit status for reporting. + * + * @return array{limit: int, remaining: int, reset_at: string|null} + */ + public function getStatus(string $identifier, string $toolName): array + { + $limit = $this->getLimitForTool($toolName); + $cacheKey = $this->getCacheKey($identifier, $toolName); + $current = (int) Cache::get($cacheKey, 0); + $ttl = Cache::ttl($cacheKey); + + return [ + 'limit' => $limit, + 'remaining' => max(0, $limit - $current), + 'reset_at' => $ttl > 0 ? now()->addSeconds($ttl)->toIso8601String() : null, + ]; + } +} diff --git a/packages/core-php/TODO.md b/packages/core-php/TODO.md new file mode 100644 index 0000000..cd2b085 --- /dev/null +++ b/packages/core-php/TODO.md @@ -0,0 +1,39 @@ +# Core-PHP TODO + +## Seeder Auto-Discovery + +**Priority:** Medium +**Context:** Currently apps need a `database/seeders/DatabaseSeeder.php` that manually lists module seeders in order. This is boilerplate that core-php could handle. + +### Requirements + +- Auto-discover seeders from registered modules (`*/Database/Seeders/*Seeder.php`) +- Support priority ordering via property or attribute (e.g., `public int $priority = 50`) +- Support dependency ordering via `$after` or `$before` arrays +- Provide base `DatabaseSeeder` class that apps can extend or use directly +- Allow apps to override/exclude specific seeders if needed + +### Example + +```php +// In a module seeder +class FeatureSeeder extends Seeder +{ + public int $priority = 10; // Run early + + public function run(): void { ... } +} + +class PackageSeeder extends Seeder +{ + public array $after = [FeatureSeeder::class]; // Run after features + + public function run(): void { ... } +} +``` + +### Notes + +- Current Host Hub DatabaseSeeder has ~20 seeders with implicit ordering +- Key dependencies: features → packages → workspaces → system user → content +- Could use Laravel's service container to resolve seeder graph diff --git a/packages/core-php/src/Core/Bouncer/Migrations/0001_01_01_000001_create_bouncer_tables.php b/packages/core-php/src/Core/Bouncer/Migrations/0001_01_01_000001_create_bouncer_tables.php new file mode 100644 index 0000000..090bfcd --- /dev/null +++ b/packages/core-php/src/Core/Bouncer/Migrations/0001_01_01_000001_create_bouncer_tables.php @@ -0,0 +1,67 @@ +id(); + $table->string('ip_address', 45); + $table->string('ip_range', 18)->nullable(); + $table->string('reason')->nullable(); + $table->string('source', 32)->default('manual'); + $table->string('status', 32)->default('active'); + $table->unsignedInteger('hit_count')->default(0); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('last_hit_at')->nullable(); + $table->timestamps(); + + $table->unique(['ip_address', 'ip_range']); + $table->index(['status', 'expires_at']); + $table->index('ip_address'); + }); + + // 2. Rate Limit Buckets + Schema::create('rate_limit_buckets', function (Blueprint $table) { + $table->id(); + $table->string('key'); + $table->string('bucket_type', 32); + $table->unsignedInteger('tokens')->default(0); + $table->unsignedInteger('max_tokens'); + $table->timestamp('last_refill_at'); + $table->timestamp('expires_at'); + $table->timestamps(); + + $table->unique(['key', 'bucket_type']); + $table->index('expires_at'); + }); + + Schema::enableForeignKeyConstraints(); + } + + public function down(): void + { + Schema::disableForeignKeyConstraints(); + Schema::dropIfExists('rate_limit_buckets'); + Schema::dropIfExists('blocked_ips'); + Schema::enableForeignKeyConstraints(); + } +}; diff --git a/packages/core-php/src/Core/Config/Migrations/0001_01_01_000001_create_config_tables.php b/packages/core-php/src/Core/Config/Migrations/0001_01_01_000001_create_config_tables.php new file mode 100644 index 0000000..8fdf829 --- /dev/null +++ b/packages/core-php/src/Core/Config/Migrations/0001_01_01_000001_create_config_tables.php @@ -0,0 +1,121 @@ +id(); + $table->string('code')->unique(); + $table->foreignId('parent_id')->nullable() + ->constrained('config_keys') + ->nullOnDelete(); + $table->string('type')->default('string'); + $table->string('category')->index(); + $table->string('description')->nullable(); + $table->json('default_value')->nullable(); + $table->timestamps(); + + $table->index(['category', 'code']); + }); + + // 2. Config Profiles (scope containers) + Schema::create('config_profiles', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('scope_type')->index(); + $table->unsignedBigInteger('scope_id')->nullable()->index(); + $table->foreignId('parent_profile_id')->nullable() + ->constrained('config_profiles') + ->nullOnDelete(); + $table->integer('priority')->default(0); + $table->timestamps(); + + $table->index(['scope_type', 'scope_id']); + $table->unique(['scope_type', 'scope_id', 'priority']); + }); + + // 3. Config Values + Schema::create('config_values', function (Blueprint $table) { + $table->id(); + $table->foreignId('profile_id') + ->constrained('config_profiles') + ->cascadeOnDelete(); + $table->foreignId('key_id') + ->constrained('config_keys') + ->cascadeOnDelete(); + $table->json('value')->nullable(); + $table->boolean('locked')->default(false); + $table->foreignId('inherited_from')->nullable() + ->constrained('config_profiles') + ->nullOnDelete(); + $table->timestamps(); + + $table->unique(['profile_id', 'key_id']); + $table->index(['key_id', 'locked']); + }); + + // 4. Config Channels + Schema::create('config_channels', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('code')->unique(); + $table->string('type')->default('notification'); + $table->json('settings')->nullable(); + $table->boolean('is_active')->default(true); + $table->integer('sort_order')->default(0); + $table->timestamps(); + + $table->index(['type', 'is_active']); + }); + + // 5. Config Resolved Cache + Schema::create('config_resolved', function (Blueprint $table) { + $table->id(); + $table->string('scope_type'); + $table->unsignedBigInteger('scope_id'); + $table->string('key_code'); + $table->json('resolved_value')->nullable(); + $table->foreignId('source_profile_id')->nullable() + ->constrained('config_profiles') + ->nullOnDelete(); + $table->timestamp('resolved_at'); + $table->timestamps(); + + $table->unique(['scope_type', 'scope_id', 'key_code'], 'config_resolved_unique'); + $table->index(['scope_type', 'scope_id']); + $table->index('key_code'); + }); + + Schema::enableForeignKeyConstraints(); + } + + public function down(): void + { + Schema::disableForeignKeyConstraints(); + Schema::dropIfExists('config_resolved'); + Schema::dropIfExists('config_channels'); + Schema::dropIfExists('config_values'); + Schema::dropIfExists('config_profiles'); + Schema::dropIfExists('config_keys'); + Schema::enableForeignKeyConstraints(); + } +}; diff --git a/packages/core-php/src/Core/Tests/Unit/LazyModuleListenerTest.php b/packages/core-php/src/Core/Tests/Unit/LazyModuleListenerTest.php new file mode 100644 index 0000000..7e157b0 --- /dev/null +++ b/packages/core-php/src/Core/Tests/Unit/LazyModuleListenerTest.php @@ -0,0 +1,93 @@ +getModuleClass())->toBe(TestLazyModule::class); + expect($listener->getMethod())->toBe('handleEvent'); + }); + + it('invokes the module method when called', function () { + TestLazyModule::$called = false; + TestLazyModule::$receivedEvent = null; + + $listener = new LazyModuleListener( + TestLazyModule::class, + 'handleEvent' + ); + + $event = new TestEvent('test data'); + $listener($event); + + expect(TestLazyModule::$called)->toBeTrue(); + expect(TestLazyModule::$receivedEvent)->toBe($event); + }); + + it('reuses the same module instance on multiple calls', function () { + TestLazyModule::$instanceCount = 0; + + $listener = new LazyModuleListener( + TestLazyModule::class, + 'handleEvent' + ); + + $event = new TestEvent('test'); + $listener($event); + $listener($event); + $listener($event); + + expect(TestLazyModule::$instanceCount)->toBe(1); + }); + + it('handle method is alias for __invoke', function () { + TestLazyModule::$called = false; + TestLazyModule::$receivedEvent = null; + + $listener = new LazyModuleListener( + TestLazyModule::class, + 'handleEvent' + ); + + $event = new TestEvent('handle test'); + $listener->handle($event); + + expect(TestLazyModule::$called)->toBeTrue(); + expect(TestLazyModule::$receivedEvent)->toBe($event); + }); +}); + +// Test fixtures + +class TestEvent +{ + public function __construct(public string $data) {} +} + +class TestLazyModule +{ + public static bool $called = false; + + public static ?TestEvent $receivedEvent = null; + + public static int $instanceCount = 0; + + public function __construct() + { + self::$instanceCount++; + } + + public function handleEvent(TestEvent $event): void + { + self::$called = true; + self::$receivedEvent = $event; + } +} + diff --git a/packages/core-php/src/Core/Tests/Unit/ModuleScannerTest.php b/packages/core-php/src/Core/Tests/Unit/ModuleScannerTest.php new file mode 100644 index 0000000..4c570f2 --- /dev/null +++ b/packages/core-php/src/Core/Tests/Unit/ModuleScannerTest.php @@ -0,0 +1,92 @@ +scanner = new ModuleScanner; +}); + +describe('extractListens', function () { + it('extracts $listens from a class with public static property', function () { + $listens = $this->scanner->extractListens(TestModuleWithListens::class); + + expect($listens)->toBe([ + 'SomeEvent' => 'handleSomeEvent', + 'AnotherEvent' => 'onAnother', + ]); + }); + + it('returns empty array when class has no $listens property', function () { + $listens = $this->scanner->extractListens(TestModuleWithoutListens::class); + + expect($listens)->toBe([]); + }); + + it('returns empty array when $listens is not public', function () { + $listens = $this->scanner->extractListens(TestModuleWithPrivateListens::class); + + expect($listens)->toBe([]); + }); + + it('returns empty array when $listens is not static', function () { + $listens = $this->scanner->extractListens(TestModuleWithNonStaticListens::class); + + expect($listens)->toBe([]); + }); + + it('returns empty array when $listens is not an array', function () { + $listens = $this->scanner->extractListens(TestModuleWithStringListens::class); + + expect($listens)->toBe([]); + }); + + it('returns empty array for non-existent class', function () { + $listens = $this->scanner->extractListens('NonExistentClass'); + + expect($listens)->toBe([]); + }); +}); + +describe('scan', function () { + it('skips non-existent directories', function () { + $result = $this->scanner->scan(['/path/that/does/not/exist']); + + expect($result)->toBe([]); + }); +}); + +// Test fixtures - these classes are used to test reflection behaviour + +class TestModuleWithListens +{ + public static array $listens = [ + 'SomeEvent' => 'handleSomeEvent', + 'AnotherEvent' => 'onAnother', + ]; +} + +class TestModuleWithoutListens +{ + public function boot(): void {} +} + +class TestModuleWithPrivateListens +{ + private static array $listens = [ + 'SomeEvent' => 'handleSomeEvent', + ]; +} + +class TestModuleWithNonStaticListens +{ + public array $listens = [ + 'SomeEvent' => 'handleSomeEvent', + ]; +} + +class TestModuleWithStringListens +{ + public static string $listens = 'not an array'; +} diff --git a/packages/core-php/src/Mod/Tenant/Concerns/BelongsToNamespace.php b/packages/core-php/src/Mod/Tenant/Concerns/BelongsToNamespace.php new file mode 100644 index 0000000..ab25e5b --- /dev/null +++ b/packages/core-php/src/Mod/Tenant/Concerns/BelongsToNamespace.php @@ -0,0 +1,247 @@ +where('is_active', true)->get(); + */ +trait BelongsToNamespace +{ + /** + * Boot the trait - sets up auto-assignment of namespace_id and cache invalidation. + */ + protected static function bootBelongsToNamespace(): void + { + // Auto-assign namespace_id when creating a model without one + static::creating(function ($model) { + if (empty($model->namespace_id)) { + $namespace = static::getCurrentNamespace(); + if ($namespace) { + $model->namespace_id = $namespace->id; + } + } + }); + + static::saved(function ($model) { + if ($model->namespace_id) { + static::clearNamespaceCache($model->namespace_id); + } + }); + + static::deleted(function ($model) { + if ($model->namespace_id) { + static::clearNamespaceCache($model->namespace_id); + } + }); + } + + /** + * Get the namespace this model belongs to. + */ + public function namespace(): BelongsTo + { + return $this->belongsTo(Namespace_::class, 'namespace_id'); + } + + /** + * Scope query to the current namespace. + */ + public function scopeOwnedByCurrentNamespace(Builder $query): Builder + { + $namespace = static::getCurrentNamespace(); + + if (! $namespace) { + return $query->whereRaw('1 = 0'); // Return empty result + } + + return $query->where('namespace_id', $namespace->id); + } + + /** + * Scope query to a specific namespace. + */ + public function scopeForNamespace(Builder $query, Namespace_|int $namespace): Builder + { + $namespaceId = $namespace instanceof Namespace_ ? $namespace->id : $namespace; + + return $query->where('namespace_id', $namespaceId); + } + + /** + * Scope query to all namespaces accessible by the current user. + */ + public function scopeAccessibleByCurrentUser(Builder $query): Builder + { + $user = auth()->user(); + + if (! $user || ! $user instanceof User) { + return $query->whereRaw('1 = 0'); // Return empty result + } + + $namespaceIds = Namespace_::accessibleBy($user)->pluck('id'); + + return $query->whereIn('namespace_id', $namespaceIds); + } + + /** + * Get all models owned by the current namespace, cached. + * + * @param int $ttl Cache TTL in seconds (default 5 minutes) + */ + public static function ownedByCurrentNamespaceCached(int $ttl = 300): Collection + { + $namespace = static::getCurrentNamespace(); + + if (! $namespace) { + return collect(); + } + + return Cache::remember( + static::namespaceCacheKey($namespace->id), + $ttl, + fn () => static::ownedByCurrentNamespace()->get() + ); + } + + /** + * Get all models for a specific namespace, cached. + * + * @param int $ttl Cache TTL in seconds (default 5 minutes) + */ + public static function forNamespaceCached(Namespace_|int $namespace, int $ttl = 300): Collection + { + $namespaceId = $namespace instanceof Namespace_ ? $namespace->id : $namespace; + + return Cache::remember( + static::namespaceCacheKey($namespaceId), + $ttl, + fn () => static::forNamespace($namespaceId)->get() + ); + } + + /** + * Get the cache key for a namespace's model collection. + */ + protected static function namespaceCacheKey(int $namespaceId): string + { + $modelClass = class_basename(static::class); + + return "namespace.{$namespaceId}.{$modelClass}"; + } + + /** + * Clear the cache for a namespace's model collection. + */ + public static function clearNamespaceCache(int $namespaceId): void + { + Cache::forget(static::namespaceCacheKey($namespaceId)); + } + + /** + * Clear cache for all namespaces accessible to current user. + */ + public static function clearAllNamespaceCache(): void + { + $user = auth()->user(); + + if ($user && $user instanceof User) { + $namespaces = Namespace_::accessibleBy($user)->get(); + foreach ($namespaces as $namespace) { + static::clearNamespaceCache($namespace->id); + } + } + } + + /** + * Get the current namespace from session/request. + */ + protected static function getCurrentNamespace(): ?Namespace_ + { + // Try to get from request attributes (set by middleware) + if (request()->attributes->has('current_namespace')) { + return request()->attributes->get('current_namespace'); + } + + // Try to get from session + $namespaceUuid = session('current_namespace_uuid'); + if ($namespaceUuid) { + $namespace = Namespace_::where('uuid', $namespaceUuid)->first(); + if ($namespace) { + return $namespace; + } + } + + // Fall back to user's default namespace + $user = auth()->user(); + if ($user && method_exists($user, 'defaultNamespace')) { + return $user->defaultNamespace(); + } + + return null; + } + + /** + * Check if this model belongs to the given namespace. + */ + public function belongsToNamespace(Namespace_|int $namespace): bool + { + $namespaceId = $namespace instanceof Namespace_ ? $namespace->id : $namespace; + + return $this->namespace_id === $namespaceId; + } + + /** + * Check if this model belongs to the current namespace. + */ + public function belongsToCurrentNamespace(): bool + { + $namespace = static::getCurrentNamespace(); + + if (! $namespace) { + return false; + } + + return $this->belongsToNamespace($namespace); + } + + /** + * Check if the current user can access this model. + */ + public function isAccessibleByCurrentUser(): bool + { + $user = auth()->user(); + + if (! $user || ! $user instanceof User) { + return false; + } + + if (! $this->namespace) { + return false; + } + + return $this->namespace->isAccessibleBy($user); + } +} diff --git a/packages/core-api/src/Mod/Api/Controllers/EntitlementApiController.php b/packages/core-php/src/Mod/Tenant/Controllers/EntitlementApiController.php similarity index 98% rename from packages/core-api/src/Mod/Api/Controllers/EntitlementApiController.php rename to packages/core-php/src/Mod/Tenant/Controllers/EntitlementApiController.php index ab855a3..806d6c9 100644 --- a/packages/core-api/src/Mod/Api/Controllers/EntitlementApiController.php +++ b/packages/core-php/src/Mod/Tenant/Controllers/EntitlementApiController.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Core\Mod\Api\Controllers; +namespace Mod\Api\Controllers; use Core\Front\Controller; -use Core\Mod\Tenant\Models\EntitlementLog; -use Core\Mod\Tenant\Models\Package; -use Core\Mod\Tenant\Models\WorkspacePackage; -use Core\Mod\Tenant\Models\User; -use Core\Mod\Tenant\Models\Workspace; -use Core\Mod\Tenant\Services\EntitlementService; +use Mod\Tenant\Models\EntitlementLog; +use Mod\Tenant\Models\Package; +use Mod\Tenant\Models\WorkspacePackage; +use Mod\Tenant\Models\User; +use Mod\Tenant\Models\Workspace; +use Mod\Tenant\Services\EntitlementService; use Illuminate\Auth\Events\Registered; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; diff --git a/packages/core-api/src/Mod/Api/Controllers/WorkspaceController.php b/packages/core-php/src/Mod/Tenant/Controllers/WorkspaceController.php similarity index 85% rename from packages/core-api/src/Mod/Api/Controllers/WorkspaceController.php rename to packages/core-php/src/Mod/Tenant/Controllers/WorkspaceController.php index dc3e335..02ece49 100644 --- a/packages/core-api/src/Mod/Api/Controllers/WorkspaceController.php +++ b/packages/core-php/src/Mod/Tenant/Controllers/WorkspaceController.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Core\Mod\Api\Controllers; +namespace Mod\Api\Controllers; use Core\Front\Controller; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Core\Mod\Api\Controllers\Concerns\HasApiResponses; -use Core\Mod\Api\Controllers\Concerns\ResolvesWorkspace; -use Core\Mod\Api\Resources\PaginatedCollection; -use Core\Mod\Api\Resources\WorkspaceResource; -use Core\Mod\Tenant\Models\User; -use Core\Mod\Tenant\Models\Workspace; +use Mod\Api\Controllers\Concerns\HasApiResponses; +use Mod\Api\Controllers\Concerns\ResolvesWorkspace; +use Mod\Api\Resources\PaginatedCollection; +use Mod\Api\Resources\WorkspaceResource; +use Mod\Tenant\Models\User; +use Mod\Tenant\Models\Workspace; /** * Workspace API controller. @@ -250,18 +250,25 @@ class WorkspaceController extends Controller return $this->notFoundResponse('Workspace'); } - // Clear existing defaults - $user->workspaces() - ->where('domain', 'hub.host.uk.com') - ->updateExistingPivot( - $user->workspaces()->pluck('workspaces.id')->toArray(), - ['is_default' => false] - ); + // Use a single transaction with optimised query: + // Clear all defaults and set the new one in one operation using raw update + \Illuminate\Support\Facades\DB::transaction(function () use ($user, $workspace) { + // Clear all existing defaults for this user's hub workspaces + \Illuminate\Support\Facades\DB::table('user_workspace') + ->where('user_id', $user->id) + ->whereIn('workspace_id', function ($query) { + $query->select('id') + ->from('workspaces') + ->where('domain', 'hub.host.uk.com'); + }) + ->update(['is_default' => false]); - // Set new default - $user->workspaces()->updateExistingPivot($workspace->id, [ - 'is_default' => true, - ]); + // Set the new default + \Illuminate\Support\Facades\DB::table('user_workspace') + ->where('user_id', $user->id) + ->where('workspace_id', $workspace->id) + ->update(['is_default' => true]); + }); $workspace->loadCount(['users', 'bioPages']); diff --git a/packages/core-php/src/Mod/Tenant/Database/Seeders/SystemUserSeeder.php b/packages/core-php/src/Mod/Tenant/Database/Seeders/SystemUserSeeder.php deleted file mode 100644 index 61b4b6d..0000000 --- a/packages/core-php/src/Mod/Tenant/Database/Seeders/SystemUserSeeder.php +++ /dev/null @@ -1,388 +0,0 @@ - 1], - [ - 'name' => 'Snider', - 'email' => 'snider@host.uk.com', - 'password' => Hash::make('change-me-in-env'), - 'tier' => UserTier::HADES, - 'tier_expires_at' => null, // Never expires - 'email_verified_at' => now(), - ] - ); - - // Environment-aware domain - $isLocal = app()->environment('local'); - $host = $isLocal ? 'host.test' : 'host.uk.com'; - - // Create default workspace if none exists - $workspace = Workspace::firstOrCreate( - ['slug' => 'system'], - [ - 'name' => 'System', - 'domain' => $host, - 'icon' => 'shield-check', - 'color' => 'red', - 'description' => 'System workspace for platform administration', - 'type' => 'custom', - 'is_active' => true, - 'sort_order' => 0, - ] - ); - - // Attach user to workspace as owner (or update if exists) - if ($workspace->users()->where('user_id', $user->id)->exists()) { - // Update existing pivot to ensure system is default - $workspace->users()->updateExistingPivot($user->id, [ - 'role' => 'owner', - 'is_default' => true, - ]); - // Clear other defaults for this user - \Illuminate\Support\Facades\DB::table('user_workspace') - ->where('user_id', $user->id) - ->where('workspace_id', '!=', $workspace->id) - ->update(['is_default' => false]); - } else { - $workspace->users()->attach($user->id, [ - 'role' => 'owner', - 'is_default' => true, - ]); - } - - // Assign hermes package (founding patron - unlimited everything) - $hermesPackage = Package::where('code', 'hermes')->first(); - if ($hermesPackage) { - WorkspacePackage::updateOrCreate( - [ - 'workspace_id' => $workspace->id, - 'package_id' => $hermesPackage->id, - ], - [ - 'status' => WorkspacePackage::STATUS_ACTIVE, - 'starts_at' => now(), - 'expires_at' => null, // Never expires - ] - ); - } - - // Assign hades package (developer tools) - $hadesPackage = Package::where('code', 'hades')->first(); - if ($hadesPackage) { - WorkspacePackage::updateOrCreate( - [ - 'workspace_id' => $workspace->id, - 'package_id' => $hadesPackage->id, - ], - [ - 'status' => WorkspacePackage::STATUS_ACTIVE, - 'starts_at' => now(), - 'expires_at' => null, // Never expires - ] - ); - } - - // Assign apollo package (beta features) - $apolloPackage = Package::where('code', 'apollo')->first(); - if ($apolloPackage) { - WorkspacePackage::updateOrCreate( - [ - 'workspace_id' => $workspace->id, - 'package_id' => $apolloPackage->id, - ], - [ - 'status' => WorkspacePackage::STATUS_ACTIVE, - 'starts_at' => now(), - 'expires_at' => null, // Never expires - ] - ); - } - - $this->command->info("System user created: {$user->email} (ID: {$user->id})"); - $this->command->info("System workspace: {$workspace->name} with Hermes + Hades + Apollo packages"); - - // Set up services for host.uk.com - $this->setupServices($workspace, $user); - } - - /** - * Set up all services for the System workspace. - */ - protected function setupServices(Workspace $workspace, User $user): void - { - // Environment-aware domains: .test for local, .uk.com for production - $isLocal = app()->environment('local'); - $host = $isLocal ? 'host.test' : 'host.uk.com'; - $email = $isLocal ? 'support@host.test' : 'support@host.uk.com'; - - // Analytics - website tracking - $analyticsWebsite = AnalyticsWebsite::updateOrCreate( - [ - 'workspace_id' => $workspace->id, - 'host' => $host, - ], - [ - 'user_id' => $user->id, - 'name' => 'Host UK', - 'url' => "https://{$host}", - 'pixel_key' => Str::uuid()->toString(), - 'tracking_enabled' => true, - 'is_enabled' => true, - 'track_pageviews' => true, - 'track_sessions' => true, - 'track_goals' => true, - ] - ); - $this->command->info("Analytics: {$analyticsWebsite->name} ({$analyticsWebsite->host})"); - - // Trust - reviews campaign - $trustCampaign = TrustCampaign::updateOrCreate( - [ - 'workspace_id' => $workspace->id, - 'host' => $host, - ], - [ - 'user_id' => $user->id, - 'name' => 'Host UK Reviews', - 'pixel_key' => Str::uuid()->toString(), - 'is_enabled' => true, - 'primary_color' => '#8b5cf6', // Violet to match brand - ] - ); - $this->command->info("Trust: {$trustCampaign->name} ({$trustCampaign->host})"); - - // Notify - push notifications - $vapidKeys = PushWebsite::generateVapidKeys(); - $pushWebsite = PushWebsite::updateOrCreate( - [ - 'workspace_id' => $workspace->id, - 'host' => $host, - ], - [ - 'user_id' => $user->id, - 'name' => 'Host UK', - 'pixel_key' => Str::uuid()->toString(), - 'vapid_public_key' => $vapidKeys['public'], - 'vapid_private_key' => $vapidKeys['private'], - 'is_enabled' => true, - 'widget_settings' => array_merge( - PushWebsite::defaultWidgetSettings(), - [ - 'primary_color' => '#8b5cf6', - 'prompt_title' => 'Stay in the loop', - 'prompt_message' => 'Get notified about new features and updates.', - ] - ), - ] - ); - $this->command->info("Notify: {$pushWebsite->name} ({$pushWebsite->host})"); - - // Support - mailbox - $mailbox = Mailbox::updateOrCreate( - [ - 'workspace_id' => $workspace->id, - 'email' => $email, - ], - [ - 'name' => 'Support', - 'slug' => 'support', - 'signature' => "Best regards,\nThe Host UK Team", - 'auto_reply_enabled' => false, - ] - ); - $this->command->info("Support: {$mailbox->name} ({$mailbox->email})"); - - // Bio - link in bio page - $this->setupBioPage($workspace, $user); - } - - /** - * Set up the Host UK bio page. - */ - protected function setupBioPage(Workspace $workspace, User $user): void - { - // Environment-aware domains - $isLocal = app()->environment('local'); - $host = $isLocal ? 'host.test' : 'host.uk.com'; - $helpHost = $isLocal ? 'help.host.test' : 'help.host.uk.com'; - - // Create the main bio page - $bioPage = BioPage::updateOrCreate( - [ - 'workspace_id' => $workspace->id, - 'url' => 'hostuk', - ], - [ - 'user_id' => $user->id, - 'type' => 'biolink', - 'is_enabled' => true, - 'settings' => [ - 'seo' => [ - 'title' => 'Host UK - Creator Hosting Tools', - 'description' => 'Premium hosting tools for UK creators. Bio pages, social scheduling, privacy-first analytics, and more.', - ], - 'theme' => [ - 'background' => [ - 'type' => 'gradient', - 'gradient_start' => '#1e1b4b', - 'gradient_end' => '#312e81', - 'gradient_direction' => '180', - ], - 'text_color' => '#ffffff', - 'font_family' => 'Inter', - 'button' => [ - 'background_color' => '#8b5cf6', - 'text_color' => '#ffffff', - 'border_radius' => '12px', - 'border_width' => '0', - ], - ], - ], - ] - ); - - // Create redirect from /host to /hostuk - BioPage::updateOrCreate( - [ - 'workspace_id' => $workspace->id, - 'url' => 'host', - ], - [ - 'user_id' => $user->id, - 'type' => 'link', - 'location_url' => '/hostuk', - 'is_enabled' => true, - ] - ); - - // Set up blocks (delete existing and recreate for clean state) - $bioPage->blocks()->delete(); - - $blocks = [ - [ - 'type' => 'avatar', - 'order' => 1, - 'settings' => [ - 'image' => '/images/host-uk-logo-icon.png', - 'size' => 'large', - 'shape' => 'rounded', - ], - ], - [ - 'type' => 'heading', - 'order' => 2, - 'settings' => [ - 'text' => 'Host UK', - 'size' => 'large', - ], - ], - [ - 'type' => 'paragraph', - 'order' => 3, - 'settings' => [ - 'text' => 'Premium hosting tools for UK creators and businesses. EU-hosted, GDPR compliant.', - ], - ], - [ - 'type' => 'socials', - 'order' => 4, - 'settings' => [ - 'style' => 'rounded', - 'size' => 'medium', - 'socials' => [ - ['platform' => 'x', 'url' => 'https://x.com/hostukcom'], - ['platform' => 'github', 'url' => 'https://github.com/nicsnide'], - ['platform' => 'linkedin', 'url' => 'https://linkedin.com/company/hostukcom'], - ], - ], - ], - [ - 'type' => 'divider', - 'order' => 5, - 'settings' => ['style' => 'line'], - ], - [ - 'type' => 'link', - 'order' => 6, - 'location_url' => "https://{$host}", - 'settings' => [ - 'name' => 'Visit Host UK', - 'icon' => 'globe', - ], - ], - [ - 'type' => 'link', - 'order' => 7, - 'location_url' => "https://{$host}/pricing", - 'settings' => [ - 'name' => 'View Pricing', - 'icon' => 'tag', - ], - ], - [ - 'type' => 'link', - 'order' => 8, - 'location_url' => "https://{$helpHost}", - 'settings' => [ - 'name' => 'Help Centre', - 'icon' => 'book-open', - ], - ], - [ - 'type' => 'link', - 'order' => 9, - 'location_url' => "mailto:hello@{$host}", - 'settings' => [ - 'name' => 'Get in Touch', - 'icon' => 'envelope', - ], - ], - ]; - - foreach ($blocks as $blockData) { - Block::create([ - 'workspace_id' => $workspace->id, - 'biolink_id' => $bioPage->id, - 'type' => $blockData['type'], - 'order' => $blockData['order'], - 'location_url' => $blockData['location_url'] ?? null, - 'settings' => $blockData['settings'], - 'is_enabled' => true, - ]); - } - - $this->command->info("Bio: /{$bioPage->url} (with ".count($blocks).' blocks)'); - $this->command->info('Bio: /host -> /hostuk (redirect)'); - } -} diff --git a/packages/core-php/src/Mod/Tenant/Middleware/ResolveNamespace.php b/packages/core-php/src/Mod/Tenant/Middleware/ResolveNamespace.php new file mode 100644 index 0000000..9a8eed9 --- /dev/null +++ b/packages/core-php/src/Mod/Tenant/Middleware/ResolveNamespace.php @@ -0,0 +1,59 @@ +query('namespace')) { + $namespace = $this->namespaceService->findByUuid($namespaceUuid); + if ($namespace && $this->namespaceService->canAccess($namespace)) { + // Store in session for subsequent requests + $this->namespaceService->setCurrent($namespace); + $request->attributes->set('current_namespace', $namespace); + + return $next($request); + } + } + + // Try to resolve namespace from header (for API requests) + if ($namespaceUuid = $request->header('X-Namespace')) { + $namespace = $this->namespaceService->findByUuid($namespaceUuid); + if ($namespace && $this->namespaceService->canAccess($namespace)) { + $request->attributes->set('current_namespace', $namespace); + + return $next($request); + } + } + + // Try to resolve from session + $namespace = $this->namespaceService->current(); + if ($namespace) { + $request->attributes->set('current_namespace', $namespace); + } + + return $next($request); + } +} diff --git a/packages/core-php/src/Mod/Tenant/Migrations/0001_01_01_000000_create_tenant_tables.php b/packages/core-php/src/Mod/Tenant/Migrations/0001_01_01_000000_create_tenant_tables.php new file mode 100644 index 0000000..8f624c5 --- /dev/null +++ b/packages/core-php/src/Mod/Tenant/Migrations/0001_01_01_000000_create_tenant_tables.php @@ -0,0 +1,316 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->string('tier')->default('free'); + $table->timestamp('tier_expires_at')->nullable(); + $table->timestamps(); + }); + + // 2. Password Reset Tokens + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + // 3. Sessions + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + + // 4. Workspaces (the tenant boundary) + Schema::create('workspaces', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('domain')->nullable(); + $table->string('icon')->nullable(); + $table->string('color')->nullable(); + $table->text('description')->nullable(); + $table->string('type')->default('default'); + $table->json('settings')->nullable(); + $table->boolean('is_active')->default(true); + $table->integer('sort_order')->default(0); + + // WP Connector fields + $table->boolean('wp_connector_enabled')->default(false); + $table->string('wp_connector_url')->nullable(); + $table->string('wp_connector_secret')->nullable(); + $table->timestamp('wp_connector_verified_at')->nullable(); + $table->timestamp('wp_connector_last_sync')->nullable(); + $table->json('wp_connector_config')->nullable(); + + // Billing fields + $table->string('stripe_customer_id')->nullable(); + $table->string('btcpay_customer_id')->nullable(); + $table->string('billing_name')->nullable(); + $table->string('billing_email')->nullable(); + $table->string('billing_address_line1')->nullable(); + $table->string('billing_address_line2')->nullable(); + $table->string('billing_city')->nullable(); + $table->string('billing_state')->nullable(); + $table->string('billing_postal_code')->nullable(); + $table->string('billing_country')->nullable(); + $table->string('vat_number')->nullable(); + $table->string('tax_id')->nullable(); + $table->boolean('tax_exempt')->default(false); + + $table->timestamps(); + $table->softDeletes(); + }); + + // 5. User Workspace Pivot + Schema::create('user_workspace', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('role')->default('member'); + $table->boolean('is_default')->default(false); + $table->timestamps(); + + $table->unique(['user_id', 'workspace_id']); + }); + + // 6. Namespaces + Schema::create('namespaces', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('name', 128); + $table->string('slug', 64); + $table->string('description', 512)->nullable(); + $table->string('icon', 64)->default('folder'); + $table->string('color', 16)->default('zinc'); + + // Polymorphic owner (User::class or Workspace::class) + $table->morphs('owner'); + + // Workspace context for billing aggregation + $table->foreignId('workspace_id')->nullable() + ->constrained()->nullOnDelete(); + + $table->json('settings')->nullable(); + $table->boolean('is_default')->default(false); + $table->boolean('is_active')->default(true); + $table->smallInteger('sort_order')->default(0); + + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['owner_type', 'owner_id', 'slug']); + $table->index(['workspace_id', 'is_active']); + $table->index(['owner_type', 'owner_id', 'is_active']); + }); + + // 7. Entitlement Features + Schema::create('entitlement_features', function (Blueprint $table) { + $table->id(); + $table->string('code')->unique(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('category')->nullable(); + $table->enum('type', ['boolean', 'limit', 'unlimited'])->default('boolean'); + $table->enum('reset_type', ['none', 'monthly', 'rolling'])->default('none'); + $table->integer('rolling_window_days')->nullable(); + $table->foreignId('parent_feature_id')->nullable() + ->constrained('entitlement_features')->nullOnDelete(); + $table->integer('sort_order')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->index(['category', 'sort_order']); + $table->index('category'); + }); + + // 8. Entitlement Packages + Schema::create('entitlement_packages', function (Blueprint $table) { + $table->id(); + $table->string('code')->unique(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('icon')->nullable(); + $table->string('color')->nullable(); + $table->integer('sort_order')->default(0); + $table->boolean('is_stackable')->default(true); + $table->boolean('is_base_package')->default(false); + $table->boolean('is_active')->default(true); + $table->boolean('is_public')->default(true); + $table->decimal('monthly_price', 10, 2)->nullable(); + $table->decimal('yearly_price', 10, 2)->nullable(); + $table->decimal('setup_fee', 10, 2)->default(0); + $table->unsignedInteger('trial_days')->default(0); + $table->string('stripe_monthly_price_id')->nullable(); + $table->string('stripe_yearly_price_id')->nullable(); + $table->string('btcpay_monthly_price_id')->nullable(); + $table->string('btcpay_yearly_price_id')->nullable(); + $table->string('blesta_package_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('blesta_package_id'); + }); + + // 9. Entitlement Package Features + Schema::create('entitlement_package_features', function (Blueprint $table) { + $table->id(); + $table->foreignId('package_id')->constrained('entitlement_packages')->cascadeOnDelete(); + $table->foreignId('feature_id')->constrained('entitlement_features')->cascadeOnDelete(); + $table->unsignedBigInteger('limit_value')->nullable(); + $table->timestamps(); + + $table->unique(['package_id', 'feature_id']); + }); + + // 10. Entitlement Workspace Packages + Schema::create('entitlement_workspace_packages', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignId('package_id')->constrained('entitlement_packages')->cascadeOnDelete(); + $table->enum('status', ['active', 'suspended', 'cancelled', 'expired'])->default('active'); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('billing_cycle_anchor')->nullable(); + $table->string('blesta_service_id')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['workspace_id', 'status'], 'ent_ws_pkg_ws_status_idx'); + $table->index(['expires_at', 'status'], 'ent_ws_pkg_expires_status_idx'); + $table->index('blesta_service_id'); + }); + + // 11. Entitlement Namespace Packages + Schema::create('entitlement_namespace_packages', function (Blueprint $table) { + $table->id(); + $table->foreignId('namespace_id')->constrained('namespaces')->cascadeOnDelete(); + $table->foreignId('package_id')->constrained('entitlement_packages')->cascadeOnDelete(); + $table->enum('status', ['active', 'suspended', 'cancelled', 'expired'])->default('active'); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['namespace_id', 'status']); + $table->index(['expires_at', 'status']); + }); + + // 12. Entitlement Boosts + Schema::create('entitlement_boosts', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('feature_code'); + $table->enum('boost_type', ['add_limit', 'enable', 'unlimited'])->default('add_limit'); + $table->enum('duration_type', ['cycle_bound', 'duration', 'permanent'])->default('cycle_bound'); + $table->unsignedBigInteger('limit_value')->nullable(); + $table->unsignedBigInteger('consumed_quantity')->default(0); + $table->enum('status', ['active', 'exhausted', 'expired', 'cancelled'])->default('active'); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->string('blesta_addon_id')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['workspace_id', 'feature_code', 'status'], 'ent_boosts_ws_feat_status_idx'); + $table->index(['expires_at', 'status'], 'ent_boosts_expires_status_idx'); + $table->index('feature_code'); + $table->index('blesta_addon_id'); + }); + + // 13. Entitlement Usage Records + Schema::create('entitlement_usage_records', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('feature_code'); + $table->unsignedBigInteger('quantity')->default(1); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->json('metadata')->nullable(); + $table->timestamp('recorded_at'); + $table->timestamps(); + + $table->index(['workspace_id', 'feature_code', 'recorded_at'], 'ent_usage_ws_feat_rec_idx'); + $table->index('recorded_at', 'ent_usage_recorded_idx'); + $table->index('feature_code'); + }); + + // 14. Entitlement Logs + Schema::create('entitlement_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('action'); + $table->string('entity_type'); + $table->unsignedBigInteger('entity_id')->nullable(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('source')->nullable(); + $table->json('old_values')->nullable(); + $table->json('new_values')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['workspace_id', 'action'], 'ent_logs_ws_action_idx'); + $table->index(['entity_type', 'entity_id'], 'ent_logs_entity_idx'); + $table->index('created_at', 'ent_logs_created_idx'); + }); + + // 15. User Two-Factor Auth + Schema::create('user_two_factor_auth', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->text('secret')->nullable(); + $table->json('recovery_codes')->nullable(); + $table->timestamp('confirmed_at')->nullable(); + $table->timestamp('enabled_at')->nullable(); + $table->timestamps(); + }); + + Schema::enableForeignKeyConstraints(); + } + + public function down(): void + { + Schema::disableForeignKeyConstraints(); + Schema::dropIfExists('user_two_factor_auth'); + Schema::dropIfExists('entitlement_logs'); + Schema::dropIfExists('entitlement_usage_records'); + Schema::dropIfExists('entitlement_boosts'); + Schema::dropIfExists('entitlement_namespace_packages'); + Schema::dropIfExists('entitlement_workspace_packages'); + Schema::dropIfExists('entitlement_package_features'); + Schema::dropIfExists('entitlement_packages'); + Schema::dropIfExists('entitlement_features'); + Schema::dropIfExists('namespaces'); + Schema::dropIfExists('user_workspace'); + Schema::dropIfExists('workspaces'); + Schema::dropIfExists('sessions'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('users'); + Schema::enableForeignKeyConstraints(); + } +}; diff --git a/packages/core-php/src/Mod/Tenant/Migrations/2026_01_25_000001_create_namespaces_table.php b/packages/core-php/src/Mod/Tenant/Migrations/2026_01_25_000001_create_namespaces_table.php new file mode 100644 index 0000000..41a6725 --- /dev/null +++ b/packages/core-php/src/Mod/Tenant/Migrations/2026_01_25_000001_create_namespaces_table.php @@ -0,0 +1,53 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('name', 128); + $table->string('slug', 64); + $table->string('description', 512)->nullable(); + $table->string('icon', 64)->default('folder'); + $table->string('color', 16)->default('zinc'); + + // Polymorphic owner (User::class or Workspace::class) + $table->morphs('owner'); + + // Workspace context for billing aggregation (optional) + // User-owned namespaces can have a workspace for billing + $table->foreignId('workspace_id')->nullable() + ->constrained()->nullOnDelete(); + + $table->json('settings')->nullable(); + $table->boolean('is_default')->default(false); + $table->boolean('is_active')->default(true); + $table->smallInteger('sort_order')->default(0); + + $table->timestamps(); + $table->softDeletes(); + + // Each owner can only have one namespace with a given slug + $table->unique(['owner_type', 'owner_id', 'slug']); + $table->index(['workspace_id', 'is_active']); + $table->index(['owner_type', 'owner_id', 'is_active']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('namespaces'); + } +}; diff --git a/packages/core-php/src/Mod/Tenant/Migrations/2026_01_25_000002_create_entitlement_namespace_packages_table.php b/packages/core-php/src/Mod/Tenant/Migrations/2026_01_25_000002_create_entitlement_namespace_packages_table.php new file mode 100644 index 0000000..e533277 --- /dev/null +++ b/packages/core-php/src/Mod/Tenant/Migrations/2026_01_25_000002_create_entitlement_namespace_packages_table.php @@ -0,0 +1,42 @@ +id(); + $table->foreignId('namespace_id') + ->constrained('namespaces') + ->cascadeOnDelete(); + $table->foreignId('package_id') + ->constrained('entitlement_packages') + ->cascadeOnDelete(); + $table->string('status', 20)->default('active'); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('billing_cycle_anchor')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['namespace_id', 'status']); + $table->index(['package_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('entitlement_namespace_packages'); + } +}; diff --git a/packages/core-php/src/Mod/Tenant/Migrations/2026_01_25_000003_add_namespace_id_to_entitlement_tables.php b/packages/core-php/src/Mod/Tenant/Migrations/2026_01_25_000003_add_namespace_id_to_entitlement_tables.php new file mode 100644 index 0000000..65c77b6 --- /dev/null +++ b/packages/core-php/src/Mod/Tenant/Migrations/2026_01_25_000003_add_namespace_id_to_entitlement_tables.php @@ -0,0 +1,62 @@ +foreignId('namespace_id')->nullable() + ->after('workspace_id') + ->constrained('namespaces') + ->nullOnDelete(); + + $table->index(['namespace_id', 'feature_code', 'status']); + }); + + // Add namespace_id to entitlement_usage_records + Schema::table('entitlement_usage_records', function (Blueprint $table) { + $table->foreignId('namespace_id')->nullable() + ->after('workspace_id') + ->constrained('namespaces') + ->nullOnDelete(); + + $table->index(['namespace_id', 'feature_code', 'recorded_at']); + }); + + // Add namespace_id to entitlement_logs + Schema::table('entitlement_logs', function (Blueprint $table) { + $table->foreignId('namespace_id')->nullable() + ->after('workspace_id') + ->constrained('namespaces') + ->nullOnDelete(); + + $table->index(['namespace_id', 'action', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('entitlement_boosts', function (Blueprint $table) { + $table->dropConstrainedForeignId('namespace_id'); + }); + + Schema::table('entitlement_usage_records', function (Blueprint $table) { + $table->dropConstrainedForeignId('namespace_id'); + }); + + Schema::table('entitlement_logs', function (Blueprint $table) { + $table->dropConstrainedForeignId('namespace_id'); + }); + } +}; diff --git a/packages/core-php/src/Mod/Tenant/Models/NamespacePackage.php b/packages/core-php/src/Mod/Tenant/Models/NamespacePackage.php new file mode 100644 index 0000000..3f94bf7 --- /dev/null +++ b/packages/core-php/src/Mod/Tenant/Models/NamespacePackage.php @@ -0,0 +1,176 @@ + 'datetime', + 'expires_at' => 'datetime', + 'billing_cycle_anchor' => 'datetime', + 'metadata' => 'array', + ]; + + /** + * Status constants. + */ + public const STATUS_ACTIVE = 'active'; + + public const STATUS_SUSPENDED = 'suspended'; + + public const STATUS_CANCELLED = 'cancelled'; + + public const STATUS_EXPIRED = 'expired'; + + /** + * The namespace this package belongs to. + */ + public function namespace(): BelongsTo + { + return $this->belongsTo(Namespace_::class, 'namespace_id'); + } + + /** + * The package definition. + */ + public function package(): BelongsTo + { + return $this->belongsTo(Package::class, 'package_id'); + } + + /** + * Scope to active assignments. + */ + public function scopeActive($query) + { + return $query->where('status', self::STATUS_ACTIVE); + } + + /** + * Scope to non-expired assignments. + */ + public function scopeNotExpired($query) + { + return $query->where(function ($q) { + $q->whereNull('expires_at') + ->orWhere('expires_at', '>', now()); + }); + } + + /** + * Check if this assignment is currently active. + */ + public function isActive(): bool + { + if ($this->status !== self::STATUS_ACTIVE) { + return false; + } + + if ($this->starts_at && $this->starts_at->isFuture()) { + return false; + } + + if ($this->expires_at && $this->expires_at->isPast()) { + return false; + } + + return true; + } + + /** + * Check if this assignment is on grace period. + */ + public function onGracePeriod(): bool + { + return $this->status === self::STATUS_CANCELLED + && $this->expires_at + && $this->expires_at->isFuture(); + } + + /** + * Get the current billing cycle start date. + */ + public function getCurrentCycleStart(): Carbon + { + if (! $this->billing_cycle_anchor) { + return $this->starts_at ?? $this->created_at; + } + + $anchor = $this->billing_cycle_anchor->copy(); + $now = now(); + + // Find the most recent cycle start + while ($anchor->addMonth()->lte($now)) { + // Keep advancing until we pass now + } + + return $anchor->subMonth(); + } + + /** + * Get the current billing cycle end date. + */ + public function getCurrentCycleEnd(): Carbon + { + return $this->getCurrentCycleStart()->copy()->addMonth(); + } + + /** + * Suspend this assignment. + */ + public function suspend(): void + { + $this->update(['status' => self::STATUS_SUSPENDED]); + } + + /** + * Reactivate this assignment. + */ + public function reactivate(): void + { + $this->update(['status' => self::STATUS_ACTIVE]); + } + + /** + * Cancel this assignment. + */ + public function cancel(?Carbon $endsAt = null): void + { + $this->update([ + 'status' => self::STATUS_CANCELLED, + 'expires_at' => $endsAt ?? $this->getCurrentCycleEnd(), + ]); + } +} diff --git a/packages/core-php/src/Mod/Tenant/Models/Namespace_.php b/packages/core-php/src/Mod/Tenant/Models/Namespace_.php new file mode 100644 index 0000000..6b67c09 --- /dev/null +++ b/packages/core-php/src/Mod/Tenant/Models/Namespace_.php @@ -0,0 +1,321 @@ + 'array', + 'is_default' => 'boolean', + 'is_active' => 'boolean', + 'sort_order' => 'integer', + ]; + + /** + * Boot the model. + */ + protected static function booted(): void + { + static::creating(function (self $namespace) { + if (empty($namespace->uuid)) { + $namespace->uuid = (string) Str::uuid(); + } + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Ownership Relationships + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get the owner of the namespace (User or Workspace). + */ + public function owner(): MorphTo + { + return $this->morphTo(); + } + + /** + * Get the workspace for billing aggregation (if set). + * + * This is separate from owner - a user-owned namespace can still + * have a workspace context for billing purposes. + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * Check if this namespace is owned by a user. + */ + public function isOwnedByUser(): bool + { + return $this->owner_type === User::class; + } + + /** + * Check if this namespace is owned by a workspace. + */ + public function isOwnedByWorkspace(): bool + { + return $this->owner_type === Workspace::class; + } + + /** + * Get the owner as User (or null if workspace-owned). + */ + public function getOwnerUser(): ?User + { + if ($this->isOwnedByUser()) { + return $this->owner; + } + + return null; + } + + /** + * Get the owner as Workspace (or null if user-owned). + */ + public function getOwnerWorkspace(): ?Workspace + { + if ($this->isOwnedByWorkspace()) { + return $this->owner; + } + + return null; + } + + // ───────────────────────────────────────────────────────────────────────── + // Entitlement Relationships + // ───────────────────────────────────────────────────────────────────────── + + /** + * Active package assignments for this namespace. + */ + public function namespacePackages(): HasMany + { + return $this->hasMany(NamespacePackage::class); + } + + /** + * Active boosts for this namespace. + */ + public function boosts(): HasMany + { + return $this->hasMany(Boost::class); + } + + /** + * Usage records for this namespace. + */ + public function usageRecords(): HasMany + { + return $this->hasMany(UsageRecord::class); + } + + /** + * Entitlement logs for this namespace. + */ + public function entitlementLogs(): HasMany + { + return $this->hasMany(EntitlementLog::class); + } + + // ───────────────────────────────────────────────────────────────────────── + // Settings & Configuration + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get a setting value from the settings JSON column. + */ + public function getSetting(string $key, mixed $default = null): mixed + { + return data_get($this->settings, $key, $default); + } + + /** + * Set a setting value in the settings JSON column. + */ + public function setSetting(string $key, mixed $value): self + { + $settings = $this->settings ?? []; + data_set($settings, $key, $value); + $this->settings = $settings; + + return $this; + } + + // ───────────────────────────────────────────────────────────────────────── + // Scopes + // ───────────────────────────────────────────────────────────────────────── + + /** + * Scope to only active namespaces. + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope to order by sort order. + */ + public function scopeOrdered($query) + { + return $query->orderBy('sort_order'); + } + + /** + * Scope to namespaces owned by a specific user. + */ + public function scopeOwnedByUser($query, User|int $user) + { + $userId = $user instanceof User ? $user->id : $user; + + return $query->where('owner_type', User::class) + ->where('owner_id', $userId); + } + + /** + * Scope to namespaces owned by a specific workspace. + */ + public function scopeOwnedByWorkspace($query, Workspace|int $workspace) + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + return $query->where('owner_type', Workspace::class) + ->where('owner_id', $workspaceId); + } + + /** + * Scope to namespaces accessible by a user (owned by user OR owned by user's workspaces). + */ + public function scopeAccessibleBy($query, User $user) + { + $workspaceIds = $user->workspaces()->pluck('workspaces.id'); + + return $query->where(function ($q) use ($user, $workspaceIds) { + // User-owned namespaces + $q->where(function ($q2) use ($user) { + $q2->where('owner_type', User::class) + ->where('owner_id', $user->id); + }); + + // Workspace-owned namespaces (where user is a member) + if ($workspaceIds->isNotEmpty()) { + $q->orWhere(function ($q2) use ($workspaceIds) { + $q2->where('owner_type', Workspace::class) + ->whereIn('owner_id', $workspaceIds); + }); + } + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helper Methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Check if a user has access to this namespace. + */ + public function isAccessibleBy(User $user): bool + { + // User owns the namespace directly + if ($this->isOwnedByUser() && $this->owner_id === $user->id) { + return true; + } + + // Workspace owns the namespace and user is a member + if ($this->isOwnedByWorkspace()) { + return $user->workspaces()->where('workspaces.id', $this->owner_id)->exists(); + } + + return false; + } + + /** + * Get the billing context for this namespace. + * + * Returns workspace if set, otherwise falls back to owner's default workspace. + */ + public function getBillingContext(): ?Workspace + { + // Explicit workspace set for billing + if ($this->workspace_id) { + return $this->workspace; + } + + // Workspace-owned: use the owner workspace + if ($this->isOwnedByWorkspace()) { + return $this->owner; + } + + // User-owned: fall back to user's default workspace + if ($this->isOwnedByUser() && $this->owner) { + return $this->owner->defaultHostWorkspace(); + } + + return null; + } + + /** + * Get the route key name for route model binding. + */ + public function getRouteKeyName(): string + { + return 'uuid'; + } +} diff --git a/packages/core-php/src/Mod/Tenant/Services/NamespaceManager.php b/packages/core-php/src/Mod/Tenant/Services/NamespaceManager.php new file mode 100644 index 0000000..531539c --- /dev/null +++ b/packages/core-php/src/Mod/Tenant/Services/NamespaceManager.php @@ -0,0 +1,278 @@ +fill([ + 'name' => $data['name'], + 'slug' => $data['slug'] ?? Str::slug($data['name']), + 'description' => $data['description'] ?? null, + 'icon' => $data['icon'] ?? 'folder', + 'color' => $data['color'] ?? 'zinc', + 'owner_type' => User::class, + 'owner_id' => $user->id, + 'workspace_id' => $data['workspace_id'] ?? null, + 'settings' => $data['settings'] ?? null, + 'is_default' => $data['is_default'] ?? false, + 'is_active' => $data['is_active'] ?? true, + 'sort_order' => $data['sort_order'] ?? 0, + ]); + + // If this is marked as default, unset other defaults + if ($namespace->is_default) { + Namespace_::ownedByUser($user) + ->where('is_default', true) + ->update(['is_default' => false]); + } + + $namespace->save(); + + // Invalidate cache + $this->namespaceService->invalidateUserCache($user); + + return $namespace; + } + + /** + * Create a namespace for a workspace. + */ + public function createForWorkspace(Workspace $workspace, array $data): Namespace_ + { + $namespace = new Namespace_(); + $namespace->fill([ + 'name' => $data['name'], + 'slug' => $data['slug'] ?? Str::slug($data['name']), + 'description' => $data['description'] ?? null, + 'icon' => $data['icon'] ?? 'folder', + 'color' => $data['color'] ?? 'zinc', + 'owner_type' => Workspace::class, + 'owner_id' => $workspace->id, + 'workspace_id' => $workspace->id, // Billing context is the owner workspace + 'settings' => $data['settings'] ?? null, + 'is_default' => $data['is_default'] ?? false, + 'is_active' => $data['is_active'] ?? true, + 'sort_order' => $data['sort_order'] ?? 0, + ]); + + // If this is marked as default, unset other defaults + if ($namespace->is_default) { + Namespace_::ownedByWorkspace($workspace) + ->where('is_default', true) + ->update(['is_default' => false]); + } + + $namespace->save(); + + // Invalidate cache for all workspace members + foreach ($workspace->users as $member) { + $this->namespaceService->invalidateUserCache($member); + } + + return $namespace; + } + + /** + * Create the default namespace for a user. + * + * This is typically called when a user first signs up. + */ + public function createDefaultForUser(User $user): Namespace_ + { + return $this->createForUser($user, [ + 'name' => 'Personal', + 'slug' => 'personal', + 'description' => 'Your personal workspace', + 'icon' => 'user', + 'color' => 'blue', + 'is_default' => true, + ]); + } + + /** + * Create the default namespace for a workspace. + * + * This is typically called when a workspace is created. + */ + public function createDefaultForWorkspace(Workspace $workspace): Namespace_ + { + return $this->createForWorkspace($workspace, [ + 'name' => $workspace->name, + 'slug' => 'default', + 'description' => "Default namespace for {$workspace->name}", + 'icon' => $workspace->icon ?? 'building', + 'color' => $workspace->color ?? 'zinc', + 'is_default' => true, + ]); + } + + /** + * Update a namespace. + */ + public function update(Namespace_ $namespace, array $data): Namespace_ + { + $wasDefault = $namespace->is_default; + + $namespace->fill(array_filter([ + 'name' => $data['name'] ?? null, + 'slug' => $data['slug'] ?? null, + 'description' => $data['description'] ?? null, + 'icon' => $data['icon'] ?? null, + 'color' => $data['color'] ?? null, + 'workspace_id' => array_key_exists('workspace_id', $data) ? $data['workspace_id'] : $namespace->workspace_id, + 'settings' => $data['settings'] ?? null, + 'is_default' => $data['is_default'] ?? null, + 'is_active' => $data['is_active'] ?? null, + 'sort_order' => $data['sort_order'] ?? null, + ], fn ($v) => $v !== null)); + + // If becoming default, unset other defaults for same owner + if (! $wasDefault && $namespace->is_default) { + Namespace_::where('owner_type', $namespace->owner_type) + ->where('owner_id', $namespace->owner_id) + ->where('id', '!=', $namespace->id) + ->where('is_default', true) + ->update(['is_default' => false]); + } + + $namespace->save(); + + // Invalidate cache + $this->namespaceService->invalidateCache($namespace->uuid); + $this->invalidateCacheForOwner($namespace); + + return $namespace; + } + + /** + * Delete (soft delete) a namespace. + */ + public function delete(Namespace_ $namespace): bool + { + // Invalidate cache first + $this->namespaceService->invalidateCache($namespace->uuid); + $this->invalidateCacheForOwner($namespace); + + // If this was the default, make another one default + if ($namespace->is_default) { + $newDefault = Namespace_::where('owner_type', $namespace->owner_type) + ->where('owner_id', $namespace->owner_id) + ->where('id', '!=', $namespace->id) + ->active() + ->ordered() + ->first(); + + if ($newDefault) { + $newDefault->update(['is_default' => true]); + } + } + + return $namespace->delete(); + } + + /** + * Restore a soft-deleted namespace. + */ + public function restore(Namespace_ $namespace): bool + { + $result = $namespace->restore(); + + // Invalidate cache + $this->namespaceService->invalidateCache($namespace->uuid); + $this->invalidateCacheForOwner($namespace); + + return $result; + } + + /** + * Set a namespace as the default for its owner. + */ + public function setAsDefault(Namespace_ $namespace): Namespace_ + { + // Unset other defaults + Namespace_::where('owner_type', $namespace->owner_type) + ->where('owner_id', $namespace->owner_id) + ->where('id', '!=', $namespace->id) + ->where('is_default', true) + ->update(['is_default' => false]); + + // Set this as default + $namespace->update(['is_default' => true]); + + // Invalidate cache + $this->invalidateCacheForOwner($namespace); + + return $namespace; + } + + /** + * Transfer a namespace to a new owner. + */ + public function transfer(Namespace_ $namespace, User|Workspace $newOwner): Namespace_ + { + $oldOwnerType = $namespace->owner_type; + $oldOwnerId = $namespace->owner_id; + + // Update ownership + $namespace->update([ + 'owner_type' => $newOwner::class, + 'owner_id' => $newOwner->id, + 'is_default' => false, // Can't be default in new context automatically + ]); + + // Invalidate cache + $this->namespaceService->invalidateCache($namespace->uuid); + + // Invalidate for old owner + if ($oldOwnerType === User::class) { + $this->namespaceService->invalidateUserCache(User::find($oldOwnerId)); + } else { + $workspace = Workspace::find($oldOwnerId); + foreach ($workspace->users as $member) { + $this->namespaceService->invalidateUserCache($member); + } + } + + // Invalidate for new owner + $this->invalidateCacheForOwner($namespace); + + return $namespace; + } + + /** + * Invalidate cache for the owner of a namespace. + */ + protected function invalidateCacheForOwner(Namespace_ $namespace): void + { + if ($namespace->isOwnedByUser()) { + $this->namespaceService->invalidateUserCache($namespace->owner); + } else { + foreach ($namespace->owner->users as $member) { + $this->namespaceService->invalidateUserCache($member); + } + } + } +} diff --git a/packages/core-php/src/Mod/Tenant/Services/NamespaceService.php b/packages/core-php/src/Mod/Tenant/Services/NamespaceService.php new file mode 100644 index 0000000..91418d2 --- /dev/null +++ b/packages/core-php/src/Mod/Tenant/Services/NamespaceService.php @@ -0,0 +1,288 @@ +attributes->has('current_namespace')) { + return request()->attributes->get('current_namespace'); + } + + // Try from session + $uuid = session('current_namespace_uuid'); + if ($uuid) { + $namespace = $this->findByUuid($uuid); + if ($namespace && $this->canAccess($namespace)) { + return $namespace; + } + } + + // Fall back to user's default + return $this->defaultForCurrentUser(); + } + + /** + * Get the current namespace UUID from session. + */ + public function currentUuid(): ?string + { + return session('current_namespace_uuid'); + } + + /** + * Set the current namespace in session. + */ + public function setCurrent(Namespace_|string $namespace): void + { + $uuid = $namespace instanceof Namespace_ ? $namespace->uuid : $namespace; + + session(['current_namespace_uuid' => $uuid]); + } + + /** + * Clear the current namespace from session. + */ + public function clearCurrent(): void + { + session()->forget('current_namespace_uuid'); + } + + /** + * Find a namespace by UUID. + */ + public function findByUuid(string $uuid): ?Namespace_ + { + return Cache::remember( + "namespace:uuid:{$uuid}", + self::CACHE_TTL, + fn () => Namespace_::where('uuid', $uuid)->first() + ); + } + + /** + * Find a namespace by slug within an owner context. + */ + public function findBySlug(string $slug, User|Workspace $owner): ?Namespace_ + { + return Namespace_::where('owner_type', $owner::class) + ->where('owner_id', $owner->id) + ->where('slug', $slug) + ->first(); + } + + /** + * Get the default namespace for the current authenticated user. + */ + public function defaultForCurrentUser(): ?Namespace_ + { + $user = auth()->user(); + + if (! $user instanceof User) { + return null; + } + + return $this->defaultForUser($user); + } + + /** + * Get the default namespace for a user. + * + * Priority: + * 1. User's default namespace (is_default = true) + * 2. First active user-owned namespace + * 3. First namespace from user's default workspace + */ + public function defaultForUser(User $user): ?Namespace_ + { + // Try user's explicit default + $default = Namespace_::ownedByUser($user) + ->where('is_default', true) + ->active() + ->first(); + + if ($default) { + return $default; + } + + // Try first user-owned namespace + $userOwned = Namespace_::ownedByUser($user) + ->active() + ->ordered() + ->first(); + + if ($userOwned) { + return $userOwned; + } + + // Try namespace from user's default workspace + $workspace = $user->defaultHostWorkspace(); + if ($workspace) { + return Namespace_::ownedByWorkspace($workspace) + ->active() + ->ordered() + ->first(); + } + + return null; + } + + /** + * Get all namespaces accessible by the current user. + */ + public function accessibleByCurrentUser(): Collection + { + $user = auth()->user(); + + if (! $user instanceof User) { + return collect(); + } + + return $this->accessibleByUser($user); + } + + /** + * Get all namespaces accessible by a user. + */ + public function accessibleByUser(User $user): Collection + { + return Cache::remember( + "user:{$user->id}:accessible_namespaces", + self::CACHE_TTL, + fn () => Namespace_::accessibleBy($user) + ->active() + ->ordered() + ->get() + ); + } + + /** + * Get all namespaces owned by a user. + */ + public function ownedByUser(User $user): Collection + { + return Namespace_::ownedByUser($user) + ->active() + ->ordered() + ->get(); + } + + /** + * Get all namespaces owned by a workspace. + */ + public function ownedByWorkspace(Workspace $workspace): Collection + { + return Namespace_::ownedByWorkspace($workspace) + ->active() + ->ordered() + ->get(); + } + + /** + * Check if the current user can access a namespace. + */ + public function canAccess(Namespace_ $namespace): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + return $namespace->isAccessibleBy($user); + } + + /** + * Group namespaces by owner type for UI display. + * + * Returns: + * [ + * 'personal' => Collection of user-owned namespaces, + * 'workspaces' => [ + * ['workspace' => Workspace, 'namespaces' => Collection], + * ... + * ] + * ] + */ + public function groupedForCurrentUser(): array + { + $user = auth()->user(); + + if (! $user instanceof User) { + return ['personal' => collect(), 'workspaces' => []]; + } + + return $this->groupedForUser($user); + } + + /** + * Group namespaces by owner type for a user. + */ + public function groupedForUser(User $user): array + { + $personal = Namespace_::ownedByUser($user) + ->active() + ->ordered() + ->get(); + + $workspaces = []; + foreach ($user->workspaces()->active()->get() as $workspace) { + $namespaces = Namespace_::ownedByWorkspace($workspace) + ->active() + ->ordered() + ->get(); + + if ($namespaces->isNotEmpty()) { + $workspaces[] = [ + 'workspace' => $workspace, + 'namespaces' => $namespaces, + ]; + } + } + + return [ + 'personal' => $personal, + 'workspaces' => $workspaces, + ]; + } + + /** + * Invalidate namespace cache for a user. + */ + public function invalidateUserCache(User $user): void + { + Cache::forget("user:{$user->id}:accessible_namespaces"); + } + + /** + * Invalidate namespace cache by UUID. + */ + public function invalidateCache(string $uuid): void + { + Cache::forget("namespace:uuid:{$uuid}"); + } +} diff --git a/packages/core-php/src/Mod/Web/Migrations/0001_01_01_000001_create_bio_tables.php b/packages/core-php/src/Mod/Web/Migrations/0001_01_01_000001_create_bio_tables.php new file mode 100644 index 0000000..2f45c38 --- /dev/null +++ b/packages/core-php/src/Mod/Web/Migrations/0001_01_01_000001_create_bio_tables.php @@ -0,0 +1,417 @@ +id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('host', 256)->unique(); + $table->string('scheme', 8)->default('https'); + $table->foreignId('biolink_id')->nullable(); + $table->string('custom_index_url', 512)->nullable(); + $table->string('custom_not_found_url', 512)->nullable(); + $table->boolean('is_enabled')->default(false); + $table->enum('verification_status', ['pending', 'verified', 'failed'])->default('pending'); + $table->string('verification_token', 64)->nullable(); + $table->timestamp('verified_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['user_id', 'is_enabled']); + $table->index(['workspace_id', 'is_enabled']); + }); + + // 2. Biolink Projects + Schema::create('biolink_projects', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name', 128); + $table->string('color', 16)->default('#6366f1'); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['user_id', 'created_at']); + $table->index(['workspace_id', 'created_at']); + }); + + // 3. Biolink Themes + Schema::create('biolink_themes', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete(); + $table->string('name', 64); + $table->string('slug', 64)->unique(); + $table->json('settings'); + $table->boolean('is_system')->default(false); + $table->boolean('is_premium')->default(false); + $table->boolean('is_gallery')->default(false); + $table->string('category', 32)->nullable(); + $table->string('preview_image', 255)->nullable(); + $table->text('description')->nullable(); + $table->boolean('is_active')->default(true); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['is_system', 'is_active', 'sort_order']); + $table->index(['user_id', 'is_active']); + $table->index(['workspace_id', 'is_active']); + $table->index(['is_gallery', 'is_active', 'category', 'sort_order'], 'gallery_filter_index'); + }); + + // 4. Biolinks + Schema::create('biolinks', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('project_id')->nullable()->constrained('biolink_projects')->nullOnDelete(); + $table->foreignId('domain_id')->nullable()->constrained('biolink_domains')->nullOnDelete(); + $table->foreignId('theme_id')->nullable()->constrained('biolink_themes')->nullOnDelete(); + $table->foreignId('namespace_id')->nullable()->constrained('namespaces')->nullOnDelete(); + $table->foreignId('parent_id')->nullable()->constrained('biolinks')->nullOnDelete(); + $table->string('type', 32)->default('biolink'); + $table->string('url', 256); + $table->string('location_url', 2048)->nullable(); + $table->json('settings')->nullable(); + $table->json('email_report_settings')->nullable(); + $table->unsignedBigInteger('clicks')->default(0); + $table->unsignedBigInteger('unique_clicks')->default(0); + $table->timestamp('start_date')->nullable(); + $table->timestamp('end_date')->nullable(); + $table->boolean('is_enabled')->default(true); + $table->boolean('is_verified')->default(false); + $table->timestamps(); + $table->softDeletes(); + $table->timestamp('last_click_at')->nullable(); + + $table->unique(['domain_id', 'url']); + $table->index(['user_id', 'type', 'is_enabled']); + $table->index(['user_id', 'project_id']); + $table->index(['workspace_id', 'type']); + $table->index('parent_id'); + }); + + // Add constraint to domains table + Schema::table('biolink_domains', function (Blueprint $table) { + $table->foreign('biolink_id')->references('id')->on('biolinks')->nullOnDelete(); + }); + + // 5. Biolink Blocks + Schema::create('biolink_blocks', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete(); + $table->string('type', 32); + $table->string('location_url', 512)->nullable(); + $table->json('settings')->nullable(); + $table->unsignedSmallInteger('order')->default(0); + $table->unsignedBigInteger('clicks')->default(0); + $table->timestamp('start_date')->nullable(); + $table->timestamp('end_date')->nullable(); + $table->boolean('is_enabled')->default(true); + $table->timestamps(); + + $table->index(['biolink_id', 'is_enabled', 'order']); + }); + + // 6. Biolink Pixels + Schema::create('biolink_pixels', function (Blueprint $table) { + $table->id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('type', 32); + $table->string('name', 64); + $table->string('pixel_id', 128); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['user_id', 'type']); + $table->index(['workspace_id', 'type']); + }); + + // 7. Biolink Pixel Pivot + Schema::create('biolink_pixel', function (Blueprint $table) { + $table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete(); + $table->foreignId('pixel_id')->constrained('biolink_pixels')->cascadeOnDelete(); + $table->primary(['biolink_id', 'pixel_id']); + }); + + // 8. Click Stats (Aggregated) + Schema::create('biolink_click_stats', function (Blueprint $table) { + $table->id(); + $table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete(); + $table->foreignId('block_id')->nullable()->constrained('biolink_blocks')->nullOnDelete(); + $table->date('date'); + $table->unsignedTinyInteger('hour')->nullable(); + $table->unsignedInteger('clicks')->default(0); + $table->unsignedInteger('unique_clicks')->default(0); + $table->char('country_code', 2)->nullable(); + $table->enum('device_type', ['desktop', 'mobile', 'tablet', 'other'])->nullable(); + $table->string('referrer_host', 256)->nullable(); + $table->string('utm_source', 64)->nullable(); + $table->timestamps(); + + $table->unique(['biolink_id', 'block_id', 'date', 'hour', 'country_code', 'device_type', 'referrer_host', 'utm_source'], 'biolink_stats_unique'); + $table->index(['biolink_id', 'date']); + $table->index(['biolink_id', 'date', 'country_code']); + }); + + // 9. Clicks (Raw) + Schema::create('biolink_clicks', function (Blueprint $table) { + $table->id(); + $table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete(); + $table->foreignId('block_id')->nullable()->constrained('biolink_blocks')->nullOnDelete(); + $table->string('visitor_hash', 64)->nullable(); + $table->char('country_code', 2)->nullable(); + $table->string('region', 64)->nullable(); + $table->enum('device_type', ['desktop', 'mobile', 'tablet', 'other'])->default('other'); + $table->string('os_name', 32)->nullable(); + $table->string('browser_name', 32)->nullable(); + $table->string('referrer_host', 256)->nullable(); + $table->string('utm_source', 64)->nullable(); + $table->string('utm_medium', 64)->nullable(); + $table->string('utm_campaign', 64)->nullable(); + $table->boolean('is_unique')->default(false); + $table->timestamp('created_at'); + + $table->index(['biolink_id', 'created_at']); + $table->index(['biolink_id', 'country_code']); + $table->index(['biolink_id', 'device_type']); + $table->index(['biolink_id', 'referrer_host']); + $table->index(['block_id', 'created_at']); + }); + + // 10. Notification Handlers + Schema::create('biolink_notification_handlers', function (Blueprint $table) { + $table->id(); + $table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('name', 128); + $table->enum('type', ['webhook', 'email', 'slack', 'discord', 'telegram']); + $table->json('settings'); + $table->json('events')->default(json_encode(['click'])); + $table->boolean('is_enabled')->default(true); + $table->unsignedInteger('trigger_count')->default(0); + $table->timestamp('last_triggered_at')->nullable(); + $table->timestamp('last_failed_at')->nullable(); + $table->unsignedSmallInteger('consecutive_failures')->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['biolink_id', 'is_enabled']); + $table->index(['workspace_id', 'type']); + }); + + // 11. Push Configs + Schema::create('biolink_push_configs', function (Blueprint $table) { + $table->id(); + $table->foreignId('biolink_id')->unique()->constrained('biolinks')->cascadeOnDelete(); + $table->text('vapid_public_key'); + $table->text('vapid_private_key'); + $table->string('default_icon_url', 512)->nullable(); + $table->boolean('prompt_enabled')->default(true); + $table->unsignedSmallInteger('prompt_delay_seconds')->default(5); + $table->unsignedSmallInteger('prompt_min_pageviews')->default(2); + $table->boolean('is_enabled')->default(true); + $table->timestamps(); + }); + + // 12. Push Subscribers + Schema::create('biolink_push_subscribers', function (Blueprint $table) { + $table->id(); + $table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete(); + $table->string('subscriber_hash', 64)->unique(); + $table->text('endpoint'); + $table->string('key_auth', 128); + $table->string('key_p256dh', 128); + $table->char('country_code', 2)->nullable(); + $table->string('city_name', 64)->nullable(); + $table->string('os_name', 32)->nullable(); + $table->string('browser_name', 32)->nullable(); + $table->string('browser_language', 16)->nullable(); + $table->enum('device_type', ['desktop', 'mobile', 'tablet', 'other'])->default('other'); + $table->boolean('is_active')->default(true); + $table->timestamp('last_notification_at')->nullable(); + $table->unsignedInteger('notifications_received')->default(0); + $table->timestamp('subscribed_at'); + $table->timestamp('unsubscribed_at')->nullable(); + $table->timestamps(); + + $table->index(['biolink_id', 'is_active']); + $table->index(['biolink_id', 'country_code']); + $table->index(['biolink_id', 'device_type']); + }); + + // 13. Push Notifications + Schema::create('biolink_push_notifications', function (Blueprint $table) { + $table->id(); + $table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete(); + $table->string('title', 64); + $table->string('body', 256)->nullable(); + $table->string('url', 512)->nullable(); + $table->string('icon_url', 512)->nullable(); + $table->string('badge_url', 512)->nullable(); + $table->enum('segment', ['all', 'desktop', 'mobile', 'country'])->default('all'); + $table->string('segment_value', 64)->nullable(); + $table->unsignedInteger('total_subscribers')->default(0); + $table->unsignedInteger('sent_count')->default(0); + $table->unsignedInteger('delivered_count')->default(0); + $table->unsignedInteger('clicked_count')->default(0); + $table->unsignedInteger('failed_count')->default(0); + $table->enum('status', ['draft', 'scheduled', 'sending', 'sent', 'failed'])->default('draft'); + $table->timestamp('scheduled_at')->nullable(); + $table->timestamp('sent_at')->nullable(); + $table->timestamps(); + + $table->index(['biolink_id', 'status']); + $table->index(['status', 'scheduled_at']); + }); + + // 14. Push Deliveries + Schema::create('biolink_push_deliveries', function (Blueprint $table) { + $table->id(); + $table->foreignId('notification_id')->constrained('biolink_push_notifications')->cascadeOnDelete(); + $table->foreignId('subscriber_id')->constrained('biolink_push_subscribers')->cascadeOnDelete(); + $table->enum('status', ['pending', 'sent', 'delivered', 'clicked', 'failed'])->default('pending'); + $table->string('error_message', 256)->nullable(); + $table->unsignedTinyInteger('retry_count')->default(0); + $table->timestamp('sent_at')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamp('clicked_at')->nullable(); + $table->timestamps(); + + $table->unique(['notification_id', 'subscriber_id']); + $table->index(['notification_id', 'status']); + }); + + // 15. PWAs + Schema::create('biolink_pwas', function (Blueprint $table) { + $table->id(); + $table->foreignId('biolink_id')->unique()->constrained('biolinks')->cascadeOnDelete(); + $table->string('name', 128); + $table->string('short_name', 32)->nullable(); + $table->string('description', 256)->nullable(); + $table->string('theme_color', 16)->default('#6366f1'); + $table->string('background_color', 16)->default('#ffffff'); + $table->enum('display', ['standalone', 'fullscreen', 'minimal-ui', 'browser'])->default('standalone'); + $table->enum('orientation', ['any', 'natural', 'portrait', 'landscape'])->default('any'); + $table->string('icon_url', 512)->nullable(); + $table->string('icon_maskable_url', 512)->nullable(); + $table->json('screenshots')->nullable(); + $table->json('shortcuts')->nullable(); + $table->string('start_url', 512)->nullable(); + $table->string('scope', 512)->nullable(); + $table->string('lang', 8)->default('en'); + $table->enum('dir', ['ltr', 'rtl', 'auto'])->default('auto'); + $table->unsignedInteger('installs')->default(0); + $table->boolean('is_enabled')->default(true); + $table->timestamps(); + }); + + // 16. Submissions + Schema::create('biolink_submissions', function (Blueprint $table) { + $table->id(); + $table->foreignId('biolink_id')->constrained('biolinks')->cascadeOnDelete(); + $table->foreignId('block_id')->constrained('biolink_blocks')->cascadeOnDelete(); + $table->enum('type', ['email', 'phone', 'contact']); + $table->json('data'); + $table->string('ip_hash', 64)->nullable(); + $table->char('country_code', 2)->nullable(); + $table->boolean('notification_sent')->default(false); + $table->timestamp('notified_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['biolink_id', 'created_at']); + $table->index(['block_id', 'created_at']); + $table->index(['biolink_id', 'type']); + $table->index('type'); + }); + + // 17. Templates + Schema::create('biolink_templates', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('workspace_id')->nullable()->constrained()->nullOnDelete(); + $table->string('name', 128); + $table->string('slug', 128)->unique(); + $table->string('category', 64); + $table->text('description')->nullable(); + $table->json('blocks_json'); + $table->json('settings_json'); + $table->json('placeholders')->nullable(); + $table->string('preview_image', 255)->nullable(); + $table->json('tags')->nullable(); + $table->boolean('is_system')->default(false); + $table->boolean('is_premium')->default(false); + $table->boolean('is_active')->default(true); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->unsignedInteger('usage_count')->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['category', 'is_active', 'sort_order']); + $table->index(['is_system', 'is_active', 'sort_order']); + $table->index(['user_id', 'is_active']); + $table->index(['workspace_id', 'is_active']); + $table->index('category'); + }); + + // 18. Theme Favourites + Schema::create('theme_favourites', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('theme_id')->constrained('biolink_themes')->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['user_id', 'theme_id']); + $table->index(['user_id', 'created_at']); + }); + + Schema::enableForeignKeyConstraints(); + } + + public function down(): void + { + Schema::disableForeignKeyConstraints(); + Schema::dropIfExists('theme_favourites'); + Schema::dropIfExists('biolink_templates'); + Schema::dropIfExists('biolink_submissions'); + Schema::dropIfExists('biolink_pwas'); + Schema::dropIfExists('biolink_push_deliveries'); + Schema::dropIfExists('biolink_push_notifications'); + Schema::dropIfExists('biolink_push_subscribers'); + Schema::dropIfExists('biolink_push_configs'); + Schema::dropIfExists('biolink_notification_handlers'); + Schema::dropIfExists('biolink_clicks'); + Schema::dropIfExists('biolink_click_stats'); + Schema::dropIfExists('biolink_pixel'); + Schema::dropIfExists('biolink_pixels'); + Schema::dropIfExists('biolink_blocks'); + Schema::table('biolink_domains', function (Blueprint $table) { + $table->dropForeign(['biolink_id']); + }); + Schema::dropIfExists('biolinks'); + Schema::dropIfExists('biolink_themes'); + Schema::dropIfExists('biolink_projects'); + Schema::dropIfExists('biolink_domains'); + Schema::enableForeignKeyConstraints(); + } +};