From 65dd9af950365fa5fc9841f936c0a980a833bf00 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 25 Jan 2026 22:28:58 +0000 Subject: [PATCH] refactor: consolidate migrations and clean up core packages - Remove old incremental migrations (now consolidated into create_* files) - Clean up cached view files - Various fixes across core-api, core-mcp, core-php packages Co-Authored-By: Claude Opus 4.5 --- README.md | 2 + packages/core-admin/TODO.md | 145 +++++ packages/core-api/TODO.md | 89 +++ .../src/Mod/Api/Concerns/HasApiResponses.php | 2 +- .../Mod/Api/Concerns/ResolvesWorkspace.php | 2 +- .../Mod/Api/Middleware/AuthenticateApiKey.php | 4 +- .../src/Mod/Api/Middleware/CheckApiScope.php | 4 +- .../src/Mod/Api/Middleware/PublicApiCors.php | 2 +- .../src/Mod/Api/Middleware/RateLimitApi.php | 2 +- .../src/Mod/Api/Middleware/TrackApiUsage.php | 2 +- packages/core-api/src/Website/Api/Boot.php | 5 + .../Api/Controllers/DocsController.php | 2 +- .../core-api/src/Website/Api/Routes/web.php | 8 +- .../Website/Api/Services/OpenApiGenerator.php | 119 +++- packages/core-mcp/TODO.md | 90 ++++ .../Commands/CleanupToolCallLogsCommand.php | 2 +- .../Console/Commands/McpMonitorCommand.php | 2 +- .../src/Mod/Mcp/Services/CircuitBreaker.php | 2 +- .../src/Mod/Mcp/Services/DataRedactor.php | 2 +- .../src/Mod/Mcp/Services/ToolRateLimiter.php | 2 +- .../Mcp/Controllers/McpRegistryController.php | 203 ++++++- .../core-mcp/src/Website/Mcp/Routes/web.php | 7 +- .../Website/Mcp/View/Modal/ApiExplorer.php | 206 +++++-- .../Website/Mcp/View/Modal/ApiKeyManager.php | 37 +- .../src/Website/Mcp/View/Modal/Dashboard.php | 170 +++++- .../src/Website/Mcp/View/Modal/McpMetrics.php | 60 ++- .../Website/Mcp/View/Modal/McpPlayground.php | 158 +++++- .../src/Website/Mcp/View/Modal/Playground.php | 123 ++++- .../src/Website/Mcp/View/Modal/RequestLog.php | 55 +- .../Website/Mcp/View/Modal/UnifiedSearch.php | 30 +- packages/core-php/TODO.md | 257 +++++++++ packages/core-php/composer.json | 2 +- packages/core-php/src/Core/Actions/Action.php | 52 ++ .../core-php/src/Core/Actions/Actionable.php | 19 + ..._01_14_000001_create_blocked_ips_table.php | 34 -- ...1_14_000002_create_seo_redirects_table.php | 36 -- ...4_000003_add_severity_to_honeypot_hits.php | 38 -- ...000001_add_status_to_blocked_ips_table.php | 30 -- ...2026_01_09_100000_create_config_tables.php | 84 --- .../2026_01_09_100001_add_config_channels.php | 89 --- ...26_01_09_100002_create_config_resolved.php | 75 --- packages/core-php/src/Core/Init.php | 30 +- packages/core-php/src/Mod/Tenant/Boot.php | 5 + .../Concerns/TwoFactorAuthenticatable.php | 165 +++++- .../TwoFactorAuthenticationProvider.php | 36 ++ .../Controllers/EntitlementApiController.php | 2 +- .../Controllers/WorkspaceController.php | 2 +- .../Tenant/Jobs/ProcessAccountDeletion.php | 130 +++++ ...6_01_07_000001_create_workspaces_table.php | 67 --- ...0000_create_user_two_factor_auth_table.php | 33 -- ...6_01_25_000001_create_namespaces_table.php | 53 -- ...e_entitlement_namespace_packages_table.php | 42 -- ...add_namespace_id_to_entitlement_tables.php | 62 --- .../core-php/src/Mod/Tenant/Models/Boost.php | 9 + .../src/Mod/Tenant/Models/EntitlementLog.php | 9 + .../src/Mod/Tenant/Models/UsageRecord.php | 9 + .../core-php/src/Mod/Tenant/Models/User.php | 63 +++ .../Mod/Tenant/Models/UserTwoFactorAuth.php | 2 +- .../src/Mod/Tenant/Models/Workspace.php | 25 + .../Tenant/Services/EntitlementService.php | 341 ++++++++++++ .../src/Mod/Tenant/Services/TotpService.php | 194 +++++++ .../Tests/Feature/AccountDeletionTest.php | 334 ++++++++++++ .../Feature/TwoFactorAuthenticatableTest.php | 334 ++++++++++++ .../0001_01_01_000001_create_bio_tables.php | 417 --------------- .../197a81ca7ea9bb86f1b0b4299461a54e.php | 93 ---- .../2cc5de739ce7e5c0ba9026aa4c3defd1.php | 179 ------- .../2e86128b7f533fbd05843fffa6fbb322.php | 506 ------------------ .../3b7d1db1c4733b8ee9d88dea0903c32c.php | 124 ----- .../4ca7befeb99700779f107cb53aa83062.php | 48 -- .../59feaf025ac9e0d20fc7f0358aadbd62.php | 76 --- .../7426fed20565036bdf8583fa143c46d6.php | 42 -- .../7c2eef4dbc7d52e212a190816cc2a217.php | 91 ---- .../8949b3a7ce3ead3cc3bee0674e4e8057.php | 81 --- .../8fa977306093a3fa3bd0e5cc153799b2.php | 82 --- .../adbe419bf3464c90684a916fd827dd64.php | 367 ------------- .../c324df4f93863f809faae41927c1571b.php | 145 ----- .../c6c667e57d02e625aa1153d61d340928.php | 135 ----- .../d31e8a81736ff6e85de996c16b4fb5e7.php | 98 ---- .../e251b27fa9ac66156a5dfe64a95c98b5.php | 11 - .../f43a486913a14930ed1266fa70fdefb3.php | 194 ------- 80 files changed, 3415 insertions(+), 3474 deletions(-) create mode 100644 packages/core-admin/TODO.md create mode 100644 packages/core-api/TODO.md create mode 100644 packages/core-mcp/TODO.md create mode 100644 packages/core-php/src/Core/Actions/Action.php create mode 100644 packages/core-php/src/Core/Actions/Actionable.php delete mode 100644 packages/core-php/src/Core/Bouncer/Migrations/2026_01_14_000001_create_blocked_ips_table.php delete mode 100644 packages/core-php/src/Core/Bouncer/Migrations/2026_01_14_000002_create_seo_redirects_table.php delete mode 100644 packages/core-php/src/Core/Bouncer/Migrations/2026_01_14_000003_add_severity_to_honeypot_hits.php delete mode 100644 packages/core-php/src/Core/Bouncer/Migrations/2026_01_21_000001_add_status_to_blocked_ips_table.php delete mode 100644 packages/core-php/src/Core/Config/Migrations/2026_01_09_100000_create_config_tables.php delete mode 100644 packages/core-php/src/Core/Config/Migrations/2026_01_09_100001_add_config_channels.php delete mode 100644 packages/core-php/src/Core/Config/Migrations/2026_01_09_100002_create_config_resolved.php create mode 100644 packages/core-php/src/Mod/Tenant/Contracts/TwoFactorAuthenticationProvider.php create mode 100644 packages/core-php/src/Mod/Tenant/Jobs/ProcessAccountDeletion.php delete mode 100644 packages/core-php/src/Mod/Tenant/Migrations/2026_01_07_000001_create_workspaces_table.php delete mode 100644 packages/core-php/src/Mod/Tenant/Migrations/2026_01_08_100000_create_user_two_factor_auth_table.php delete mode 100644 packages/core-php/src/Mod/Tenant/Migrations/2026_01_25_000001_create_namespaces_table.php delete mode 100644 packages/core-php/src/Mod/Tenant/Migrations/2026_01_25_000002_create_entitlement_namespace_packages_table.php delete mode 100644 packages/core-php/src/Mod/Tenant/Migrations/2026_01_25_000003_add_namespace_id_to_entitlement_tables.php create mode 100644 packages/core-php/src/Mod/Tenant/Services/TotpService.php create mode 100644 packages/core-php/src/Mod/Tenant/Tests/Feature/AccountDeletionTest.php create mode 100644 packages/core-php/src/Mod/Tenant/Tests/Feature/TwoFactorAuthenticatableTest.php delete mode 100644 packages/core-php/src/Mod/Web/Migrations/0001_01_01_000001_create_bio_tables.php delete mode 100644 storage/framework/views/197a81ca7ea9bb86f1b0b4299461a54e.php delete mode 100644 storage/framework/views/2cc5de739ce7e5c0ba9026aa4c3defd1.php delete mode 100644 storage/framework/views/2e86128b7f533fbd05843fffa6fbb322.php delete mode 100644 storage/framework/views/3b7d1db1c4733b8ee9d88dea0903c32c.php delete mode 100644 storage/framework/views/4ca7befeb99700779f107cb53aa83062.php delete mode 100644 storage/framework/views/59feaf025ac9e0d20fc7f0358aadbd62.php delete mode 100644 storage/framework/views/7426fed20565036bdf8583fa143c46d6.php delete mode 100644 storage/framework/views/7c2eef4dbc7d52e212a190816cc2a217.php delete mode 100644 storage/framework/views/8949b3a7ce3ead3cc3bee0674e4e8057.php delete mode 100644 storage/framework/views/8fa977306093a3fa3bd0e5cc153799b2.php delete mode 100644 storage/framework/views/adbe419bf3464c90684a916fd827dd64.php delete mode 100644 storage/framework/views/c324df4f93863f809faae41927c1571b.php delete mode 100644 storage/framework/views/c6c667e57d02e625aa1153d61d340928.php delete mode 100644 storage/framework/views/d31e8a81736ff6e85de996c16b4fb5e7.php delete mode 100644 storage/framework/views/e251b27fa9ac66156a5dfe64a95c98b5.php delete mode 100644 storage/framework/views/f43a486913a14930ed1266fa70fdefb3.php diff --git a/README.md b/README.md index bdfb3a9..8a4e833 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Core PHP Framework + + A modular monolith framework for Laravel with event-driven architecture and lazy module loading. ## Installation diff --git a/packages/core-admin/TODO.md b/packages/core-admin/TODO.md new file mode 100644 index 0000000..c188784 --- /dev/null +++ b/packages/core-admin/TODO.md @@ -0,0 +1,145 @@ +# Core-Admin TODO + +## Form Authorization Components + +**Priority:** Medium +**Context:** Authorization checks scattered throughout views with `@can` directives. + +### Solution + +Build authorization into form components themselves. + +### Implementation + +```blade +{{-- resources/views/components/forms/input.blade.php --}} +@props([ + 'id', + 'label' => null, + 'canGate' => null, + 'canResource' => null, +]) + +@php + $disabled = $attributes->get('disabled', false); + if ($canGate && $canResource && !$disabled) { + $disabled = !auth()->user()?->can($canGate, $canResource); + } +@endphp + +merge(['disabled' => $disabled]) }} /> +``` + +### Usage + +```blade + +Save +``` + +### Components to Create + +- `input.blade.php` +- `textarea.blade.php` +- `select.blade.php` +- `checkbox.blade.php` +- `button.blade.php` +- `toggle.blade.php` + +--- + +## Global Search (⌘K) + +**Priority:** Medium +**Context:** No unified search across resources. + +### Implementation + +```php +class GlobalSearch extends Component +{ + public bool $open = false; + public string $query = ''; + public array $results = []; + + public function updatedQuery() + { + if (strlen($this->query) < 2) return; + + $this->results = $this->searchProviders(); + } +} +``` + +### Features + +- ⌘K keyboard shortcut +- Arrow key navigation +- Enter to select +- Module-provided search providers +- Recent searches + +### Requirements + +- `SearchProvider` interface for modules to implement +- Auto-discover providers from registered modules +- Fuzzy matching support + +--- + +## Real-time WebSocket (Soketi) + +**Priority:** Low +**Context:** No real-time updates. Users must refresh. + +### Implementation + +Self-hosted Soketi + Laravel Echo. + +```yaml +# docker-compose.yml +soketi: + image: 'quay.io/soketi/soketi:latest' + ports: + - '6001:6001' +``` + +```php +// Broadcasting events +broadcast(new ResourceUpdated($resource)); + +// Livewire listening +protected function getListeners() +{ + return [ + "echo-private:workspace.{$this->workspace->id},resource.updated" => 'refresh', + ]; +} +``` + +### Notes + +- DO NOT route through Bunny CDN (charges per connection) +- Use private channels for workspace-scoped events + +--- + +## Enhanced Form Components + +**Priority:** Medium +**Context:** Extend Flux Pro components with additional features. + +### Features to Add + +- Dark mode consistency +- Automatic error display +- Helper text support +- Disabled states from authorization +- `instantSave` for real-time persistence + +### Components + +```blade + + +``` diff --git a/packages/core-api/TODO.md b/packages/core-api/TODO.md new file mode 100644 index 0000000..5c6e934 --- /dev/null +++ b/packages/core-api/TODO.md @@ -0,0 +1,89 @@ +# Core-API TODO + +## Webhook Signing (Outbound) + +**Priority:** Medium +**Context:** No request signing for outbound webhooks. Recipients cannot verify requests came from our platform. + +### Implementation + +```php +// When sending webhooks +$payload = json_encode($data); +$signature = hash_hmac('sha256', $payload, $webhookSecret); + +$response = Http::withHeaders([ + 'X-Signature' => $signature, + 'X-Timestamp' => now()->timestamp, +])->post($url, $data); +``` + +### Requirements + +- Generate per-endpoint webhook secrets +- Sign all outbound webhook requests +- Include timestamp to prevent replay attacks +- Document verification for recipients + +--- + +## OpenAPI/Swagger Documentation + +**Priority:** Low +**Context:** No auto-generated API documentation. + +### Options + +1. **dedoc/scramble** - Auto-generates from routes/controllers +2. **darkaonline/l5-swagger** - Annotation-based +3. **Custom** - Generate from route definitions + +### Requirements + +- Auto-discover API routes from modules +- Support module-specific doc sections +- Serve at `/api/docs` endpoint +- Include authentication examples + +--- + +## API Key Security + +**Priority:** Medium (Security) +**Context:** API keys use SHA-256 without salt. + +### Current + +```php +$hashedKey = hash('sha256', $rawKey); +``` + +### Recommended + +```php +// Use Argon2 or bcrypt +$hashedKey = Hash::make($rawKey); + +// Verify +Hash::check($providedKey, $storedHash); +``` + +### Notes + +- Migration needed for existing keys +- Consider key rotation mechanism +- Add key scopes/permissions + +--- + +## Rate Limiting Improvements + +**Priority:** Medium +**Context:** Basic rate limiting exists but needs granularity. + +### Requirements + +- Per-endpoint rate limits +- Per-workspace rate limits +- Burst allowance configuration +- Rate limit headers in responses diff --git a/packages/core-api/src/Mod/Api/Concerns/HasApiResponses.php b/packages/core-api/src/Mod/Api/Concerns/HasApiResponses.php index 9c193ac..1db3bf3 100644 --- a/packages/core-api/src/Mod/Api/Concerns/HasApiResponses.php +++ b/packages/core-api/src/Mod/Api/Concerns/HasApiResponses.php @@ -1,6 +1,6 @@ app->runningInConsole()) { + return; + } + Route::middleware('web') ->domain(request()->getHost()) ->group(__DIR__.'/Routes/web.php'); diff --git a/packages/core-api/src/Website/Api/Controllers/DocsController.php b/packages/core-api/src/Website/Api/Controllers/DocsController.php index fb4066a..f12e91a 100644 --- a/packages/core-api/src/Website/Api/Controllers/DocsController.php +++ b/packages/core-api/src/Website/Api/Controllers/DocsController.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace Core\Website\Api\Controllers; -use Core\Website\Api\Services\OpenApiGenerator; use Illuminate\Http\JsonResponse; use Illuminate\View\View; +use Website\Api\Services\OpenApiGenerator; class DocsController { diff --git a/packages/core-api/src/Website/Api/Routes/web.php b/packages/core-api/src/Website/Api/Routes/web.php index 22bb397..5305c5d 100644 --- a/packages/core-api/src/Website/Api/Routes/web.php +++ b/packages/core-api/src/Website/Api/Routes/web.php @@ -2,8 +2,8 @@ declare(strict_types=1); -use Core\Website\Api\Controllers\DocsController; use Illuminate\Support\Facades\Route; +use Website\Api\Controllers\DocsController; // Documentation landing Route::get('/', [DocsController::class, 'index'])->name('api.docs'); @@ -29,5 +29,7 @@ Route::get('/scalar', [DocsController::class, 'scalar'])->name('api.scalar'); // ReDoc (three-panel API reference) Route::get('/redoc', [DocsController::class, 'redoc'])->name('api.redoc'); -// OpenAPI spec -Route::get('/openapi.json', [DocsController::class, 'openapi'])->name('api.openapi.json'); +// OpenAPI spec (rate limited - expensive to generate) +Route::get('/openapi.json', [DocsController::class, 'openapi']) + ->middleware('throttle:60,1') + ->name('api.openapi.json'); diff --git a/packages/core-api/src/Website/Api/Services/OpenApiGenerator.php b/packages/core-api/src/Website/Api/Services/OpenApiGenerator.php index 58b2fa7..93d74ca 100644 --- a/packages/core-api/src/Website/Api/Services/OpenApiGenerator.php +++ b/packages/core-api/src/Website/Api/Services/OpenApiGenerator.php @@ -5,15 +5,46 @@ declare(strict_types=1); namespace Core\Website\Api\Services; use Illuminate\Routing\Route; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Route as RouteFacade; use Illuminate\Support\Str; class OpenApiGenerator { + /** + * Cache duration in seconds (1 hour in production, 0 in local). + */ + protected function getCacheDuration(): int + { + return app()->isProduction() ? 3600 : 0; + } + /** * Generate OpenAPI 3.0 specification from Laravel routes. */ public function generate(): array + { + $duration = $this->getCacheDuration(); + + if ($duration === 0) { + return $this->buildSpec(); + } + + return Cache::remember('openapi:spec', $duration, fn () => $this->buildSpec()); + } + + /** + * Clear the cached OpenAPI spec. + */ + public function clearCache(): void + { + Cache::forget('openapi:spec'); + } + + /** + * Build the full OpenAPI specification. + */ + protected function buildSpec(): array { return [ 'openapi' => '3.0.0', @@ -28,10 +59,14 @@ class OpenApiGenerator protected function buildInfo(): array { return [ - 'title' => config('app.name', 'API'), - 'description' => config('core.api.description', 'API Documentation'), - 'version' => config('core.api.version', '1.0.0'), - 'contact' => config('core.api.contact', []), + 'title' => config('app.name').' API', + 'description' => 'Unified API for Host UK services including commerce, analytics, push notifications, support, and MCP.', + 'version' => config('api.version', '1.0.0'), + 'contact' => [ + 'name' => config('app.name').' Support', + 'url' => config('app.url').'/contact', + 'email' => config('mail.from.address', 'support@host.uk.com'), + ], ]; } @@ -47,7 +82,24 @@ class OpenApiGenerator protected function buildTags(): array { - return config('core.api.tags', []); + return [ + ['name' => 'Analytics', 'description' => 'Website analytics and tracking'], + ['name' => 'Bio', 'description' => 'Bio link pages, blocks, and QR codes'], + ['name' => 'Chat Widget', 'description' => 'Public chat widget API'], + ['name' => 'Commerce', 'description' => 'Billing, orders, invoices, subscriptions, and provisioning'], + ['name' => 'Content', 'description' => 'AI content generation and briefs'], + ['name' => 'Entitlements', 'description' => 'Feature entitlements and usage'], + ['name' => 'MCP', 'description' => 'Model Context Protocol HTTP bridge'], + ['name' => 'Notify', 'description' => 'Push notification management'], + ['name' => 'Pixel', 'description' => 'Unified pixel tracking'], + ['name' => 'SEO', 'description' => 'SEO report and analysis endpoints'], + ['name' => 'Social', 'description' => 'Social media management'], + ['name' => 'Support', 'description' => 'Helpdesk API'], + ['name' => 'Tenant', 'description' => 'Workspaces and multi-tenancy'], + ['name' => 'Trees', 'description' => 'Trees for Agents statistics'], + ['name' => 'Trust', 'description' => 'Social proof widgets'], + ['name' => 'Webhooks', 'description' => 'Incoming webhook endpoints for external services'], + ]; } protected function buildPaths(): array @@ -148,8 +200,33 @@ class OpenApiGenerator $uri = $route->uri(); $name = $route->getName() ?? ''; - // Match by route name prefix - configurable - $tagMap = config('core.api.tag_map.routes', []); + // Match by route name prefix + $tagMap = [ + 'api.webhook' => 'Webhooks', + 'api.trees' => 'Trees', + 'api.seo' => 'SEO', + 'api.pixel' => 'Pixel', + 'api.commerce' => 'Commerce', + 'api.entitlements' => 'Entitlements', + 'api.support.chat' => 'Chat Widget', + 'api.support' => 'Support', + 'api.mcp' => 'MCP', + 'api.social' => 'Social', + 'api.notify' => 'Notify', + 'api.bio' => 'Bio', + 'api.blocks' => 'Bio', + 'api.shortlinks' => 'Bio', + 'api.qr' => 'Bio', + 'api.workspaces' => 'Tenant', + 'api.key.workspaces' => 'Tenant', + 'api.key.bio' => 'Bio', + 'api.key.blocks' => 'Bio', + 'api.key.shortlinks' => 'Bio', + 'api.key.qr' => 'Bio', + 'api.content' => 'Content', + 'api.key.content' => 'Content', + 'api.trust' => 'Trust', + ]; foreach ($tagMap as $prefix => $tag) { if (str_starts_with($name, $prefix)) { @@ -159,7 +236,27 @@ class OpenApiGenerator // Match by URI prefix (check start of path after 'api/') $path = preg_replace('#^api/#', '', $uri); - $uriTagMap = config('core.api.tag_map.uris', []); + $uriTagMap = [ + 'webhooks' => 'Webhooks', + 'trees' => 'Trees', + 'pixel' => 'Pixel', + 'provisioning' => 'Commerce', + 'commerce' => 'Commerce', + 'entitlements' => 'Entitlements', + 'support/chat' => 'Chat Widget', + 'support' => 'Support', + 'mcp' => 'MCP', + 'bio' => 'Bio', + 'shortlinks' => 'Bio', + 'qr' => 'Bio', + 'blocks' => 'Bio', + 'workspaces' => 'Tenant', + 'analytics' => 'Analytics', + 'social' => 'Social', + 'trust' => 'Trust', + 'notify' => 'Notify', + 'content' => 'Content', + ]; foreach ($uriTagMap as $prefix => $tag) { if (str_starts_with($path, $prefix)) { @@ -216,8 +313,12 @@ class OpenApiGenerator return [['bearerAuth' => []]]; } + if (in_array('commerce.api', $middleware)) { + return [['apiKeyAuth' => []]]; + } + foreach ($middleware as $m) { - if (str_contains($m, 'ApiKey')) { + if (str_contains($m, 'McpApiKeyAuth')) { return [['apiKeyAuth' => []]]; } } diff --git a/packages/core-mcp/TODO.md b/packages/core-mcp/TODO.md new file mode 100644 index 0000000..324c0e9 --- /dev/null +++ b/packages/core-mcp/TODO.md @@ -0,0 +1,90 @@ +# Core-MCP TODO + +## MCP Playground UI + +**Priority:** Low +**Context:** Interactive UI for testing MCP tools. + +### Requirements + +- Tool browser with documentation +- Input form builder from tool schemas +- Response viewer with formatting +- Session/conversation persistence +- Example prompts per tool + +--- + +## Workspace Context Security + +**Priority:** High (Security) +**Context:** MCP falls back to `workspace_id = 1` when no context provided. + +### Current Issue + +```php +// Dangerous fallback +$workspaceId = $context->workspaceId ?? 1; +``` + +### Solution + +```php +// Throw instead of fallback +if (!$context->workspaceId) { + throw new MissingWorkspaceContextException( + 'MCP tool requires workspace context' + ); +} +``` + +### Requirements + +- Remove all hardcoded workspace fallbacks +- Require explicit workspace context for all workspace-scoped tools +- Add context validation middleware +- Audit all tools for proper scoping + +--- + +## Tool Usage Analytics + +**Priority:** Low +**Context:** Track tool usage patterns for optimisation. + +### Requirements + +- Per-tool call counts +- Average response times +- Error rates by tool +- Popular tool combinations +- Dashboard in admin + +--- + +## Query Security + +**Priority:** Critical (Security) +**Context:** QueryDatabase tool regex check bypassed by UNION/stacked queries. + +### Current Issue + +Regex-based SQL validation is insufficient. + +### Solution + +1. **Read-only database user** - Primary defence +2. **Query whitelist** - Only allow specific query patterns +3. **Parameterised views** - Expose data through views, not raw queries + +### Implementation + +```php +// Use read-only connection +DB::connection('readonly')->select($query); + +// Or whitelist approach +if (!$this->isWhitelistedQuery($query)) { + throw new ForbiddenQueryException(); +} +``` diff --git a/packages/core-mcp/src/Mod/Mcp/Console/Commands/CleanupToolCallLogsCommand.php b/packages/core-mcp/src/Mod/Mcp/Console/Commands/CleanupToolCallLogsCommand.php index c6d73dc..42923eb 100644 --- a/packages/core-mcp/src/Mod/Mcp/Console/Commands/CleanupToolCallLogsCommand.php +++ b/packages/core-mcp/src/Mod/Mcp/Console/Commands/CleanupToolCallLogsCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Mod\Mcp\Console\Commands; +namespace Core\Mod\Mcp\Console\Commands; use Illuminate\Console\Command; use Mod\Mcp\Models\McpApiRequest; diff --git a/packages/core-mcp/src/Mod/Mcp/Console/Commands/McpMonitorCommand.php b/packages/core-mcp/src/Mod/Mcp/Console/Commands/McpMonitorCommand.php index 1b3f83b..d3869b8 100644 --- a/packages/core-mcp/src/Mod/Mcp/Console/Commands/McpMonitorCommand.php +++ b/packages/core-mcp/src/Mod/Mcp/Console/Commands/McpMonitorCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Mod\Mcp\Console\Commands; +namespace Core\Mod\Mcp\Console\Commands; use Illuminate\Console\Command; use Mod\Mcp\Services\McpMetricsService; diff --git a/packages/core-mcp/src/Mod/Mcp/Services/CircuitBreaker.php b/packages/core-mcp/src/Mod/Mcp/Services/CircuitBreaker.php index 6190e69..4b130df 100644 --- a/packages/core-mcp/src/Mod/Mcp/Services/CircuitBreaker.php +++ b/packages/core-mcp/src/Mod/Mcp/Services/CircuitBreaker.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Mod\Mcp\Services; +namespace Core\Mod\Mcp\Services; use Closure; use Illuminate\Support\Facades\Cache; diff --git a/packages/core-mcp/src/Mod/Mcp/Services/DataRedactor.php b/packages/core-mcp/src/Mod/Mcp/Services/DataRedactor.php index 8910b7e..54c1e71 100644 --- a/packages/core-mcp/src/Mod/Mcp/Services/DataRedactor.php +++ b/packages/core-mcp/src/Mod/Mcp/Services/DataRedactor.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Mod\Mcp\Services; +namespace Core\Mod\Mcp\Services; /** * Data Redactor - redacts sensitive information from tool call logs. diff --git a/packages/core-mcp/src/Mod/Mcp/Services/ToolRateLimiter.php b/packages/core-mcp/src/Mod/Mcp/Services/ToolRateLimiter.php index 909c538..6178983 100644 --- a/packages/core-mcp/src/Mod/Mcp/Services/ToolRateLimiter.php +++ b/packages/core-mcp/src/Mod/Mcp/Services/ToolRateLimiter.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Mod\Mcp\Services; +namespace Core\Mod\Mcp\Services; use Illuminate\Support\Facades\Cache; diff --git a/packages/core-mcp/src/Website/Mcp/Controllers/McpRegistryController.php b/packages/core-mcp/src/Website/Mcp/Controllers/McpRegistryController.php index ec8b37a..f0ad09c 100644 --- a/packages/core-mcp/src/Website/Mcp/Controllers/McpRegistryController.php +++ b/packages/core-mcp/src/Website/Mcp/Controllers/McpRegistryController.php @@ -7,6 +7,8 @@ namespace Core\Website\Mcp\Controllers; use Core\Front\Controller; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; +use Mod\Mcp\Models\McpToolCall; +use Mod\Mcp\Services\OpenApiGenerator; use Symfony\Component\Yaml\Yaml; /** @@ -149,6 +151,119 @@ class McpRegistryController extends Controller return view('mcp::web.connect', [ 'servers' => $servers, 'templates' => $registry['connection_templates'] ?? [], + 'workspace' => $request->attributes->get('mcp_workspace'), + ]); + } + + /** + * Dashboard: /dashboard + * + * Shows MCP usage for the authenticated workspace. + */ + public function dashboard(Request $request) + { + $workspace = $request->attributes->get('mcp_workspace'); + $entitlement = $request->attributes->get('mcp_entitlement'); + + // Get tool call stats for this workspace + $stats = $this->getWorkspaceStats($workspace); + + return view('mcp::web.dashboard', [ + 'workspace' => $workspace, + 'entitlement' => $entitlement, + 'stats' => $stats, + ]); + } + + /** + * API Keys management: /keys + * + * Manage API keys for MCP access. + */ + public function keys(Request $request) + { + $workspace = $request->attributes->get('mcp_workspace'); + + return view('mcp::web.keys', [ + 'workspace' => $workspace, + 'keys' => $workspace->apiKeys ?? collect(), + ]); + } + + /** + * Get MCP usage stats for a workspace. + */ + protected function getWorkspaceStats($workspace): array + { + $since = now()->subDays(30); + + // Use aggregate queries instead of loading all records into memory + $baseQuery = McpToolCall::where('created_at', '>=', $since); + + if ($workspace) { + $baseQuery->where('workspace_id', $workspace->id); + } + + $totalCalls = (clone $baseQuery)->count(); + $successfulCalls = (clone $baseQuery)->where('success', true)->count(); + + $byServer = (clone $baseQuery) + ->selectRaw('server_id, COUNT(*) as count') + ->groupBy('server_id') + ->orderByDesc('count') + ->limit(5) + ->pluck('count', 'server_id') + ->all(); + + $byDay = (clone $baseQuery) + ->selectRaw('DATE(created_at) as date, COUNT(*) as count') + ->groupBy('date') + ->orderBy('date') + ->pluck('count', 'date') + ->all(); + + return [ + 'total_calls' => $totalCalls, + 'successful_calls' => $successfulCalls, + 'by_server' => $byServer, + 'by_day' => $byDay, + ]; + } + + /** + * Usage analytics endpoint: /servers/{id}/analytics + * + * Shows tool usage stats for a specific server. + */ + public function analytics(Request $request, string $id) + { + $server = $this->loadServerFull($id); + + if (! $server) { + if ($this->wantsJson($request)) { + return response()->json(['error' => 'Server not found'], 404); + } + abort(404, 'Server not found'); + } + + // Validate days parameter - bound to reasonable range + $days = min(max($request->integer('days', 7), 1), 90); + + // Get tool call stats for this server + $stats = $this->getServerAnalytics($id, $days); + + if ($this->wantsJson($request)) { + return response()->json([ + 'server_id' => $id, + 'period_days' => $days, + 'stats' => $stats, + ]); + } + + return view('mcp::web.analytics', [ + 'server' => $server, + 'stats' => $stats, + 'days' => $days, ]); } @@ -159,24 +274,80 @@ class McpRegistryController extends Controller */ public function openapi(Request $request) { + $generator = new OpenApiGenerator; $format = $request->query('format', 'json'); - // Return empty spec for now - implement OpenApiGenerator if needed - $spec = [ - 'openapi' => '3.0.0', - 'info' => [ - 'title' => 'MCP API', - 'version' => '1.0.0', - ], - 'paths' => [], - ]; - if ($format === 'yaml' || str_ends_with($request->path(), '.yaml')) { - return response(Yaml::dump($spec, 10)) + return response($generator->toYaml()) ->header('Content-Type', 'application/x-yaml'); } - return response()->json($spec); + return response()->json($generator->generate()); + } + + /** + * Get analytics for a specific server. + */ + protected function getServerAnalytics(string $serverId, int $days = 7): array + { + $since = now()->subDays($days); + + $baseQuery = McpToolCall::forServer($serverId) + ->where('created_at', '>=', $since); + + // Get aggregate stats without loading all records into memory + $totalCalls = (clone $baseQuery)->count(); + $successfulCalls = (clone $baseQuery)->where('success', true)->count(); + $failedCalls = $totalCalls - $successfulCalls; + $avgDuration = (clone $baseQuery)->avg('duration_ms') ?? 0; + + // Tool breakdown with aggregates + $byTool = (clone $baseQuery) + ->selectRaw('tool_name, COUNT(*) as calls, SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as success_count, AVG(duration_ms) as avg_duration') + ->groupBy('tool_name') + ->orderByDesc('calls') + ->limit(10) + ->get() + ->mapWithKeys(fn ($row) => [ + $row->tool_name => [ + 'calls' => (int) $row->calls, + 'success_rate' => $row->calls > 0 + ? round($row->success_count / $row->calls * 100, 1) + : 0, + 'avg_duration_ms' => round($row->avg_duration ?? 0), + ], + ]) + ->all(); + + // Daily breakdown + $byDay = (clone $baseQuery) + ->selectRaw('DATE(created_at) as date, COUNT(*) as count') + ->groupBy('date') + ->orderBy('date') + ->pluck('count', 'date') + ->all(); + + // Error breakdown + $errors = (clone $baseQuery) + ->where('success', false) + ->whereNotNull('error_code') + ->selectRaw('error_code, COUNT(*) as count') + ->groupBy('error_code') + ->orderByDesc('count') + ->limit(5) + ->pluck('count', 'error_code') + ->all(); + + return [ + 'total_calls' => $totalCalls, + 'successful_calls' => $successfulCalls, + 'failed_calls' => $failedCalls, + 'success_rate' => $totalCalls > 0 ? round($successfulCalls / $totalCalls * 100, 1) : 0, + 'avg_duration_ms' => round($avgDuration), + 'by_tool' => $byTool, + 'by_day' => $byDay, + 'errors' => $errors, + ]; } /** @@ -200,6 +371,14 @@ class McpRegistryController extends Controller */ protected function loadServerYaml(string $id): ?array { + // Sanitise server ID to prevent path traversal attacks + $id = basename($id, '.yaml'); + + // Validate ID format (alphanumeric with hyphens only) + if (! preg_match('/^[a-z0-9-]+$/', $id)) { + return null; + } + return Cache::remember("mcp:server:{$id}", $this->getCacheTtl(), function () use ($id) { $path = resource_path("mcp/servers/{$id}.yaml"); diff --git a/packages/core-mcp/src/Website/Mcp/Routes/web.php b/packages/core-mcp/src/Website/Mcp/Routes/web.php index ae85d65..c6c575d 100644 --- a/packages/core-mcp/src/Website/Mcp/Routes/web.php +++ b/packages/core-mcp/src/Website/Mcp/Routes/web.php @@ -1,7 +1,8 @@ name('mcp.')->group(function () { // Landing page Route::get('/', [McpRegistryController::class, 'landing']) + ->middleware(McpAuthenticate::class.':optional') ->name('landing'); // Server list (HTML/JSON based on Accept header) Route::get('servers', [McpRegistryController::class, 'index']) + ->middleware(McpAuthenticate::class.':optional') ->name('servers.index'); // Server detail (supports .json extension) Route::get('servers/{id}', [McpRegistryController::class, 'show']) + ->middleware(McpAuthenticate::class.':optional') ->name('servers.show') ->where('id', '[a-z0-9-]+(?:\.json)?'); // Connection config page Route::get('connect', [McpRegistryController::class, 'connect']) + ->middleware(McpAuthenticate::class.':optional') ->name('connect'); // OpenAPI spec diff --git a/packages/core-mcp/src/Website/Mcp/View/Modal/ApiExplorer.php b/packages/core-mcp/src/Website/Mcp/View/Modal/ApiExplorer.php index 3b1300f..8767d90 100644 --- a/packages/core-mcp/src/Website/Mcp/View/Modal/ApiExplorer.php +++ b/packages/core-mcp/src/Website/Mcp/View/Modal/ApiExplorer.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Core\Website\Mcp\View\Modal; use Livewire\Component; +use Core\Mod\Api\Services\ApiSnippetService; /** * Interactive API Explorer @@ -20,7 +21,7 @@ class ApiExplorer extends Component public string $selectedLanguage = 'curl'; - public string $baseUrl = 'https://api.host.uk.com'; + public string $baseUrl = ''; // Request configuration public string $method = 'GET'; @@ -54,10 +55,63 @@ class ApiExplorer extends Component 'description' => 'Create a new workspace', 'body' => ['name' => 'My Workspace', 'description' => 'A new workspace'], ], + [ + 'name' => 'Get Workspace', + 'method' => 'GET', + 'path' => '/api/v1/workspaces/{id}', + 'description' => 'Get a specific workspace by ID', + 'body' => null, + ], + [ + 'name' => 'List Bio Links', + 'method' => 'GET', + 'path' => '/api/v1/biolinks', + 'description' => 'Get all bio links in the workspace', + 'body' => null, + ], + [ + 'name' => 'Create Bio Link', + 'method' => 'POST', + 'path' => '/api/v1/biolinks', + 'description' => 'Create a new bio link page', + 'body' => ['title' => 'My Links', 'slug' => 'mylinks', 'theme' => 'default'], + ], + [ + 'name' => 'List Short Links', + 'method' => 'GET', + 'path' => '/api/v1/links', + 'description' => 'Get all short links', + 'body' => null, + ], + [ + 'name' => 'Create Short Link', + 'method' => 'POST', + 'path' => '/api/v1/links', + 'description' => 'Create a new short link', + 'body' => ['url' => 'https://example.com', 'slug' => 'example'], + ], + [ + 'name' => 'Get Analytics', + 'method' => 'GET', + 'path' => '/api/v1/analytics/summary', + 'description' => 'Get analytics summary for the workspace', + 'body' => null, + ], ]; + protected ApiSnippetService $snippetService; + + public function boot(ApiSnippetService $snippetService): void + { + $this->snippetService = $snippetService; + } + public function mount(): void { + // Set base URL from config + $this->baseUrl = config('api.base_url', config('app.url')); + + // Pre-select first endpoint if (! empty($this->endpoints)) { $this->selectEndpoint(0); } @@ -82,57 +136,135 @@ class ApiExplorer extends Component public function getCodeSnippet(): string { - $key = $this->apiKey ?: 'YOUR_API_KEY'; - $url = rtrim($this->baseUrl, '/').'/'.ltrim($this->path, '/'); - - return match ($this->selectedLanguage) { - 'curl' => $this->getCurlSnippet($url, $key), - 'php' => $this->getPhpSnippet($url, $key), - 'javascript' => $this->getJsSnippet($url, $key), - default => $this->getCurlSnippet($url, $key), - }; - } - - protected function getCurlSnippet(string $url, string $key): string - { - $cmd = "curl -X {$this->method} \"{$url}\" \\\n"; - $cmd .= " -H \"Authorization: Bearer {$key}\" \\\n"; - $cmd .= " -H \"Content-Type: application/json\" \\\n"; - $cmd .= " -H \"Accept: application/json\""; + $headers = [ + 'Authorization' => 'Bearer '.($this->apiKey ?: 'YOUR_API_KEY'), + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; + $body = null; if (in_array($this->method, ['POST', 'PUT', 'PATCH']) && $this->bodyJson !== '{}') { - $cmd .= " \\\n -d '{$this->bodyJson}'"; + $body = json_decode($this->bodyJson, true); } - return $cmd; + return $this->snippetService->generate( + $this->selectedLanguage, + $this->method, + $this->path, + $headers, + $body, + $this->baseUrl + ); } - protected function getPhpSnippet(string $url, string $key): string + public function getAllSnippets(): array { - return <<acceptJson() - ->{$this->method}('{$url}'); -PHP; + $headers = [ + 'Authorization' => 'Bearer '.($this->apiKey ?: 'YOUR_API_KEY'), + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; + + $body = null; + if (in_array($this->method, ['POST', 'PUT', 'PATCH']) && $this->bodyJson !== '{}') { + $body = json_decode($this->bodyJson, true); + } + + return $this->snippetService->generateAll( + $this->method, + $this->path, + $headers, + $body, + $this->baseUrl + ); } - protected function getJsSnippet(string $url, string $key): string + public function copyToClipboard(): void { - return <<method}', - headers: { - 'Authorization': 'Bearer {$key}', - 'Content-Type': 'application/json', - }, -}); -JS; + $this->dispatch('copy-to-clipboard', code: $this->getCodeSnippet()); + } + + public function sendRequest(): void + { + if (empty($this->apiKey)) { + $this->error = 'Please enter your API key to send requests'; + + return; + } + + $this->isLoading = true; + $this->response = null; + $this->error = null; + + try { + $startTime = microtime(true); + + $url = rtrim($this->baseUrl, '/').'/'.ltrim($this->path, '/'); + + $options = [ + 'http' => [ + 'method' => $this->method, + 'header' => [ + "Authorization: Bearer {$this->apiKey}", + 'Content-Type: application/json', + 'Accept: application/json', + ], + 'timeout' => 30, + 'ignore_errors' => true, + ], + ]; + + if (in_array($this->method, ['POST', 'PUT', 'PATCH']) && $this->bodyJson !== '{}') { + $options['http']['content'] = $this->bodyJson; + } + + $context = stream_context_create($options); + $result = @file_get_contents($url, false, $context); + + $this->responseTime = (int) round((microtime(true) - $startTime) * 1000); + + if ($result === false) { + $this->error = 'Request failed - check your API key and endpoint'; + + return; + } + + // Parse response headers + $statusCode = 200; + if (isset($http_response_header[0])) { + preg_match('/HTTP\/\d+\.?\d* (\d+)/', $http_response_header[0], $matches); + $statusCode = (int) ($matches[1] ?? 200); + } + + $this->response = [ + 'status' => $statusCode, + 'body' => json_decode($result, true) ?? $result, + 'headers' => $http_response_header ?? [], + ]; + + } catch (\Exception $e) { + $this->error = $e->getMessage(); + } finally { + $this->isLoading = false; + } + } + + public function formatBody(): void + { + try { + $decoded = json_decode($this->bodyJson, true); + if (json_last_error() === JSON_ERROR_NONE) { + $this->bodyJson = json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + } catch (\Exception $e) { + // Ignore + } } public function render() { return view('mcp::web.api-explorer', [ - 'languages' => ['curl', 'php', 'javascript'], + 'languages' => ApiSnippetService::getLanguages(), 'snippet' => $this->getCodeSnippet(), ]); } diff --git a/packages/core-mcp/src/Website/Mcp/View/Modal/ApiKeyManager.php b/packages/core-mcp/src/Website/Mcp/View/Modal/ApiKeyManager.php index 534baaa..a41114f 100644 --- a/packages/core-mcp/src/Website/Mcp/View/Modal/ApiKeyManager.php +++ b/packages/core-mcp/src/Website/Mcp/View/Modal/ApiKeyManager.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace Core\Website\Mcp\View\Modal; use Livewire\Component; +use Core\Mod\Api\Models\ApiKey; +use Mod\Tenant\Models\Workspace; /** * MCP API Key Manager. @@ -14,6 +16,8 @@ use Livewire\Component; */ class ApiKeyManager extends Component { + public Workspace $workspace; + // Create form state public bool $showCreateModal = false; @@ -28,6 +32,11 @@ class ApiKeyManager extends Component public bool $showNewKeyModal = false; + public function mount(Workspace $workspace): void + { + $this->workspace = $workspace; + } + public function openCreateModal(): void { $this->showCreateModal = true; @@ -47,10 +56,22 @@ class ApiKeyManager extends Component 'newKeyName' => 'required|string|max:100', ]); - // Implement key creation in your application - // $result = ApiKey::generate(...); - // $this->newPlainKey = $result['plain_key']; + $expiresAt = match ($this->newKeyExpiry) { + '30days' => now()->addDays(30), + '90days' => now()->addDays(90), + '1year' => now()->addYear(), + default => null, + }; + $result = ApiKey::generate( + workspaceId: $this->workspace->id, + userId: auth()->id(), + name: $this->newKeyName, + scopes: $this->newKeyScopes, + expiresAt: $expiresAt, + ); + + $this->newPlainKey = $result['plain_key']; $this->showCreateModal = false; $this->showNewKeyModal = true; @@ -63,6 +84,14 @@ class ApiKeyManager extends Component $this->showNewKeyModal = false; } + public function revokeKey(int $keyId): void + { + $key = $this->workspace->apiKeys()->findOrFail($keyId); + $key->revoke(); + + session()->flash('message', 'API key revoked.'); + } + public function toggleScope(string $scope): void { if (in_array($scope, $this->newKeyScopes)) { @@ -75,7 +104,7 @@ class ApiKeyManager extends Component public function render() { return view('mcp::web.api-key-manager', [ - 'keys' => collect(), // Override to provide real keys + 'keys' => $this->workspace->apiKeys()->orderByDesc('created_at')->get(), ]); } } diff --git a/packages/core-mcp/src/Website/Mcp/View/Modal/Dashboard.php b/packages/core-mcp/src/Website/Mcp/View/Modal/Dashboard.php index 5e04354..1138fda 100644 --- a/packages/core-mcp/src/Website/Mcp/View/Modal/Dashboard.php +++ b/packages/core-mcp/src/Website/Mcp/View/Modal/Dashboard.php @@ -7,30 +7,178 @@ namespace Core\Website\Mcp\View\Modal; use Livewire\Attributes\Layout; use Livewire\Component; use Livewire\WithPagination; +use Mod\Uptelligence\Models\AnalysisLog; +use Mod\Uptelligence\Models\Asset; +use Mod\Uptelligence\Models\Pattern; +use Mod\Uptelligence\Models\UpstreamTodo; +use Mod\Uptelligence\Models\Vendor; /** * MCP Dashboard * - * Overview of MCP servers, tools, and usage stats. + * @todo This file incorrectly references Uptelligence models. Needs to be rewritten + * to show MCP servers, tools, and usage stats instead. + * + * Unified view of vendors, todos, assets, and patterns. */ -#[Layout('mcp::layouts.app')] +#[Layout('hub::admin.layouts.app')] class Dashboard extends Component { use WithPagination; - public string $serverFilter = ''; + public string $vendorFilter = ''; - public string $statusFilter = ''; + public string $typeFilter = ''; + + public string $statusFilter = 'pending'; + + public string $effortFilter = ''; + + public bool $quickWinsOnly = false; + + public function updatingVendorFilter(): void + { + $this->resetPage(); + } + + public function updatingTypeFilter(): void + { + $this->resetPage(); + } + + public function updatingStatusFilter(): void + { + $this->resetPage(); + } + + public function getVendorsProperty() + { + try { + return Vendor::active()->withCount(['todos', 'releases'])->get(); + } catch (\Illuminate\Database\QueryException $e) { + return collect(); + } + } public function getStatsProperty(): array { - // Override in your application to provide real stats - return [ - 'total_servers' => 0, - 'total_tools' => 0, - 'total_calls' => 0, - 'success_rate' => 0, - ]; + try { + return [ + 'total_vendors' => Vendor::active()->count(), + 'pending_todos' => UpstreamTodo::pending()->count(), + 'quick_wins' => UpstreamTodo::quickWins()->count(), + 'security_updates' => UpstreamTodo::pending()->where('type', 'security')->count(), + 'recent_releases' => \Mod\Uptelligence\Models\VersionRelease::recent(7)->count(), + 'in_progress' => UpstreamTodo::inProgress()->count(), + ]; + } catch (\Illuminate\Database\QueryException $e) { + return [ + 'total_vendors' => 0, + 'pending_todos' => 0, + 'quick_wins' => 0, + 'security_updates' => 0, + 'recent_releases' => 0, + 'in_progress' => 0, + ]; + } + } + + public function getTodosProperty() + { + try { + $query = UpstreamTodo::with('vendor') + ->orderByDesc('priority') + ->orderBy('effort'); + + if ($this->vendorFilter) { + $query->where('vendor_id', $this->vendorFilter); + } + + if ($this->typeFilter) { + $query->where('type', $this->typeFilter); + } + + if ($this->statusFilter) { + $query->where('status', $this->statusFilter); + } + + if ($this->effortFilter) { + $query->where('effort', $this->effortFilter); + } + + if ($this->quickWinsOnly) { + $query->where('effort', 'low')->where('priority', '>=', 5); + } + + return $query->paginate(15); + } catch (\Illuminate\Database\QueryException $e) { + return new \Illuminate\Pagination\LengthAwarePaginator([], 0, 15); + } + } + + public function getRecentLogsProperty() + { + try { + return AnalysisLog::with('vendor') + ->latest() + ->limit(10) + ->get(); + } catch (\Illuminate\Database\QueryException $e) { + return collect(); + } + } + + public function getAssetsProperty() + { + try { + return Asset::active()->orderBy('type')->get(); + } catch (\Illuminate\Database\QueryException $e) { + return collect(); + } + } + + public function getPatternsProperty() + { + try { + return Pattern::active()->orderBy('category')->limit(6)->get(); + } catch (\Illuminate\Database\QueryException $e) { + return collect(); + } + } + + public function getAssetStatsProperty(): array + { + try { + return [ + 'total' => Asset::active()->count(), + 'updates_available' => Asset::active()->needsUpdate()->count(), + 'patterns' => Pattern::active()->count(), + ]; + } catch (\Illuminate\Database\QueryException $e) { + return [ + 'total' => 0, + 'updates_available' => 0, + 'patterns' => 0, + ]; + } + } + + public function markInProgress(int $todoId): void + { + $todo = UpstreamTodo::findOrFail($todoId); + $todo->markInProgress(); + } + + public function markPorted(int $todoId): void + { + $todo = UpstreamTodo::findOrFail($todoId); + $todo->markPorted(); + } + + public function markSkipped(int $todoId): void + { + $todo = UpstreamTodo::findOrFail($todoId); + $todo->markSkipped(); } public function render() diff --git a/packages/core-mcp/src/Website/Mcp/View/Modal/McpMetrics.php b/packages/core-mcp/src/Website/Mcp/View/Modal/McpMetrics.php index 6e579bf..00d20d6 100644 --- a/packages/core-mcp/src/Website/Mcp/View/Modal/McpMetrics.php +++ b/packages/core-mcp/src/Website/Mcp/View/Modal/McpMetrics.php @@ -6,22 +6,31 @@ namespace Core\Website\Mcp\View\Modal; use Livewire\Attributes\Layout; use Livewire\Component; +use Core\Mod\Mcp\Services\McpMetricsService; /** * MCP Metrics Dashboard * * Displays analytics and metrics for MCP tool usage. */ -#[Layout('mcp::layouts.app')] +#[Layout('components.layouts.mcp')] class McpMetrics extends Component { public int $days = 7; public string $activeTab = 'overview'; + protected McpMetricsService $metricsService; + + public function boot(McpMetricsService $metricsService): void + { + $this->metricsService = $metricsService; + } + public function setDays(int $days): void { - $this->days = $days; + // Bound days to a reasonable range (1-90) + $this->days = min(max($days, 1), 90); } public function setTab(string $tab): void @@ -31,12 +40,47 @@ class McpMetrics extends Component public function getOverviewProperty(): array { - // Override in your application - return [ - 'total_calls' => 0, - 'success_rate' => 0, - 'avg_duration' => 0, - ]; + return app(McpMetricsService::class)->getOverview($this->days); + } + + public function getDailyTrendProperty(): array + { + return app(McpMetricsService::class)->getDailyTrend($this->days); + } + + public function getTopToolsProperty(): array + { + return app(McpMetricsService::class)->getTopTools($this->days, 10); + } + + public function getServerStatsProperty(): array + { + return app(McpMetricsService::class)->getServerStats($this->days); + } + + public function getRecentCallsProperty(): array + { + return app(McpMetricsService::class)->getRecentCalls(20); + } + + public function getErrorBreakdownProperty(): array + { + return app(McpMetricsService::class)->getErrorBreakdown($this->days); + } + + public function getToolPerformanceProperty(): array + { + return app(McpMetricsService::class)->getToolPerformance($this->days, 10); + } + + public function getHourlyDistributionProperty(): array + { + return app(McpMetricsService::class)->getHourlyDistribution(); + } + + public function getPlanActivityProperty(): array + { + return app(McpMetricsService::class)->getPlanActivity($this->days, 10); } public function render() diff --git a/packages/core-mcp/src/Website/Mcp/View/Modal/McpPlayground.php b/packages/core-mcp/src/Website/Mcp/View/Modal/McpPlayground.php index ed164bd..e879e99 100644 --- a/packages/core-mcp/src/Website/Mcp/View/Modal/McpPlayground.php +++ b/packages/core-mcp/src/Website/Mcp/View/Modal/McpPlayground.php @@ -5,8 +5,11 @@ declare(strict_types=1); namespace Core\Website\Mcp\View\Modal; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Layout; use Livewire\Component; +use Core\Mod\Mcp\Models\McpToolCall; +use Symfony\Component\Process\Process; use Symfony\Component\Yaml\Yaml; /** @@ -15,7 +18,7 @@ use Symfony\Component\Yaml\Yaml; * A browser-based UI for testing MCP tool calls. * Allows users to select a server, pick a tool, and execute it with custom parameters. */ -#[Layout('mcp::layouts.app')] +#[Layout('components.layouts.mcp')] class McpPlayground extends Component { public string $selectedServer = ''; @@ -63,6 +66,7 @@ class McpPlayground extends Component public function updatedSelectedTool(): void { + // Pre-fill example parameters based on tool definition $this->prefillParameters(); $this->lastResult = null; $this->lastError = null; @@ -72,6 +76,15 @@ class McpPlayground extends Component { $this->validate(); + // Rate limit: 10 executions per minute per user/IP + $rateLimitKey = 'mcp-playground:'.$this->getRateLimitKey(); + if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) { + $this->lastError = 'Too many requests. Please wait before trying again.'; + + return; + } + RateLimiter::hit($rateLimitKey, 60); + $this->isExecuting = true; $this->lastResult = null; $this->lastError = null; @@ -84,13 +97,16 @@ class McpPlayground extends Component return; } - // Mock execution for demo - $this->lastResult = [ - 'status' => 'success', - 'message' => 'Tool execution simulated', - 'params' => $params, - ]; - $this->executionTime = rand(50, 200); + $startTime = microtime(true); + $result = $this->callTool($this->selectedServer, $this->selectedTool, $params); + $this->executionTime = (int) round((microtime(true) - $startTime) * 1000); + + if (isset($result['error'])) { + $this->lastError = $result['error']; + $this->lastResult = $result; + } else { + $this->lastResult = $result; + } } catch (\Exception $e) { $this->lastError = $e->getMessage(); @@ -99,6 +115,18 @@ class McpPlayground extends Component } } + /** + * Get rate limit key based on user or IP. + */ + protected function getRateLimitKey(): string + { + if (auth()->check()) { + return 'user:'.auth()->id(); + } + + return 'ip:'.request()->ip(); + } + public function formatJson(): void { try { @@ -164,6 +192,7 @@ class McpPlayground extends Component return; } + // Build example params from parameter definitions $params = []; foreach ($tool['parameters'] as $paramName => $paramDef) { if (is_array($paramDef)) { @@ -174,6 +203,7 @@ class McpPlayground extends Component if ($default !== null) { $params[$paramName] = $default; } elseif ($required) { + // Add placeholder $params[$paramName] = match ($type) { 'boolean' => false, 'integer', 'number' => 0, @@ -187,6 +217,99 @@ class McpPlayground extends Component $this->inputJson = json_encode($params, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); } + protected function callTool(string $serverId, string $toolName, array $params): array + { + $server = $this->loadServerYaml($serverId); + + if (! $server) { + return ['error' => 'Server not found']; + } + + $connection = $server['connection'] ?? []; + $type = $connection['type'] ?? 'stdio'; + + if ($type !== 'stdio') { + return ['error' => "Connection type '{$type}' not supported in playground"]; + } + + $command = $connection['command'] ?? null; + $args = $connection['args'] ?? []; + $cwd = $this->resolveEnvVars($connection['cwd'] ?? getcwd()); + + if (! $command) { + return ['error' => 'No command configured for this server']; + } + + // Build MCP tool call request + $request = json_encode([ + 'jsonrpc' => '2.0', + 'method' => 'tools/call', + 'params' => [ + 'name' => $toolName, + 'arguments' => $params, + ], + 'id' => 1, + ]); + + try { + $startTime = microtime(true); + + $fullCommand = array_merge([$command], $args); + $process = new Process($fullCommand, $cwd); + $process->setInput($request); + $process->setTimeout(30); + + $process->run(); + + $duration = (int) round((microtime(true) - $startTime) * 1000); + $output = $process->getOutput(); + + // Log the tool call + McpToolCall::log( + serverId: $serverId, + toolName: $toolName, + params: $params, + success: $process->isSuccessful(), + durationMs: $duration, + errorMessage: $process->isSuccessful() ? null : $process->getErrorOutput(), + ); + + if (! $process->isSuccessful()) { + return [ + 'error' => 'Process failed', + 'exit_code' => $process->getExitCode(), + 'stderr' => $process->getErrorOutput(), + ]; + } + + // Parse JSON-RPC response + $lines = explode("\n", trim($output)); + foreach ($lines as $line) { + $response = json_decode($line, true); + if ($response) { + if (isset($response['error'])) { + return [ + 'error' => $response['error']['message'] ?? 'Unknown error', + 'code' => $response['error']['code'] ?? null, + 'data' => $response['error']['data'] ?? null, + ]; + } + if (isset($response['result'])) { + return $response['result']; + } + } + } + + return [ + 'error' => 'No valid response received', + 'raw_output' => $output, + ]; + + } catch (\Exception $e) { + return ['error' => $e->getMessage()]; + } + } + protected function loadRegistry(): array { return Cache::remember('mcp:registry', 0, function () { @@ -201,6 +324,14 @@ class McpPlayground extends Component protected function loadServerYaml(string $id): ?array { + // Sanitise server ID to prevent path traversal attacks + $id = basename($id, '.yaml'); + + // Validate ID format (alphanumeric with hyphens only) + if (! preg_match('/^[a-z0-9-]+$/', $id)) { + return null; + } + $path = resource_path("mcp/servers/{$id}.yaml"); if (! file_exists($path)) { return null; @@ -209,6 +340,17 @@ class McpPlayground extends Component return Yaml::parseFile($path); } + protected function resolveEnvVars(string $value): string + { + return preg_replace_callback('/\$\{([^}]+)\}/', function ($matches) { + $parts = explode(':-', $matches[1], 2); + $var = $parts[0]; + $default = $parts[1] ?? ''; + + return env($var, $default); + }, $value); + } + public function render() { return view('mcp::web.mcp-playground'); diff --git a/packages/core-mcp/src/Website/Mcp/View/Modal/Playground.php b/packages/core-mcp/src/Website/Mcp/View/Modal/Playground.php index 6533ab9..cfccdd9 100644 --- a/packages/core-mcp/src/Website/Mcp/View/Modal/Playground.php +++ b/packages/core-mcp/src/Website/Mcp/View/Modal/Playground.php @@ -4,14 +4,17 @@ declare(strict_types=1); namespace Core\Website\Mcp\View\Modal; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Layout; use Livewire\Component; +use Core\Mod\Api\Models\ApiKey; use Symfony\Component\Yaml\Yaml; /** * MCP Playground - interactive tool testing in the browser. */ -#[Layout('mcp::layouts.app')] +#[Layout('components.layouts.mcp')] class Playground extends Component { public string $selectedServer = ''; @@ -28,6 +31,10 @@ class Playground extends Component public ?string $error = null; + public ?string $keyStatus = null; + + public ?array $keyInfo = null; + public array $servers = []; public array $tools = []; @@ -92,6 +99,7 @@ class Playground extends Component try { $this->toolSchema = collect($this->tools)->firstWhere('name', $this->selectedTool); + // Pre-fill arguments with defaults $params = $this->toolSchema['inputSchema']['properties'] ?? []; foreach ($params as $name => $schema) { $this->arguments[$name] = $schema['default'] ?? ''; @@ -102,26 +110,110 @@ class Playground extends Component } } + public function updatedApiKey(): void + { + // Clear key status when key changes + $this->keyStatus = null; + $this->keyInfo = null; + } + + public function validateKey(): void + { + $this->keyStatus = null; + $this->keyInfo = null; + + if (empty($this->apiKey)) { + $this->keyStatus = 'empty'; + + return; + } + + $key = ApiKey::findByPlainKey($this->apiKey); + + if (! $key) { + $this->keyStatus = 'invalid'; + + return; + } + + if ($key->isExpired()) { + $this->keyStatus = 'expired'; + + return; + } + + $this->keyStatus = 'valid'; + $this->keyInfo = [ + 'name' => $key->name, + 'scopes' => $key->scopes, + 'server_scopes' => $key->getAllowedServers(), + 'workspace' => $key->workspace?->name ?? 'Unknown', + 'last_used' => $key->last_used_at?->diffForHumans() ?? 'Never', + ]; + } + + public function isAuthenticated(): bool + { + return auth()->check(); + } + public function execute(): void { if (! $this->selectedServer || ! $this->selectedTool) { return; } + // Rate limit: 10 executions per minute per user/IP + $rateLimitKey = 'mcp-playground-api:'.$this->getRateLimitKey(); + if (RateLimiter::tooManyAttempts($rateLimitKey, 10)) { + $this->error = 'Too many requests. Please wait before trying again.'; + + return; + } + RateLimiter::hit($rateLimitKey, 60); + $this->loading = true; $this->response = ''; $this->error = null; try { + // Filter out empty arguments $args = array_filter($this->arguments, fn ($v) => $v !== '' && $v !== null); + // Convert numeric strings to numbers where appropriate + foreach ($args as $key => $value) { + if (is_numeric($value)) { + $args[$key] = str_contains($value, '.') ? (float) $value : (int) $value; + } + if ($value === 'true') { + $args[$key] = true; + } + if ($value === 'false') { + $args[$key] = false; + } + } + $payload = [ 'server' => $this->selectedServer, 'tool' => $this->selectedTool, 'arguments' => $args, ]; - // Show request format (actual execution requires API key + backend) + // If we have an API key, make a real request + if (! empty($this->apiKey) && $this->keyStatus === 'valid') { + $response = Http::withToken($this->apiKey) + ->timeout(30) + ->post(config('app.url').'/api/v1/mcp/tools/call', $payload); + + $this->response = json_encode([ + 'status' => $response->status(), + 'response' => $response->json(), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + return; + } + + // Otherwise, just show request format $this->response = json_encode([ 'request' => $payload, 'note' => 'Add an API key above to execute this request live.', @@ -142,9 +234,12 @@ class Playground extends Component public function render() { + $isAuthenticated = $this->isAuthenticated(); + $workspace = $isAuthenticated ? auth()->user()?->defaultHostWorkspace() : null; + return view('mcp::web.playground', [ - 'isAuthenticated' => auth()->check(), - 'workspace' => null, + 'isAuthenticated' => $isAuthenticated, + 'workspace' => $workspace, ]); } @@ -157,11 +252,31 @@ class Playground extends Component protected function loadServerFull(string $id): ?array { + // Sanitise server ID to prevent path traversal attacks + $id = basename($id, '.yaml'); + + // Validate ID format (alphanumeric with hyphens only) + if (! preg_match('/^[a-z0-9-]+$/', $id)) { + return null; + } + $path = resource_path("mcp/servers/{$id}.yaml"); return file_exists($path) ? Yaml::parseFile($path) : null; } + /** + * Get rate limit key based on user or IP. + */ + protected function getRateLimitKey(): string + { + if (auth()->check()) { + return 'user:'.auth()->id(); + } + + return 'ip:'.request()->ip(); + } + protected function loadServerSummary(string $id): ?array { $server = $this->loadServerFull($id); diff --git a/packages/core-mcp/src/Website/Mcp/View/Modal/RequestLog.php b/packages/core-mcp/src/Website/Mcp/View/Modal/RequestLog.php index f567572..0e81606 100644 --- a/packages/core-mcp/src/Website/Mcp/View/Modal/RequestLog.php +++ b/packages/core-mcp/src/Website/Mcp/View/Modal/RequestLog.php @@ -7,11 +7,12 @@ namespace Core\Website\Mcp\View\Modal; use Livewire\Attributes\Layout; use Livewire\Component; use Livewire\WithPagination; +use Core\Mod\Mcp\Models\McpApiRequest; /** * MCP Request Log - view and replay API requests. */ -#[Layout('mcp::layouts.app')] +#[Layout('components.layouts.mcp')] class RequestLog extends Component { use WithPagination; @@ -22,6 +23,8 @@ class RequestLog extends Component public ?int $selectedRequestId = null; + public ?McpApiRequest $selectedRequest = null; + public function updatedServerFilter(): void { $this->resetPage(); @@ -34,20 +37,64 @@ class RequestLog extends Component public function selectRequest(int $id): void { + $workspace = auth()->user()?->defaultHostWorkspace(); + + // Only allow selecting requests that belong to the user's workspace + $request = McpApiRequest::query() + ->when($workspace, fn ($q) => $q->forWorkspace($workspace->id)) + ->find($id); + + if (! $request) { + $this->selectedRequestId = null; + $this->selectedRequest = null; + + return; + } + $this->selectedRequestId = $id; + $this->selectedRequest = $request; } public function closeDetail(): void { $this->selectedRequestId = null; + $this->selectedRequest = null; } public function render() { - // Override to provide real request data + $workspace = auth()->user()?->defaultHostWorkspace(); + + $query = McpApiRequest::query() + ->orderByDesc('created_at'); + + if ($workspace) { + $query->forWorkspace($workspace->id); + } + + if ($this->serverFilter) { + $query->forServer($this->serverFilter); + } + + if ($this->statusFilter === 'success') { + $query->successful(); + } elseif ($this->statusFilter === 'failed') { + $query->failed(); + } + + $requests = $query->paginate(20); + + // Get unique servers for filter dropdown + $servers = McpApiRequest::query() + ->when($workspace, fn ($q) => $q->forWorkspace($workspace->id)) + ->distinct() + ->pluck('server_id') + ->filter() + ->values(); + return view('mcp::web.request-log', [ - 'requests' => collect(), - 'servers' => collect(), + 'requests' => $requests, + 'servers' => $servers, ]); } } diff --git a/packages/core-mcp/src/Website/Mcp/View/Modal/UnifiedSearch.php b/packages/core-mcp/src/Website/Mcp/View/Modal/UnifiedSearch.php index 403a87e..03bf000 100644 --- a/packages/core-mcp/src/Website/Mcp/View/Modal/UnifiedSearch.php +++ b/packages/core-mcp/src/Website/Mcp/View/Modal/UnifiedSearch.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Core\Website\Mcp\View\Modal; +use Core\Search\Unified as UnifiedSearchService; use Illuminate\Support\Collection; use Livewire\Attributes\Layout; use Livewire\Component; @@ -14,7 +15,7 @@ use Livewire\Component; * Single search interface across all system components: * MCP tools, API endpoints, patterns, assets, todos, and plans. */ -#[Layout('mcp::layouts.app')] +#[Layout('components.layouts.mcp')] class UnifiedSearch extends Component { public string $query = ''; @@ -23,6 +24,13 @@ class UnifiedSearch extends Component public int $limit = 50; + protected UnifiedSearchService $searchService; + + public function boot(UnifiedSearchService $searchService): void + { + $this->searchService = $searchService; + } + public function updatedQuery(): void { // Debounce handled by wire:model.debounce @@ -48,17 +56,23 @@ class UnifiedSearch extends Component return collect(); } - // Override in your application to provide real search - return collect(); + return $this->searchService->search($this->query, $this->selectedTypes, $this->limit); } public function getTypesProperty(): array { - return [ - 'mcp_tool' => 'MCP Tools', - 'api_endpoint' => 'API Endpoints', - 'pattern' => 'Patterns', - ]; + return UnifiedSearchService::getTypes(); + } + + public function getResultCountsByTypeProperty(): array + { + if (strlen($this->query) < 2) { + return []; + } + + $allResults = $this->searchService->search($this->query, [], 200); + + return $allResults->groupBy('type')->map->count()->toArray(); } public function render() diff --git a/packages/core-php/TODO.md b/packages/core-php/TODO.md index cd2b085..0a6daa9 100644 --- a/packages/core-php/TODO.md +++ b/packages/core-php/TODO.md @@ -1,5 +1,32 @@ # Core-PHP TODO +## Implemented + +### Actions Pattern ✓ + +`Core\Actions\Action` trait for single-purpose business logic classes. + +```php +use Core\Actions\Action; + +class CreateThing +{ + use Action; + + public function handle(User $user, array $data): Thing + { + // Complex business logic here + } +} + +// Usage +$thing = CreateThing::run($user, $data); +``` + +**Location:** `src/Core/Actions/Action.php`, `src/Core/Actions/Actionable.php` + +--- + ## Seeder Auto-Discovery **Priority:** Medium @@ -37,3 +64,233 @@ class PackageSeeder extends Seeder - 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 + +--- + +## Team-Scoped Caching + +**Priority:** Medium +**Context:** Repeated queries for workspace-scoped resources. Cache workspace-scoped queries with auto-invalidation. + +### Implementation + +Extend `BelongsToWorkspace` trait: + +```php +trait BelongsToWorkspace +{ + public static function ownedByCurrentWorkspaceCached(int $ttl = 300) + { + $workspace = currentWorkspace(); + if (!$workspace) return collect(); + + return Cache::remember( + static::workspaceCacheKey($workspace->id), + $ttl, + fn() => static::ownedByCurrentWorkspace()->get() + ); + } + + protected static function bootBelongsToWorkspace(): void + { + static::saved(fn($m) => static::clearWorkspaceCache($m->workspace_id)); + static::deleted(fn($m) => static::clearWorkspaceCache($m->workspace_id)); + } +} +``` + +### Usage + +```php +// Cached for 5 minutes, auto-clears on changes +$biolinks = Biolink::ownedByCurrentWorkspaceCached(); +``` + +--- + +## Activity Logging + +**Priority:** Low +**Context:** No audit trail of user actions across modules. + +### Implementation + +Add `spatie/laravel-activitylog` integration: + +```php +// Core trait for models +trait LogsActivity +{ + use \Spatie\Activitylog\Traits\LogsActivity; + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } +} +``` + +### Requirements + +- Base trait modules can use +- Activity viewer Livewire component for admin +- Workspace-scoped activity queries + +--- + +## Multi-Tenant Data Isolation + +**Priority:** High (Security) +**Context:** Multiple modules have workspace isolation issues. + +### Issues + +1. **Fallback workspace_id** - Some code falls back to `workspace_id = 1` when no context +2. **Global queries** - Some commands query globally without workspace scope +3. **Session trust** - Using `session('workspace_id', 1)` with hardcoded fallback + +### Solution + +- `BelongsToWorkspace` trait should throw exception when workspace context is missing (not fallback) +- Add `WorkspaceScope` global scope that throws on missing context +- Audit all models for proper scoping +- Add middleware that ensures workspace context before any workspace-scoped operation + +--- + +## Bouncer Request Whitelisting + +**Priority:** Medium +**Context:** Every controller action must be explicitly permitted. Unknown actions are blocked (production) or prompt for approval (training mode). + +**Philosophy:** If it wasn't trained, it doesn't exist. + +### Concept + +``` +Training Mode (Development): +1. Developer hits /admin/products +2. Clicks "Create Product" +3. System: "BLOCKED - No permission defined for:" + - Role: admin + - Action: product.create + - Route: POST /admin/products +4. Developer clicks [Allow for admin] +5. Permission recorded +6. Continue working + +Production Mode: +If permission not in whitelist → 403 Forbidden +No exceptions. No fallbacks. No "default allow". +``` + +### Database Schema + +```php +// core_action_permissions +Schema::create('core_action_permissions', function (Blueprint $table) { + $table->id(); + $table->string('action'); // product.create, order.refund + $table->string('scope')->nullable(); // Resource type or specific ID + $table->string('guard')->default('web'); // web, api, admin + $table->string('role')->nullable(); // admin, editor, or null for any auth + $table->boolean('allowed')->default(false); + $table->string('source'); // 'trained', 'seeded', 'manual' + $table->string('trained_route')->nullable(); + $table->foreignId('trained_by')->nullable(); + $table->timestamp('trained_at')->nullable(); + $table->timestamps(); + + $table->unique(['action', 'scope', 'guard', 'role']); +}); + +// core_action_requests (audit log) +Schema::create('core_action_requests', function (Blueprint $table) { + $table->id(); + $table->string('method'); + $table->string('route'); + $table->string('action'); + $table->string('scope')->nullable(); + $table->string('guard'); + $table->string('role')->nullable(); + $table->foreignId('user_id')->nullable(); + $table->string('ip_address')->nullable(); + $table->string('status'); // allowed, denied, pending + $table->boolean('was_trained')->default(false); + $table->timestamps(); + + $table->index(['action', 'status']); +}); +``` + +### Action Resolution + +```php +// Explicit via route attribute +Route::post('/products', [ProductController::class, 'store']) + ->action('product.create'); + +// Or via controller attribute +#[Action('product.create')] +public function store(Request $request) { ... } + +// Or auto-resolved from controller@method +// ProductController@store → product.store +``` + +### Integration with Existing Auth + +``` +Request + │ + ▼ +BouncerGate (action whitelisting) + │ "Is this action permitted at all?" + ▼ +Laravel Gate/Policy (authorisation) + │ "Can THIS USER do this to THIS RESOURCE?" + ▼ +Controller +``` + +### Implementation Phases + +**Phase 1: Core** +- [ ] Database migrations +- [ ] `ActionPermission` model +- [ ] `BouncerService` with `check()` method +- [ ] `BouncerGate` middleware + +**Phase 2: Training Mode** +- [ ] Training UI (modal prompt) +- [ ] Training controller/routes +- [ ] Request logging + +**Phase 3: Tooling** +- [ ] `bouncer:export` command +- [ ] `bouncer:list` command +- [ ] Admin UI for viewing/editing permissions + +**Phase 4: Integration** +- [ ] Apply to admin routes +- [ ] Apply to API routes +- [ ] Documentation + +### Artisan Commands + +```bash +php artisan bouncer:export # Export trained permissions to seeder +php artisan bouncer:seed # Import from seeder +php artisan bouncer:list # List all defined actions +php artisan bouncer:reset # Clear training data +``` + +### Benefits + +1. **Complete audit trail** - Know exactly what actions exist in your app +2. **No forgotten routes** - If it's not trained, it doesn't work +3. **Role-based by default** - Actions scoped to guards and roles +4. **Deployment safety** - Export/import permissions between environments +5. **Discovery tool** - Training mode maps your entire app's surface area diff --git a/packages/core-php/composer.json b/packages/core-php/composer.json index d543218..801c408 100644 --- a/packages/core-php/composer.json +++ b/packages/core-php/composer.json @@ -13,7 +13,7 @@ "php": "^8.2", "laravel/framework": "^11.0|^12.0", "laravel/pennant": "^1.0", - "livewire/livewire": "^3.0" + "livewire/livewire": "^3.0|^4.0" }, "autoload": { "psr-4": { diff --git a/packages/core-php/src/Core/Actions/Action.php b/packages/core-php/src/Core/Actions/Action.php new file mode 100644 index 0000000..cbe7aab --- /dev/null +++ b/packages/core-php/src/Core/Actions/Action.php @@ -0,0 +1,52 @@ +createBiolink->handle($user, $data); + * + * // Via static helper + * $biolink = CreateBiolink::run($user, $data); + * + * // Via app container + * $biolink = app(CreateBiolink::class)->handle($user, $data); + * + * Directory structure: + * app/Mod/{Module}/Actions/ + * ├── CreateThing.php + * ├── UpdateThing.php + * ├── DeleteThing.php + * └── Thing/ + * ├── PublishThing.php + * └── ArchiveThing.php + */ +trait Action +{ + /** + * Run the action via the container. + * + * Resolves the action from the container (with dependencies) + * and calls handle() with the provided arguments. + */ + public static function run(mixed ...$args): mixed + { + return app(static::class)->handle(...$args); + } +} diff --git a/packages/core-php/src/Core/Actions/Actionable.php b/packages/core-php/src/Core/Actions/Actionable.php new file mode 100644 index 0000000..cf09e72 --- /dev/null +++ b/packages/core-php/src/Core/Actions/Actionable.php @@ -0,0 +1,19 @@ +id(); - $table->string('ip_address', 45)->unique(); - $table->string('reason', 50)->default('manual'); - $table->timestamp('blocked_at'); - $table->timestamp('expires_at')->nullable(); - $table->text('notes')->nullable(); - - $table->index('expires_at'); - $table->index('reason'); - }); - } - - public function down(): void - { - Schema::dropIfExists('blocked_ips'); - } -}; diff --git a/packages/core-php/src/Core/Bouncer/Migrations/2026_01_14_000002_create_seo_redirects_table.php b/packages/core-php/src/Core/Bouncer/Migrations/2026_01_14_000002_create_seo_redirects_table.php deleted file mode 100644 index 8213d26..0000000 --- a/packages/core-php/src/Core/Bouncer/Migrations/2026_01_14_000002_create_seo_redirects_table.php +++ /dev/null @@ -1,36 +0,0 @@ -id(); - $table->string('from_path', 500); - $table->string('to_path', 500); - $table->smallInteger('status_code')->default(301); - $table->boolean('active')->default(true); - $table->unsignedInteger('hit_count')->default(0); - $table->timestamp('last_hit_at')->nullable(); - $table->timestamps(); - - $table->unique('from_path'); - $table->index('active'); - }); - } - - public function down(): void - { - Schema::dropIfExists('seo_redirects'); - } -}; diff --git a/packages/core-php/src/Core/Bouncer/Migrations/2026_01_14_000003_add_severity_to_honeypot_hits.php b/packages/core-php/src/Core/Bouncer/Migrations/2026_01_14_000003_add_severity_to_honeypot_hits.php deleted file mode 100644 index e2481ce..0000000 --- a/packages/core-php/src/Core/Bouncer/Migrations/2026_01_14_000003_add_severity_to_honeypot_hits.php +++ /dev/null @@ -1,38 +0,0 @@ -string('severity', 20)->default('warning')->after('bot_name'); - $table->index('severity'); - }); - } - - public function down(): void - { - Schema::table('honeypot_hits', function (Blueprint $table) { - $table->dropIndex(['severity']); - $table->dropColumn('severity'); - }); - } -}; diff --git a/packages/core-php/src/Core/Bouncer/Migrations/2026_01_21_000001_add_status_to_blocked_ips_table.php b/packages/core-php/src/Core/Bouncer/Migrations/2026_01_21_000001_add_status_to_blocked_ips_table.php deleted file mode 100644 index 38e3858..0000000 --- a/packages/core-php/src/Core/Bouncer/Migrations/2026_01_21_000001_add_status_to_blocked_ips_table.php +++ /dev/null @@ -1,30 +0,0 @@ -string('status', 20)->default('approved')->after('reason'); - $table->index('status'); - }); - } - - public function down(): void - { - Schema::table('blocked_ips', function (Blueprint $table) { - $table->dropIndex(['status']); - $table->dropColumn('status'); - }); - } -}; diff --git a/packages/core-php/src/Core/Config/Migrations/2026_01_09_100000_create_config_tables.php b/packages/core-php/src/Core/Config/Migrations/2026_01_09_100000_create_config_tables.php deleted file mode 100644 index e40a2dd..0000000 --- a/packages/core-php/src/Core/Config/Migrations/2026_01_09_100000_create_config_tables.php +++ /dev/null @@ -1,84 +0,0 @@ -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']); - }); - - // M2: 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']); - }); - - // Junction: Config values (profile <> key with value) - 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']); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('config_values'); - Schema::dropIfExists('config_profiles'); - Schema::dropIfExists('config_keys'); - } -}; diff --git a/packages/core-php/src/Core/Config/Migrations/2026_01_09_100001_add_config_channels.php b/packages/core-php/src/Core/Config/Migrations/2026_01_09_100001_add_config_channels.php deleted file mode 100644 index e6b5e88..0000000 --- a/packages/core-php/src/Core/Config/Migrations/2026_01_09_100001_add_config_channels.php +++ /dev/null @@ -1,89 +0,0 @@ -id(); - $table->string('code'); - $table->string('name'); - $table->foreignId('parent_id')->nullable() - ->constrained('config_channels') - ->nullOnDelete(); - $table->foreignId('workspace_id')->nullable() - ->constrained('workspaces') - ->cascadeOnDelete(); - $table->json('metadata')->nullable(); - $table->timestamps(); - - // System channels have unique codes - // Workspace channels can reuse codes (override system) - $table->unique(['code', 'workspace_id']); - $table->index('parent_id'); - }); - } - - // Skip config_values alterations if table doesn't exist - if (! Schema::hasTable('config_values')) { - return; - } - - // Skip if already migrated - if (Schema::hasColumn('config_values', 'channel_id')) { - return; - } - - // Add channel dimension to config values - Schema::table('config_values', function (Blueprint $table) { - // Add a standalone index on profile_id so FK can use it - // (before we drop the unique constraint that FK was using) - $table->index('profile_id', 'config_values_profile_id_index'); - - // Add channel_id column - $table->foreignId('channel_id')->nullable() - ->after('key_id') - ->constrained('config_channels') - ->nullOnDelete(); - }); - - // Update unique constraint in separate statement - // (dropping after index created to avoid FK issues) - Schema::table('config_values', function (Blueprint $table) { - $table->dropUnique(['profile_id', 'key_id']); - $table->unique(['profile_id', 'key_id', 'channel_id'], 'config_values_profile_key_channel_unique'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('config_values', function (Blueprint $table) { - $table->dropUnique('config_values_profile_key_channel_unique'); - $table->dropConstrainedForeignId('channel_id'); - $table->dropIndex('config_values_profile_id_index'); - $table->unique(['profile_id', 'key_id']); - }); - - Schema::dropIfExists('config_channels'); - } -}; diff --git a/packages/core-php/src/Core/Config/Migrations/2026_01_09_100002_create_config_resolved.php b/packages/core-php/src/Core/Config/Migrations/2026_01_09_100002_create_config_resolved.php deleted file mode 100644 index 0d4b8b3..0000000 --- a/packages/core-php/src/Core/Config/Migrations/2026_01_09_100002_create_config_resolved.php +++ /dev/null @@ -1,75 +0,0 @@ -id(); - - // The full address: workspace + channel + key - $table->unsignedBigInteger('workspace_id')->nullable(); - $table->unsignedBigInteger('channel_id')->nullable(); - $table->string('key_code'); - - // Generated scope key for unique lookups (NULL becomes 'N') - // This allows nullable columns while maintaining uniqueness - $table->string('scope_key')->storedAs( - "CONCAT(COALESCE(workspace_id, 'N'), ':', COALESCE(channel_id, 'N'), ':', key_code)" - ); - - // The resolved value - $table->json('value')->nullable(); - $table->string('type')->default('string'); - $table->boolean('locked')->default(false); - - // Audit: where did this value come from? - $table->unsignedBigInteger('source_profile_id')->nullable(); - $table->unsignedBigInteger('source_channel_id')->nullable(); - $table->boolean('virtual')->default(false); - - $table->timestamp('computed_at'); - - // Unique on generated scope_key - O(1) lookup - $table->unique('scope_key', 'config_resolved_lookup'); - - // Index for scope queries - $table->index(['workspace_id', 'channel_id'], 'config_resolved_scope_idx'); - - $table->foreign('source_profile_id') - ->references('id') - ->on('config_profiles') - ->nullOnDelete(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('config_resolved'); - } -}; diff --git a/packages/core-php/src/Core/Init.php b/packages/core-php/src/Core/Init.php index 2287886..c061c46 100644 --- a/packages/core-php/src/Core/Init.php +++ b/packages/core-php/src/Core/Init.php @@ -10,7 +10,7 @@ declare(strict_types=1); namespace Core; -use Illuminate\Http\Request; +use Core\Input\Input; /** * Application initialisation - the true entry point. @@ -22,6 +22,9 @@ use Illuminate\Http\Request; * * This replaces Laravel's bootstrap/app.php pattern with * explicit provider loading via Core\Boot. + * + * The Input::capture() method provides a WAF layer that sanitises + * all input ($_GET, $_POST) before Laravel sees it. */ class Init { @@ -36,9 +39,13 @@ class Init require $maintenance; } - // Capture request and hand to Laravel - $request = Request::capture(); - Boot::app()->handleRequest($request); + // Capture and filter input - WAF layer + // This sanitises $_GET and $_POST before creating the request + $request = Input::capture(); + + // Hand clean request to Laravel + // Use App\Boot if it exists (app customizations), otherwise Core\Boot + self::boot()::app()->handleRequest($request); } /** @@ -46,8 +53,19 @@ class Init */ public static function handleForTesting(): mixed { - $request = Request::capture(); + $request = Input::capture(); - return Boot::app()->handle($request); + return self::boot()::app()->handle($request); + } + + /** + * Get the Boot class to use. + * + * Prefers App\Boot if it exists, allowing apps to customise + * providers, middleware, and exception handling. + */ + protected static function boot(): string + { + return class_exists('App\\Boot') ? 'App\\Boot' : Boot::class; } } diff --git a/packages/core-php/src/Mod/Tenant/Boot.php b/packages/core-php/src/Mod/Tenant/Boot.php index 7820e8f..6824330 100644 --- a/packages/core-php/src/Mod/Tenant/Boot.php +++ b/packages/core-php/src/Mod/Tenant/Boot.php @@ -39,6 +39,11 @@ class Boot extends ServiceProvider public function register(): void { + $this->app->singleton( + \Core\Mod\Tenant\Contracts\TwoFactorAuthenticationProvider::class, + \Core\Mod\Tenant\Services\TotpService::class + ); + $this->app->singleton( \Core\Mod\Tenant\Services\EntitlementService::class, \Core\Mod\Tenant\Services\EntitlementService::class diff --git a/packages/core-php/src/Mod/Tenant/Concerns/TwoFactorAuthenticatable.php b/packages/core-php/src/Mod/Tenant/Concerns/TwoFactorAuthenticatable.php index afa7747..f838870 100644 --- a/packages/core-php/src/Mod/Tenant/Concerns/TwoFactorAuthenticatable.php +++ b/packages/core-php/src/Mod/Tenant/Concerns/TwoFactorAuthenticatable.php @@ -4,14 +4,15 @@ declare(strict_types=1); namespace Core\Mod\Tenant\Concerns; +use Core\Mod\Tenant\Contracts\TwoFactorAuthenticationProvider; use Core\Mod\Tenant\Models\UserTwoFactorAuth; +use Core\Mod\Tenant\Services\TotpService; use Illuminate\Database\Eloquent\Relations\HasOne; /** * Trait for two-factor authentication support. * - * This is a native implementation replacing Mixpost's 2FA. - * Currently stubbed - full implementation to follow. + * Provides TOTP-based 2FA using the TotpService. */ trait TwoFactorAuthenticatable { @@ -75,8 +76,14 @@ trait TwoFactorAuthenticatable */ public function twoFactorQrCodeSvg(): string { - // Stub - will implement with bacon/bacon-qr-code - return ''; + $secret = $this->twoFactorAuthSecretKey(); + if (! $secret) { + return ''; + } + + $url = $this->twoFactorQrCodeUrl(); + + return $this->getTotpService()->qrCodeSvg($url); } /** @@ -84,11 +91,53 @@ trait TwoFactorAuthenticatable */ public function twoFactorQrCodeUrl(): string { - $appName = rawurlencode(config('app.name')); - $email = rawurlencode($this->email); - $secret = $this->twoFactorAuthSecretKey(); + return $this->getTotpService()->qrCodeUrl( + config('app.name'), + $this->email, + $this->twoFactorAuthSecretKey() + ); + } - return "otpauth://totp/{$appName}:{$email}?secret={$secret}&issuer={$appName}"; + /** + * Verify a TOTP code. + */ + public function verifyTwoFactorCode(string $code): bool + { + $secret = $this->twoFactorAuthSecretKey(); + if (! $secret) { + return false; + } + + return $this->getTotpService()->verify($secret, $code); + } + + /** + * Generate a new two-factor secret. + */ + public function generateTwoFactorSecret(): string + { + return $this->getTotpService()->generateSecretKey(); + } + + /** + * Verify a recovery code. + * + * @return bool True if the recovery code was valid and used + */ + public function verifyRecoveryCode(string $code): bool + { + $codes = $this->twoFactorRecoveryCodes(); + $code = strtoupper(trim($code)); + + $index = array_search($code, $codes); + + if ($index !== false) { + $this->twoFactorReplaceRecoveryCode($code); + + return true; + } + + return false; } /** @@ -98,4 +147,104 @@ trait TwoFactorAuthenticatable { return strtoupper(bin2hex(random_bytes(5))).'-'.strtoupper(bin2hex(random_bytes(5))); } + + /** + * Generate a set of recovery codes. + * + * @param int $count Number of codes to generate + */ + public function generateRecoveryCodes(int $count = 8): array + { + $codes = []; + + for ($i = 0; $i < $count; $i++) { + $codes[] = $this->generateRecoveryCode(); + } + + return $codes; + } + + /** + * Enable two-factor authentication for this user. + * + * Creates the 2FA record with a new secret but does not confirm it yet. + * The user must verify a code before 2FA is fully enabled. + * + * @return string The secret key for QR code generation + */ + public function enableTwoFactorAuth(): string + { + $secret = $this->generateTwoFactorSecret(); + + $this->twoFactorAuth()->updateOrCreate( + ['user_id' => $this->id], + [ + 'secret_key' => $secret, + 'recovery_codes' => null, + 'confirmed_at' => null, + ] + ); + + $this->load('twoFactorAuth'); + + return $secret; + } + + /** + * Confirm two-factor authentication after verifying a code. + * + * @return array The recovery codes + */ + public function confirmTwoFactorAuth(): array + { + if (! $this->twoFactorAuth || ! $this->twoFactorAuth->secret_key) { + throw new \RuntimeException('Two-factor authentication has not been initialised.'); + } + + $recoveryCodes = $this->generateRecoveryCodes(); + + $this->twoFactorAuth->update([ + 'recovery_codes' => $recoveryCodes, + 'confirmed_at' => now(), + ]); + + return $recoveryCodes; + } + + /** + * Disable two-factor authentication for this user. + */ + public function disableTwoFactorAuth(): void + { + $this->twoFactorAuth?->delete(); + $this->unsetRelation('twoFactorAuth'); + } + + /** + * Regenerate recovery codes. + * + * @return array The new recovery codes + */ + public function regenerateTwoFactorRecoveryCodes(): array + { + if (! $this->hasTwoFactorAuthEnabled()) { + throw new \RuntimeException('Two-factor authentication is not enabled.'); + } + + $recoveryCodes = $this->generateRecoveryCodes(); + + $this->twoFactorAuth->update([ + 'recovery_codes' => $recoveryCodes, + ]); + + return $recoveryCodes; + } + + /** + * Get the TOTP service instance. + */ + protected function getTotpService(): TwoFactorAuthenticationProvider + { + return app(TwoFactorAuthenticationProvider::class); + } } diff --git a/packages/core-php/src/Mod/Tenant/Contracts/TwoFactorAuthenticationProvider.php b/packages/core-php/src/Mod/Tenant/Contracts/TwoFactorAuthenticationProvider.php new file mode 100644 index 0000000..eb5230b --- /dev/null +++ b/packages/core-php/src/Mod/Tenant/Contracts/TwoFactorAuthenticationProvider.php @@ -0,0 +1,36 @@ +deletionRequest->id); + + if (! $request) { + Log::info('Skipping account deletion - request no longer exists', [ + 'deletion_request_id' => $this->deletionRequest->id, + ]); + + return; + } + + // Verify the request is still valid for deletion + if (! $request->isActive()) { + Log::info('Skipping account deletion - request no longer active', [ + 'deletion_request_id' => $request->id, + ]); + + return; + } + + $user = $request->user; + + if (! $user) { + Log::warning('User not found for deletion request', [ + 'deletion_request_id' => $request->id, + ]); + $request->complete(); + + return; + } + + // Update local reference + $this->deletionRequest = $request; + + $userId = $user->id; + + DB::transaction(function () use ($user) { + // Mark request as completed + $this->deletionRequest->complete(); + + // Delete all workspaces owned by the user + if (method_exists($user, 'ownedWorkspaces')) { + $user->ownedWorkspaces()->each(function ($workspace) { + $workspace->delete(); + }); + } + + // Hard delete user account + $user->forceDelete(); + }); + + Log::info('Account deleted successfully', [ + 'user_id' => $userId, + 'deletion_request_id' => $this->deletionRequest->id, + 'via' => 'job', + ]); + } + + /** + * Handle a job failure. + */ + public function failed(\Throwable $exception): void + { + Log::error('Failed to process account deletion', [ + 'deletion_request_id' => $this->deletionRequest->id, + 'error' => $exception->getMessage(), + ]); + } + + /** + * Get the tags that should be assigned to the job. + * + * @return array + */ + public function tags(): array + { + return [ + 'account-deletion', + 'user:'.$this->deletionRequest->user_id, + ]; + } +} diff --git a/packages/core-php/src/Mod/Tenant/Migrations/2026_01_07_000001_create_workspaces_table.php b/packages/core-php/src/Mod/Tenant/Migrations/2026_01_07_000001_create_workspaces_table.php deleted file mode 100644 index 72dcaf5..0000000 --- a/packages/core-php/src/Mod/Tenant/Migrations/2026_01_07_000001_create_workspaces_table.php +++ /dev/null @@ -1,67 +0,0 @@ -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(); - }); - - 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']); - }); - } - - public function down(): void - { - Schema::dropIfExists('user_workspace'); - Schema::dropIfExists('workspaces'); - } -}; diff --git a/packages/core-php/src/Mod/Tenant/Migrations/2026_01_08_100000_create_user_two_factor_auth_table.php b/packages/core-php/src/Mod/Tenant/Migrations/2026_01_08_100000_create_user_two_factor_auth_table.php deleted file mode 100644 index 282b090..0000000 --- a/packages/core-php/src/Mod/Tenant/Migrations/2026_01_08_100000_create_user_two_factor_auth_table.php +++ /dev/null @@ -1,33 +0,0 @@ -id(); - $table->foreignId('user_id')->constrained()->cascadeOnDelete(); - $table->string('secret_key')->nullable(); - $table->json('recovery_codes')->nullable(); - $table->timestamp('confirmed_at')->nullable(); - $table->timestamps(); - - $table->unique('user_id'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::dropIfExists('user_two_factor_auth'); - } -}; 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 deleted file mode 100644 index 41a6725..0000000 --- a/packages/core-php/src/Mod/Tenant/Migrations/2026_01_25_000001_create_namespaces_table.php +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index e533277..0000000 --- a/packages/core-php/src/Mod/Tenant/Migrations/2026_01_25_000002_create_entitlement_namespace_packages_table.php +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 65c77b6..0000000 --- a/packages/core-php/src/Mod/Tenant/Migrations/2026_01_25_000003_add_namespace_id_to_entitlement_tables.php +++ /dev/null @@ -1,62 +0,0 @@ -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/Boost.php b/packages/core-php/src/Mod/Tenant/Models/Boost.php index 64d955e..9c43e19 100644 --- a/packages/core-php/src/Mod/Tenant/Models/Boost.php +++ b/packages/core-php/src/Mod/Tenant/Models/Boost.php @@ -14,6 +14,7 @@ class Boost extends Model protected $fillable = [ 'workspace_id', + 'namespace_id', 'user_id', 'feature_code', 'boost_type', @@ -72,6 +73,14 @@ class Boost extends Model return $this->belongsTo(Workspace::class); } + /** + * The namespace this boost belongs to. + */ + public function namespace(): BelongsTo + { + return $this->belongsTo(Namespace_::class, 'namespace_id'); + } + /** * The user this boost belongs to (for user-level boosts like vanity URLs). */ diff --git a/packages/core-php/src/Mod/Tenant/Models/EntitlementLog.php b/packages/core-php/src/Mod/Tenant/Models/EntitlementLog.php index 01b005e..8146546 100644 --- a/packages/core-php/src/Mod/Tenant/Models/EntitlementLog.php +++ b/packages/core-php/src/Mod/Tenant/Models/EntitlementLog.php @@ -14,6 +14,7 @@ class EntitlementLog extends Model protected $fillable = [ 'workspace_id', + 'namespace_id', 'action', 'entity_type', 'entity_id', @@ -80,6 +81,14 @@ class EntitlementLog extends Model return $this->belongsTo(Workspace::class); } + /** + * The namespace this log belongs to. + */ + public function namespace(): BelongsTo + { + return $this->belongsTo(Namespace_::class, 'namespace_id'); + } + /** * The user who triggered this action. */ diff --git a/packages/core-php/src/Mod/Tenant/Models/UsageRecord.php b/packages/core-php/src/Mod/Tenant/Models/UsageRecord.php index 680d237..ec44d7f 100644 --- a/packages/core-php/src/Mod/Tenant/Models/UsageRecord.php +++ b/packages/core-php/src/Mod/Tenant/Models/UsageRecord.php @@ -15,6 +15,7 @@ class UsageRecord extends Model protected $fillable = [ 'workspace_id', + 'namespace_id', 'feature_code', 'quantity', 'user_id', @@ -36,6 +37,14 @@ class UsageRecord extends Model return $this->belongsTo(Workspace::class); } + /** + * The namespace this usage belongs to. + */ + public function namespace(): BelongsTo + { + return $this->belongsTo(Namespace_::class, 'namespace_id'); + } + /** * The user who incurred this usage. */ diff --git a/packages/core-php/src/Mod/Tenant/Models/User.php b/packages/core-php/src/Mod/Tenant/Models/User.php index 5bf85da..287153e 100644 --- a/packages/core-php/src/Mod/Tenant/Models/User.php +++ b/packages/core-php/src/Mod/Tenant/Models/User.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Pennant\Concerns\HasFeatures; @@ -175,6 +176,68 @@ class User extends Authenticatable implements MustVerifyEmail ->first() ?? $this->hostWorkspaces()->first(); } + // ───────────────────────────────────────────────────────────────────────── + // Namespace Relationships + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get all namespaces owned directly by this user. + */ + public function namespaces(): MorphMany + { + return $this->morphMany(Namespace_::class, 'owner'); + } + + /** + * Get the user's default namespace. + * + * 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 defaultNamespace(): ?Namespace_ + { + // Try user's explicit default + $default = $this->namespaces() + ->where('is_default', true) + ->active() + ->first(); + + if ($default) { + return $default; + } + + // Try first user-owned namespace + $userOwned = $this->namespaces() + ->active() + ->ordered() + ->first(); + + if ($userOwned) { + return $userOwned; + } + + // Try namespace from user's default workspace + $workspace = $this->defaultHostWorkspace(); + if ($workspace) { + return $workspace->namespaces() + ->active() + ->ordered() + ->first(); + } + + return null; + } + + /** + * Get all namespaces accessible by this user (owned + via workspaces). + */ + public function accessibleNamespaces(): \Illuminate\Database\Eloquent\Builder + { + return Namespace_::accessibleBy($this); + } + /** * Check if user's email has been verified. * Hades accounts are always considered verified. diff --git a/packages/core-php/src/Mod/Tenant/Models/UserTwoFactorAuth.php b/packages/core-php/src/Mod/Tenant/Models/UserTwoFactorAuth.php index 6207b7c..f969303 100644 --- a/packages/core-php/src/Mod/Tenant/Models/UserTwoFactorAuth.php +++ b/packages/core-php/src/Mod/Tenant/Models/UserTwoFactorAuth.php @@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * User two-factor authentication record. * - * Native implementation replacing Mixpost's 2FA model. + * Stores TOTP secrets and recovery codes for 2FA. */ class UserTwoFactorAuth extends Model { diff --git a/packages/core-php/src/Mod/Tenant/Models/Workspace.php b/packages/core-php/src/Mod/Tenant/Models/Workspace.php index 78a6602..f0c8b35 100644 --- a/packages/core-php/src/Mod/Tenant/Models/Workspace.php +++ b/packages/core-php/src/Mod/Tenant/Models/Workspace.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Core\Mod\Tenant\Services\EntitlementResult; use Core\Mod\Tenant\Services\EntitlementService; @@ -103,6 +104,30 @@ class Workspace extends Model return $this->hasMany(WorkspacePackage::class); } + // ───────────────────────────────────────────────────────────────────────── + // Namespace Relationships + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get all namespaces owned by this workspace. + */ + public function namespaces(): MorphMany + { + return $this->morphMany(Namespace_::class, 'owner'); + } + + /** + * Get the workspace's default namespace. + */ + public function defaultNamespace(): ?Namespace_ + { + return $this->namespaces() + ->where('is_default', true) + ->active() + ->first() + ?? $this->namespaces()->active()->ordered()->first(); + } + /** * The package definitions assigned to this workspace. */ diff --git a/packages/core-php/src/Mod/Tenant/Services/EntitlementService.php b/packages/core-php/src/Mod/Tenant/Services/EntitlementService.php index 5dfdce1..807ed68 100644 --- a/packages/core-php/src/Mod/Tenant/Services/EntitlementService.php +++ b/packages/core-php/src/Mod/Tenant/Services/EntitlementService.php @@ -5,6 +5,8 @@ namespace Core\Mod\Tenant\Services; use Core\Mod\Tenant\Models\Boost; use Core\Mod\Tenant\Models\EntitlementLog; use Core\Mod\Tenant\Models\Feature; +use Core\Mod\Tenant\Models\Namespace_; +use Core\Mod\Tenant\Models\NamespacePackage; use Core\Mod\Tenant\Models\Package; use Core\Mod\Tenant\Models\UsageRecord; use Core\Mod\Tenant\Models\Workspace; @@ -78,6 +80,119 @@ class EntitlementService ); } + /** + * Check if a namespace can use a feature. + * + * Entitlement cascade: + * 1. Check namespace-level packages first + * 2. Fall back to workspace pool (if namespace has workspace context) + * 3. Fall back to user tier (for user-owned namespaces without workspace) + */ + public function canForNamespace(Namespace_ $namespace, string $featureCode, int $quantity = 1): EntitlementResult + { + $feature = $this->getFeature($featureCode); + + if (! $feature) { + return EntitlementResult::denied( + reason: "Feature '{$featureCode}' does not exist.", + featureCode: $featureCode + ); + } + + // Get the pool feature code (parent if hierarchical) + $poolFeatureCode = $feature->getPoolFeatureCode(); + + // Try namespace-level limit first + $totalLimit = $this->getNamespaceTotalLimit($namespace, $poolFeatureCode); + + // If not found at namespace level, try workspace fallback + if ($totalLimit === null && $namespace->workspace_id) { + $workspace = $namespace->workspace; + if ($workspace) { + $totalLimit = $this->getTotalLimit($workspace, $poolFeatureCode); + } + } + + // If still not found, try user tier fallback for user-owned namespaces + if ($totalLimit === null && $namespace->isOwnedByUser()) { + $user = $namespace->getOwnerUser(); + if ($user) { + // Check if user's tier includes this feature + if ($feature->isBoolean()) { + $hasFeature = $user->hasFeature($featureCode); + if ($hasFeature) { + return EntitlementResult::allowed(featureCode: $featureCode); + } + } + } + } + + if ($totalLimit === null) { + return EntitlementResult::denied( + reason: "Your plan does not include {$feature->name}.", + featureCode: $featureCode + ); + } + + // Check for unlimited + if ($totalLimit === -1) { + return EntitlementResult::unlimited($featureCode); + } + + // For boolean features, just check if enabled + if ($feature->isBoolean()) { + return EntitlementResult::allowed(featureCode: $featureCode); + } + + // Get current usage + $currentUsage = $this->getNamespaceCurrentUsage($namespace, $poolFeatureCode, $feature); + + // Check if quantity would exceed limit + if ($currentUsage + $quantity > $totalLimit) { + return EntitlementResult::denied( + reason: "You've reached your {$feature->name} limit ({$totalLimit}).", + limit: $totalLimit, + used: $currentUsage, + featureCode: $featureCode + ); + } + + return EntitlementResult::allowed( + limit: $totalLimit, + used: $currentUsage, + featureCode: $featureCode + ); + } + + /** + * Record usage of a feature for a namespace. + */ + public function recordNamespaceUsage( + Namespace_ $namespace, + string $featureCode, + int $quantity = 1, + ?User $user = null, + ?array $metadata = null + ): UsageRecord { + $feature = $this->getFeature($featureCode); + $poolFeatureCode = $feature?->getPoolFeatureCode() ?? $featureCode; + + $record = UsageRecord::create([ + 'namespace_id' => $namespace->id, + 'workspace_id' => $namespace->workspace_id, + 'feature_code' => $poolFeatureCode, + 'quantity' => $quantity, + 'user_id' => $user?->id, + 'metadata' => $metadata, + 'recorded_at' => now(), + ]); + + // Invalidate cache + $this->invalidateNamespaceCache($namespace); + + return $record; + } + /** * Record usage of a feature. */ @@ -477,4 +592,230 @@ class EntitlementService $this->invalidateCache($workspace); } + + // ───────────────────────────────────────────────────────────────────────── + // Namespace-specific methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get the total limit for a feature from namespace-level packages + boosts. + * + * Returns null if feature not included, -1 if unlimited. + */ + protected function getNamespaceTotalLimit(Namespace_ $namespace, string $featureCode): ?int + { + $cacheKey = "entitlement:ns:{$namespace->id}:limit:{$featureCode}"; + + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($namespace, $featureCode) { + $feature = $this->getFeature($featureCode); + + if (! $feature) { + return null; + } + + $totalLimit = 0; + $hasFeature = false; + + // Sum limits from active namespace packages + $packages = $namespace->namespacePackages() + ->with('package.features') + ->active() + ->notExpired() + ->get(); + + foreach ($packages as $namespacePackage) { + $packageFeature = $namespacePackage->package->features + ->where('code', $featureCode) + ->first(); + + if ($packageFeature) { + $hasFeature = true; + + // Check if unlimited in this package + if ($packageFeature->type === Feature::TYPE_UNLIMITED) { + return -1; + } + + // Add limit value (null = boolean, no limit to add) + $limitValue = $packageFeature->pivot->limit_value; + if ($limitValue !== null) { + $totalLimit += $limitValue; + } + } + } + + // Add limits from active namespace-level boosts + $boosts = $namespace->boosts() + ->forFeature($featureCode) + ->usable() + ->get(); + + foreach ($boosts as $boost) { + $hasFeature = true; + + if ($boost->boost_type === Boost::BOOST_TYPE_UNLIMITED) { + return -1; + } + + if ($boost->boost_type === Boost::BOOST_TYPE_ADD_LIMIT) { + $remaining = $boost->getRemainingLimit(); + if ($remaining !== null) { + $totalLimit += $remaining; + } + } + } + + return $hasFeature ? $totalLimit : null; + }); + } + + /** + * Get current usage for a feature at namespace level. + */ + protected function getNamespaceCurrentUsage(Namespace_ $namespace, string $featureCode, Feature $feature): int + { + $cacheKey = "entitlement:ns:{$namespace->id}:usage:{$featureCode}"; + + return Cache::remember($cacheKey, 60, function () use ($namespace, $featureCode, $feature) { + // Determine the time window for usage calculation + if ($feature->resetsMonthly()) { + // Get billing cycle anchor from the primary package + $primaryPackage = $namespace->namespacePackages() + ->whereHas('package', fn ($q) => $q->where('is_base_package', true)) + ->active() + ->first(); + + $cycleStart = $primaryPackage + ? $primaryPackage->getCurrentCycleStart() + : now()->startOfMonth(); + + return UsageRecord::where('namespace_id', $namespace->id) + ->where('feature_code', $featureCode) + ->where('recorded_at', '>=', $cycleStart) + ->sum('quantity'); + } + + if ($feature->resetsRolling()) { + $days = $feature->rolling_window_days ?? 30; + $since = now()->subDays($days); + + return UsageRecord::where('namespace_id', $namespace->id) + ->where('feature_code', $featureCode) + ->where('recorded_at', '>=', $since) + ->sum('quantity'); + } + + // No reset - all time usage + return UsageRecord::where('namespace_id', $namespace->id) + ->where('feature_code', $featureCode) + ->sum('quantity'); + }); + } + + /** + * Get usage summary for a namespace. + */ + public function getNamespaceUsageSummary(Namespace_ $namespace): Collection + { + $features = Feature::active()->orderBy('category')->orderBy('sort_order')->get(); + $summary = collect(); + + foreach ($features as $feature) { + $result = $this->canForNamespace($namespace, $feature->code); + + $summary->push([ + 'feature' => $feature, + 'code' => $feature->code, + 'name' => $feature->name, + 'category' => $feature->category, + 'type' => $feature->type, + 'allowed' => $result->isAllowed(), + 'limit' => $result->limit, + 'used' => $result->used, + 'remaining' => $result->remaining, + 'unlimited' => $result->isUnlimited(), + 'percentage' => $result->getUsagePercentage(), + 'near_limit' => $result->isNearLimit(), + ]); + } + + return $summary->groupBy('category'); + } + + /** + * Provision a package for a namespace. + */ + public function provisionNamespacePackage( + Namespace_ $namespace, + string $packageCode, + array $options = [] + ): NamespacePackage { + $package = Package::where('code', $packageCode)->firstOrFail(); + + // Check if this is a base package and namespace already has one + if ($package->is_base_package) { + $existingBase = $namespace->namespacePackages() + ->whereHas('package', fn ($q) => $q->where('is_base_package', true)) + ->active() + ->first(); + + if ($existingBase) { + // Cancel existing base package + $existingBase->cancel(now()); + } + } + + $namespacePackage = NamespacePackage::create([ + 'namespace_id' => $namespace->id, + 'package_id' => $package->id, + 'status' => NamespacePackage::STATUS_ACTIVE, + 'starts_at' => $options['starts_at'] ?? now(), + 'expires_at' => $options['expires_at'] ?? null, + 'billing_cycle_anchor' => $options['billing_cycle_anchor'] ?? now(), + 'metadata' => $options['metadata'] ?? null, + ]); + + $this->invalidateNamespaceCache($namespace); + + return $namespacePackage; + } + + /** + * Provision a boost for a namespace. + */ + public function provisionNamespaceBoost( + Namespace_ $namespace, + string $featureCode, + array $options = [] + ): Boost { + $boost = Boost::create([ + 'namespace_id' => $namespace->id, + 'workspace_id' => $namespace->workspace_id, + 'feature_code' => $featureCode, + 'boost_type' => $options['boost_type'] ?? Boost::BOOST_TYPE_ADD_LIMIT, + 'duration_type' => $options['duration_type'] ?? Boost::DURATION_CYCLE_BOUND, + 'limit_value' => $options['limit_value'] ?? null, + 'consumed_quantity' => 0, + 'status' => Boost::STATUS_ACTIVE, + 'starts_at' => $options['starts_at'] ?? now(), + 'expires_at' => $options['expires_at'] ?? null, + 'metadata' => $options['metadata'] ?? null, + ]); + + $this->invalidateNamespaceCache($namespace); + + return $boost; + } + + /** + * Invalidate all entitlement caches for a namespace. + */ + public function invalidateNamespaceCache(Namespace_ $namespace): void + { + $features = Feature::pluck('code'); + foreach ($features as $code) { + Cache::forget("entitlement:ns:{$namespace->id}:limit:{$code}"); + Cache::forget("entitlement:ns:{$namespace->id}:usage:{$code}"); + } + } } diff --git a/packages/core-php/src/Mod/Tenant/Services/TotpService.php b/packages/core-php/src/Mod/Tenant/Services/TotpService.php new file mode 100644 index 0000000..54de492 --- /dev/null +++ b/packages/core-php/src/Mod/Tenant/Services/TotpService.php @@ -0,0 +1,194 @@ +base32Encode($secret); + } + + /** + * Generate QR code URL for authenticator app setup. + * + * @param string $name Application/account name + * @param string $email User email + * @param string $secret TOTP secret key + */ + public function qrCodeUrl(string $name, string $email, string $secret): string + { + $encodedName = rawurlencode($name); + $encodedEmail = rawurlencode($email); + + return "otpauth://totp/{$encodedName}:{$encodedEmail}?secret={$secret}&issuer={$encodedName}&algorithm=SHA1&digits=6&period=30"; + } + + /** + * Generate a QR code SVG for the given URL. + */ + public function qrCodeSvg(string $url): string + { + $options = new QROptions([ + 'outputType' => QRCode::OUTPUT_MARKUP_SVG, + 'eccLevel' => QRCode::ECC_M, + 'imageBase64' => false, + 'addQuietzone' => true, + 'quietzoneSize' => 2, + 'drawLightModules' => false, + 'svgViewBoxSize' => 200, + ]); + + return (new QRCode($options))->render($url); + } + + /** + * Verify a TOTP code against the secret. + * + * @param string $secret TOTP secret key (base32 encoded) + * @param string $code User-provided 6-digit code + */ + public function verify(string $secret, string $code): bool + { + // Remove any spaces or dashes from the code + $code = preg_replace('/[^0-9]/', '', $code); + + if (strlen($code) !== self::CODE_LENGTH) { + return false; + } + + $secretBytes = $this->base32Decode($secret); + $timestamp = time(); + + // Check current time and adjacent windows for clock drift + for ($i = -self::WINDOW; $i <= self::WINDOW; $i++) { + $calculatedCode = $this->generateCode($secretBytes, $timestamp + ($i * self::TIME_STEP)); + + if (hash_equals($calculatedCode, $code)) { + return true; + } + } + + return false; + } + + /** + * Generate a TOTP code for a given timestamp. + */ + protected function generateCode(string $secretBytes, int $timestamp): string + { + $counter = (int) floor($timestamp / self::TIME_STEP); + + // Pack counter as 64-bit big-endian + $counterBytes = pack('N*', 0, $counter); + + // Generate HMAC + $hash = hash_hmac(self::ALGORITHM, $counterBytes, $secretBytes, true); + + // Dynamic truncation + $offset = ord($hash[strlen($hash) - 1]) & 0x0F; + $binary = + ((ord($hash[$offset]) & 0x7F) << 24) | + ((ord($hash[$offset + 1]) & 0xFF) << 16) | + ((ord($hash[$offset + 2]) & 0xFF) << 8) | + (ord($hash[$offset + 3]) & 0xFF); + + $otp = $binary % (10 ** self::CODE_LENGTH); + + return str_pad((string) $otp, self::CODE_LENGTH, '0', STR_PAD_LEFT); + } + + /** + * Encode bytes as base32. + */ + protected function base32Encode(string $data): string + { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + $binary = ''; + + foreach (str_split($data) as $char) { + $binary .= str_pad(decbin(ord($char)), 8, '0', STR_PAD_LEFT); + } + + $encoded = ''; + $chunks = str_split($binary, 5); + + foreach ($chunks as $chunk) { + if (strlen($chunk) < 5) { + $chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT); + } + $encoded .= $alphabet[bindec($chunk)]; + } + + return $encoded; + } + + /** + * Decode base32 to bytes. + */ + protected function base32Decode(string $data): string + { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + $data = strtoupper($data); + $data = rtrim($data, '='); + + $binary = ''; + foreach (str_split($data) as $char) { + $index = strpos($alphabet, $char); + if ($index === false) { + continue; + } + $binary .= str_pad(decbin($index), 5, '0', STR_PAD_LEFT); + } + + $decoded = ''; + $chunks = str_split($binary, 8); + + foreach ($chunks as $chunk) { + if (strlen($chunk) === 8) { + $decoded .= chr(bindec($chunk)); + } + } + + return $decoded; + } +} diff --git a/packages/core-php/src/Mod/Tenant/Tests/Feature/AccountDeletionTest.php b/packages/core-php/src/Mod/Tenant/Tests/Feature/AccountDeletionTest.php new file mode 100644 index 0000000..7d9455b --- /dev/null +++ b/packages/core-php/src/Mod/Tenant/Tests/Feature/AccountDeletionTest.php @@ -0,0 +1,334 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); +}); + +describe('AccountDeletionRequest Model', function () { + describe('createForUser()', function () { + it('creates a new deletion request', function () { + $request = AccountDeletionRequest::createForUser($this->user); + + expect($request)->toBeInstanceOf(AccountDeletionRequest::class) + ->and($request->user_id)->toBe($this->user->id) + ->and($request->token)->toHaveLength(64) + ->and($request->completed_at)->toBeNull() + ->and($request->cancelled_at)->toBeNull(); + }); + + it('sets expiry based on configured grace period', function () { + config(['tenant.deletion.grace_period_days' => 14]); + + $this->travelTo(now()->startOfDay()); + $request = AccountDeletionRequest::createForUser($this->user); + + // Expiry should be 14 days in the future + expect((int) abs($request->expires_at->startOfDay()->diffInDays(now()->startOfDay())))->toBe(14); + }); + + it('stores optional reason', function () { + $reason = 'Switching to competitor'; + + $request = AccountDeletionRequest::createForUser($this->user, $reason); + + expect($request->reason)->toBe($reason); + }); + + it('cancels existing pending requests', function () { + $oldRequest = AccountDeletionRequest::createForUser($this->user); + $oldRequestId = $oldRequest->id; + + $newRequest = AccountDeletionRequest::createForUser($this->user); + + expect(AccountDeletionRequest::find($oldRequestId))->toBeNull() + ->and($newRequest->id)->not->toBe($oldRequestId); + }); + + it('does not affect completed requests', function () { + $completedRequest = AccountDeletionRequest::createForUser($this->user); + $completedRequest->complete(); + + $newRequest = AccountDeletionRequest::createForUser($this->user); + + expect(AccountDeletionRequest::find($completedRequest->id))->not->toBeNull() + ->and($newRequest->id)->not->toBe($completedRequest->id); + }); + }); + + describe('findValidByToken()', function () { + it('finds valid request by token', function () { + $request = AccountDeletionRequest::createForUser($this->user); + + $found = AccountDeletionRequest::findValidByToken($request->token); + + expect($found)->not->toBeNull() + ->and($found->id)->toBe($request->id); + }); + + it('returns null for completed request', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->complete(); + + $found = AccountDeletionRequest::findValidByToken($request->token); + + expect($found)->toBeNull(); + }); + + it('returns null for cancelled request', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->cancel(); + + $found = AccountDeletionRequest::findValidByToken($request->token); + + expect($found)->toBeNull(); + }); + + it('returns null for invalid token', function () { + AccountDeletionRequest::createForUser($this->user); + + $found = AccountDeletionRequest::findValidByToken('invalid-token'); + + expect($found)->toBeNull(); + }); + }); + + describe('pendingAutoDelete()', function () { + it('returns requests past expiry date', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDay()]); + + $pending = AccountDeletionRequest::pendingAutoDelete()->get(); + + expect($pending)->toHaveCount(1) + ->and($pending->first()->id)->toBe($request->id); + }); + + it('excludes requests not yet expired', function () { + AccountDeletionRequest::createForUser($this->user); + + $pending = AccountDeletionRequest::pendingAutoDelete()->get(); + + expect($pending)->toHaveCount(0); + }); + + it('excludes completed requests', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDay()]); + $request->complete(); + + $pending = AccountDeletionRequest::pendingAutoDelete()->get(); + + expect($pending)->toHaveCount(0); + }); + + it('excludes cancelled requests', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDay()]); + $request->cancel(); + + $pending = AccountDeletionRequest::pendingAutoDelete()->get(); + + expect($pending)->toHaveCount(0); + }); + }); + + describe('state methods', function () { + it('isActive returns true for pending requests', function () { + $request = AccountDeletionRequest::createForUser($this->user); + + expect($request->isActive())->toBeTrue(); + }); + + it('isActive returns false after completion', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->complete(); + + expect($request->isActive())->toBeFalse(); + }); + + it('isActive returns false after cancellation', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->cancel(); + + expect($request->isActive())->toBeFalse(); + }); + + it('isPending returns true for future expiry', function () { + $request = AccountDeletionRequest::createForUser($this->user); + + expect($request->isPending())->toBeTrue(); + }); + + it('isReadyForAutoDeletion returns true for past expiry', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDay()]); + + expect($request->isReadyForAutoDeletion())->toBeTrue(); + }); + }); + + describe('time helpers', function () { + it('calculates days remaining approximately', function () { + $this->travelTo(now()->startOfDay()); + + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->startOfDay()->addDays(5)]); + + // Use startOfDay to avoid timing issues + expect($request->daysRemaining())->toBeGreaterThanOrEqual(4) + ->and($request->daysRemaining())->toBeLessThanOrEqual(5); + }); + + it('calculates hours remaining approximately', function () { + $this->travelTo(now()->startOfHour()); + + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->startOfHour()->addHours(48)]); + + expect($request->hoursRemaining())->toBeGreaterThanOrEqual(47) + ->and($request->hoursRemaining())->toBeLessThanOrEqual(48); + }); + + it('returns zero for past expiry', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDays(2)]); + + expect($request->daysRemaining())->toBe(0) + ->and($request->hoursRemaining())->toBe(0); + }); + }); + + describe('URL helpers', function () { + it('generates confirmation URL with token', function () { + $request = AccountDeletionRequest::createForUser($this->user); + + $url = $request->confirmationUrl(); + + expect($url)->toContain($request->token) + ->and($url)->toContain('account/delete'); + }); + + it('generates cancel URL with token', function () { + $request = AccountDeletionRequest::createForUser($this->user); + + $url = $request->cancelUrl(); + + expect($url)->toContain($request->token) + ->and($url)->toContain('cancel'); + }); + }); +}); + +describe('ProcessAccountDeletion Job', function () { + it('deletes user account', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDay()]); + + $job = new ProcessAccountDeletion($request); + $job->handle(); + + // User should be deleted + expect(User::find($this->user->id))->toBeNull(); + + // Note: AccountDeletionRequest is also deleted due to CASCADE constraint + // This is expected behaviour as we want the request deleted when user is deleted + }); + + it('deletes user workspaces', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDay()]); + $workspaceId = $this->workspace->id; + + $job = new ProcessAccountDeletion($request); + $job->handle(); + + expect(Workspace::find($workspaceId))->toBeNull(); + }); + + it('skips if request no longer active', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->cancel(); + + $job = new ProcessAccountDeletion($request); + $job->handle(); + + expect(User::find($this->user->id))->not->toBeNull(); + }); + + it('handles missing user gracefully', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $this->user->forceDelete(); + + // Request is deleted due to CASCADE, job should handle this gracefully + $job = new ProcessAccountDeletion($request); + + // Should not throw + $job->handle(); + + // Just verify user is still gone + expect(User::find($this->user->id))->toBeNull(); + }); +}); + +describe('ProcessAccountDeletions Command', function () { + it('processes expired deletion requests', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDay()]); + + $this->artisan('accounts:process-deletions') + ->assertSuccessful() + ->expectsOutputToContain('1 deleted'); + + expect(User::find($this->user->id))->toBeNull(); + }); + + it('skips non-expired requests', function () { + AccountDeletionRequest::createForUser($this->user); + + $this->artisan('accounts:process-deletions') + ->assertSuccessful() + ->expectsOutputToContain('No pending account deletions'); + + expect(User::find($this->user->id))->not->toBeNull(); + }); + + it('supports dry-run mode', function () { + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDay()]); + + $this->artisan('accounts:process-deletions', ['--dry-run' => true]) + ->assertSuccessful() + ->expectsOutputToContain('DRY RUN'); + + // User should still exist + expect(User::find($this->user->id))->not->toBeNull(); + }); + + it('supports queue mode', function () { + Queue::fake(); + + $request = AccountDeletionRequest::createForUser($this->user); + $request->update(['expires_at' => now()->subDay()]); + + $this->artisan('accounts:process-deletions', ['--queue' => true]) + ->assertSuccessful() + ->expectsOutputToContain('queued'); + + Queue::assertPushed(ProcessAccountDeletion::class); + }); +}); diff --git a/packages/core-php/src/Mod/Tenant/Tests/Feature/TwoFactorAuthenticatableTest.php b/packages/core-php/src/Mod/Tenant/Tests/Feature/TwoFactorAuthenticatableTest.php new file mode 100644 index 0000000..fbfe594 --- /dev/null +++ b/packages/core-php/src/Mod/Tenant/Tests/Feature/TwoFactorAuthenticatableTest.php @@ -0,0 +1,334 @@ +user = User::factory()->create(); +}); + +describe('TwoFactorAuthenticatable Trait', function () { + describe('twoFactorAuth() relationship', function () { + it('returns HasOne relationship', function () { + expect($this->user->twoFactorAuth())->toBeInstanceOf( + \Illuminate\Database\Eloquent\Relations\HasOne::class + ); + }); + + it('returns null when no 2FA record exists', function () { + expect($this->user->twoFactorAuth)->toBeNull(); + }); + + it('returns 2FA record when it exists', function () { + $twoFactorAuth = UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => ['code1', 'code2'], + 'confirmed_at' => now(), + ]); + + $this->user->refresh(); + + expect($this->user->twoFactorAuth)->toBeInstanceOf(UserTwoFactorAuth::class) + ->and($this->user->twoFactorAuth->id)->toBe($twoFactorAuth->id); + }); + }); + + describe('hasTwoFactorAuthEnabled()', function () { + it('returns false when no 2FA record exists', function () { + expect($this->user->hasTwoFactorAuthEnabled())->toBeFalse(); + }); + + it('returns false when 2FA record exists but secret_key is null', function () { + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => null, + 'recovery_codes' => [], + 'confirmed_at' => now(), + ]); + + $this->user->refresh(); + + expect($this->user->hasTwoFactorAuthEnabled())->toBeFalse(); + }); + + it('returns false when 2FA record exists but confirmed_at is null', function () { + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => [], + 'confirmed_at' => null, + ]); + + $this->user->refresh(); + + expect($this->user->hasTwoFactorAuthEnabled())->toBeFalse(); + }); + + it('returns true when 2FA is fully enabled', function () { + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => ['code1', 'code2'], + 'confirmed_at' => now(), + ]); + + $this->user->refresh(); + + expect($this->user->hasTwoFactorAuthEnabled())->toBeTrue(); + }); + }); + + describe('twoFactorAuthSecretKey()', function () { + it('returns null when no 2FA record exists', function () { + expect($this->user->twoFactorAuthSecretKey())->toBeNull(); + }); + + it('returns the secret key when 2FA record exists', function () { + $secretKey = 'JBSWY3DPEHPK3PXP'; + + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => $secretKey, + 'recovery_codes' => [], + ]); + + $this->user->refresh(); + + expect($this->user->twoFactorAuthSecretKey())->toBe($secretKey); + }); + }); + + describe('twoFactorRecoveryCodes()', function () { + it('returns empty array when no 2FA record exists', function () { + expect($this->user->twoFactorRecoveryCodes())->toBe([]); + }); + + it('returns empty array when recovery_codes is null', function () { + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => null, + ]); + + $this->user->refresh(); + + expect($this->user->twoFactorRecoveryCodes())->toBe([]); + }); + + it('returns recovery codes as array', function () { + $codes = ['CODE1-CODE1', 'CODE2-CODE2', 'CODE3-CODE3']; + + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => $codes, + ]); + + $this->user->refresh(); + + expect($this->user->twoFactorRecoveryCodes())->toBe($codes); + }); + }); + + describe('twoFactorReplaceRecoveryCode()', function () { + it('does nothing when no 2FA record exists', function () { + // Should not throw + $this->user->twoFactorReplaceRecoveryCode('nonexistent'); + + expect($this->user->twoFactorAuth)->toBeNull(); + }); + + it('does nothing when code is not found in recovery codes', function () { + $codes = ['CODE1-CODE1', 'CODE2-CODE2', 'CODE3-CODE3']; + + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => $codes, + ]); + + $this->user->refresh(); + + $this->user->twoFactorReplaceRecoveryCode('NONEXISTENT'); + + $this->user->refresh(); + + expect($this->user->twoFactorRecoveryCodes())->toBe($codes); + }); + + it('replaces a used recovery code with a new one', function () { + $codes = ['CODE1-CODE1', 'CODE2-CODE2', 'CODE3-CODE3']; + + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => $codes, + ]); + + $this->user->refresh(); + + $this->user->twoFactorReplaceRecoveryCode('CODE2-CODE2'); + + $this->user->refresh(); + $newCodes = $this->user->twoFactorRecoveryCodes(); + + // Should still have 3 codes + expect($newCodes)->toHaveCount(3) + // First and third codes should be unchanged + ->and($newCodes[0])->toBe('CODE1-CODE1') + ->and($newCodes[2])->toBe('CODE3-CODE3') + // Second code should be different and in the expected format + ->and($newCodes[1])->not->toBe('CODE2-CODE2') + ->and($newCodes[1])->toMatch('/^[A-F0-9]{10}-[A-F0-9]{10}$/'); + }); + }); + + describe('twoFactorQrCodeUrl()', function () { + it('generates valid TOTP URL', function () { + $secretKey = 'JBSWY3DPEHPK3PXP'; + + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => $secretKey, + 'recovery_codes' => [], + ]); + + $this->user->refresh(); + + $url = $this->user->twoFactorQrCodeUrl(); + + expect($url)->toStartWith('otpauth://totp/') + ->and($url)->toContain($secretKey) + ->and($url)->toContain(rawurlencode($this->user->email)) + ->and($url)->toContain('issuer='); + }); + + it('includes app name in the URL', function () { + $appName = config('app.name'); + + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => [], + ]); + + $this->user->refresh(); + + $url = $this->user->twoFactorQrCodeUrl(); + + expect($url)->toContain(rawurlencode($appName)); + }); + }); + + describe('twoFactorQrCodeSvg()', function () { + it('returns empty string when no secret exists', function () { + expect($this->user->twoFactorQrCodeSvg())->toBe(''); + }); + + it('returns SVG content when secret exists', function () { + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => [], + ]); + + $this->user->refresh(); + + $svg = $this->user->twoFactorQrCodeSvg(); + + expect($svg)->toStartWith('and($svg)->toContain(''); + }); + }); + + describe('generateRecoveryCode() via twoFactorReplaceRecoveryCode()', function () { + it('generates codes in the expected format', function () { + $codes = ['TESTCODE1']; + + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => $codes, + ]); + + $this->user->refresh(); + + $this->user->twoFactorReplaceRecoveryCode('TESTCODE1'); + + $this->user->refresh(); + $newCode = $this->user->twoFactorRecoveryCodes()[0]; + + // Format: 10 uppercase hex chars - 10 uppercase hex chars + expect($newCode)->toMatch('/^[A-F0-9]{10}-[A-F0-9]{10}$/'); + }); + + it('generates unique codes', function () { + $codes = ['CODE1', 'CODE2', 'CODE3']; + + UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => $codes, + ]); + + $this->user->refresh(); + + // Replace all codes + $this->user->twoFactorReplaceRecoveryCode('CODE1'); + $this->user->refresh(); + $this->user->twoFactorReplaceRecoveryCode('CODE2'); + $this->user->refresh(); + $this->user->twoFactorReplaceRecoveryCode('CODE3'); + $this->user->refresh(); + + $newCodes = $this->user->twoFactorRecoveryCodes(); + + // All codes should be unique + expect(array_unique($newCodes))->toHaveCount(3); + }); + }); +}); + +describe('UserTwoFactorAuth Model', function () { + it('belongs to a user', function () { + $twoFactorAuth = UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => [], + ]); + + expect($twoFactorAuth->user)->toBeInstanceOf(User::class) + ->and($twoFactorAuth->user->id)->toBe($this->user->id); + }); + + it('casts recovery_codes to collection', function () { + $codes = ['CODE1', 'CODE2']; + + $twoFactorAuth = UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => $codes, + ]); + + expect($twoFactorAuth->recovery_codes)->toBeInstanceOf(\Illuminate\Support\Collection::class) + ->and($twoFactorAuth->recovery_codes->toArray())->toBe($codes); + }); + + it('casts confirmed_at to datetime', function () { + $confirmedAt = now(); + + $twoFactorAuth = UserTwoFactorAuth::create([ + 'user_id' => $this->user->id, + 'secret_key' => 'JBSWY3DPEHPK3PXP', + 'recovery_codes' => [], + 'confirmed_at' => $confirmedAt, + ]); + + expect($twoFactorAuth->confirmed_at)->toBeInstanceOf(\Carbon\Carbon::class); + }); +}); 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 deleted file mode 100644 index 2f45c38..0000000 --- a/packages/core-php/src/Mod/Web/Migrations/0001_01_01_000001_create_bio_tables.php +++ /dev/null @@ -1,417 +0,0 @@ -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(); - } -}; diff --git a/storage/framework/views/197a81ca7ea9bb86f1b0b4299461a54e.php b/storage/framework/views/197a81ca7ea9bb86f1b0b4299461a54e.php deleted file mode 100644 index 0db9d45..0000000 --- a/storage/framework/views/197a81ca7ea9bb86f1b0b4299461a54e.php +++ /dev/null @@ -1,93 +0,0 @@ - null, - 'active' => false, - 'color' => 'gray', - 'expanded' => null, -])); - -foreach ($attributes->all() as $__key => $__value) { - if (in_array($__key, $__propNames)) { - $$__key = $$__key ?? $__value; - } else { - $__newAttributes[$__key] = $__value; - } -} - -$attributes = new \Illuminate\View\ComponentAttributeBag($__newAttributes); - -unset($__propNames); -unset($__newAttributes); - -foreach (array_filter(([ - 'title', - 'icon' => null, - 'active' => false, - 'color' => 'gray', - 'expanded' => null, -]), 'is_string', ARRAY_FILTER_USE_KEY) as $__key => $__value) { - $$__key = $$__key ?? $__value; -} - -$__defined_vars = get_defined_vars(); - -foreach ($attributes->all() as $__key => $__value) { - if (array_key_exists($__key, $__defined_vars)) unset($$__key); -} - -unset($__defined_vars, $__key, $__value); ?> - - - -
  • - -
    -
    - - - - '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => $icon,'class' => 'shrink-0 '.e($active ? 'text-violet-500' : 'text-' . $color . '-500').'']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?> -withName('core::icon'); ?> -shouldRender()): ?> -startComponent($component->resolveView(), $component->data()); ?> - -except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?> - -withAttributes(['name' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($icon),'class' => 'shrink-0 '.e($active ? 'text-violet-500' : 'text-' . $color . '-500').'']); ?> -renderComponent(); ?> - - - - - - - - - - - -
    -
    - - - -
    -
    -
    -
    -
      - - -
    -
    -
  • - \ No newline at end of file diff --git a/storage/framework/views/2cc5de739ce7e5c0ba9026aa4c3defd1.php b/storage/framework/views/2cc5de739ce7e5c0ba9026aa4c3defd1.php deleted file mode 100644 index 7d5d034..0000000 --- a/storage/framework/views/2cc5de739ce7e5c0ba9026aa4c3defd1.php +++ /dev/null @@ -1,179 +0,0 @@ - - null, // Override: solid, regular, light, thin, duotone, brands, jelly - 'size' => null, // Size class: xs, sm, lg, xl, 2xl, etc. - 'spin' => false, // Animate spinning - 'pulse' => false, // Animate pulsing - 'flip' => null, // horizontal, vertical, both - 'rotate' => null, // 90, 180, 270 - 'fw' => false, // Fixed width -])); - -foreach ($attributes->all() as $__key => $__value) { - if (in_array($__key, $__propNames)) { - $$__key = $$__key ?? $__value; - } else { - $__newAttributes[$__key] = $__value; - } -} - -$attributes = new \Illuminate\View\ComponentAttributeBag($__newAttributes); - -unset($__propNames); -unset($__newAttributes); - -foreach (array_filter(([ - 'name', - 'style' => null, // Override: solid, regular, light, thin, duotone, brands, jelly - 'size' => null, // Size class: xs, sm, lg, xl, 2xl, etc. - 'spin' => false, // Animate spinning - 'pulse' => false, // Animate pulsing - 'flip' => null, // horizontal, vertical, both - 'rotate' => null, // 90, 180, 270 - 'fw' => false, // Fixed width -]), 'is_string', ARRAY_FILTER_USE_KEY) as $__key => $__value) { - $$__key = $$__key ?? $__value; -} - -$__defined_vars = get_defined_vars(); - -foreach ($attributes->all() as $__key => $__value) { - if (array_key_exists($__key, $__defined_vars)) unset($$__key); -} - -unset($__defined_vars, $__key, $__value); ?> - - 'regular', - 'thin' => 'regular', - 'duotone' => 'solid', - 'sharp' => 'solid', - 'jelly' => 'solid', - ]; - - $hasFaPro = Pro::hasFontAwesomePro(); - - // Determine raw style - if ($style) { - $rawStyle = match($style) { - 'brands', 'brand' => 'brands', - default => $style, - }; - } elseif (in_array($name, $brandIcons)) { - $rawStyle = 'brands'; - } elseif (in_array($name, $jellyIcons)) { - $rawStyle = 'jelly'; - } else { - $rawStyle = 'solid'; - } - - // Apply fallback if Pro not available - $finalStyle = $rawStyle; - if (!$hasFaPro && isset($proStyleFallbacks[$rawStyle])) { - $finalStyle = $proStyleFallbacks[$rawStyle]; - } - - $iconStyle = "fa-{$finalStyle}"; - - // Build classes - $classes = collect([ - $iconStyle, - "fa-{$name}", - $size ? "fa-{$size}" : null, - $spin ? 'fa-spin' : null, - $pulse ? 'fa-pulse' : null, - $flip ? "fa-flip-{$flip}" : null, - $rotate ? "fa-rotate-{$rotate}" : null, - $fw ? 'fa-fw' : null, - ])->filter()->implode(' '); -?> - -class($classes)); ?> aria-hidden="true"> - \ No newline at end of file diff --git a/storage/framework/views/2e86128b7f533fbd05843fffa6fbb322.php b/storage/framework/views/2e86128b7f533fbd05843fffa6fbb322.php deleted file mode 100644 index 0c3c27f..0000000 --- a/storage/framework/views/2e86128b7f533fbd05843fffa6fbb322.php +++ /dev/null @@ -1,506 +0,0 @@ -user(); - $showDevBar = $user && method_exists($user, 'isHades') && $user->isHades(); - - // Performance metrics - $queryCount = count(DB::getQueryLog()); - $startTime = defined('LARAVEL_START') ? LARAVEL_START : microtime(true); - $loadTime = number_format((microtime(true) - $startTime) * 1000, 2); - $memoryUsage = number_format(memory_get_peak_usage(true) / 1024 / 1024, 1); - - // Check available dev tools - $hasHorizon = class_exists(\Laravel\Horizon\Horizon::class); - $hasPulse = class_exists(\Laravel\Pulse\Pulse::class); - $hasTelescope = class_exists(\Laravel\Telescope\Telescope::class) && config('telescope.enabled', false); -?> - - -
    - -
    - -
    -
    -

    Recent Logs

    - -
    -
    - - - -
    -
    - - -
    -
    -

    Routes

    - -
    -
    - - -
    -
    - - -
    -

    Session & Request

    -
    -
    -
    Session ID
    -
    -
    -
    -
    User Agent
    -
    -
    -
    -
    IP Address
    -
    -
    -
    -
    PHP Version
    -
    -
    -
    -
    Laravel Version
    -
    version()); ?>
    -
    -
    -
    Environment
    -
    environment()); ?>
    -
    -
    -
    - - -
    -

    Cache Management

    -
    - - - - - -
    -

    - - These actions run artisan cache commands on the server. -

    -
    - - -
    - -

    Classic

    -
    - - - - - -
    - - -

    Sharp

    -
    - - - - - -
    - - -

    Specialty

    -
    - - - - - - - - - - - - - - -
    - - -

    Size

    -
    - - - - - - - -
    - -

    - - Current: - + -

    -
    -
    - - -
    -
    - -
    -
    - - environment()); ?> - - - | - - Hades - -
    - -
    - - -
    - - - - - - - - - - - | - - - - - - - - - - - - - - - - - - - -
    - - -
    - - - -
    -
    -
    -
    - - - - \ No newline at end of file diff --git a/storage/framework/views/3b7d1db1c4733b8ee9d88dea0903c32c.php b/storage/framework/views/3b7d1db1c4733b8ee9d88dea0903c32c.php deleted file mode 100644 index 6a03c19..0000000 --- a/storage/framework/views/3b7d1db1c4733b8ee9d88dea0903c32c.php +++ /dev/null @@ -1,124 +0,0 @@ -
      - addLoop($__currentLoopData); foreach($__currentLoopData as $item): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - - -
    • -
      -
    • - - -
    • - - - 'admin::components.nav-menu','data' => ['title' => $item['label'],'icon' => $item['icon'] ?? null,'active' => $item['active'] ?? false,'color' => $item['color'] ?? 'gray']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?> -withName('admin::nav-menu'); ?> -shouldRender()): ?> -startComponent($component->resolveView(), $component->data()); ?> - -except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?> - -withAttributes(['title' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($item['label']),'icon' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($item['icon'] ?? null),'active' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($item['active'] ?? false),'color' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($item['color'] ?? 'gray')]); ?> - addLoop($__currentLoopData); foreach($__currentLoopData as $child): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - - - 'text-violet-500', - 'blue' => 'text-blue-500', - 'green' => 'text-green-500', - 'red' => 'text-red-500', - 'amber' => 'text-amber-500', - 'emerald' => 'text-emerald-500', - 'cyan' => 'text-cyan-500', - 'pink' => 'text-pink-500', - default => 'text-gray-500', - }; - ?> -
    • - - - - '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => $child['icon'],'class' => 'size-4 shrink-0 '.e($sectionIconClass).'']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?> -withName('core::icon'); ?> -shouldRender()): ?> -startComponent($component->resolveView(), $component->data()); ?> - -except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?> - -withAttributes(['name' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($child['icon']),'class' => 'size-4 shrink-0 '.e($sectionIconClass).'']); ?> -renderComponent(); ?> - - - - - - - - - - - - - - -
    • - - - - 'admin::components.nav-link','data' => ['href' => $child['href'] ?? '#','active' => $child['active'] ?? false,'badge' => $child['badge'] ?? null,'icon' => $child['icon'] ?? null,'color' => $child['color'] ?? null]] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?> -withName('admin::nav-link'); ?> -shouldRender()): ?> -startComponent($component->resolveView(), $component->data()); ?> - -except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?> - -withAttributes(['href' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($child['href'] ?? '#'),'active' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($child['active'] ?? false),'badge' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($child['badge'] ?? null),'icon' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($child['icon'] ?? null),'color' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($child['color'] ?? null)]); ?> renderComponent(); ?> - - - - - - - - - - - popLoop(); $loop = $__env->getLastLoop(); ?> - renderComponent(); ?> - - - - - - - - - - - - -
    • - - - 'admin::components.nav-item','data' => ['href' => $item['href'] ?? '#','icon' => $item['icon'] ?? null,'active' => $item['active'] ?? false,'color' => $item['color'] ?? 'gray','badge' => $item['badge'] ?? null]] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?> -withName('admin::nav-item'); ?> -shouldRender()): ?> -startComponent($component->resolveView(), $component->data()); ?> - -except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?> - -withAttributes(['href' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($item['href'] ?? '#'),'icon' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($item['icon'] ?? null),'active' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($item['active'] ?? false),'color' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($item['color'] ?? 'gray'),'badge' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($item['badge'] ?? null)]); ?> renderComponent(); ?> - - - - - - - - - -
    • - - popLoop(); $loop = $__env->getLastLoop(); ?> -
    - \ No newline at end of file diff --git a/storage/framework/views/4ca7befeb99700779f107cb53aa83062.php b/storage/framework/views/4ca7befeb99700779f107cb53aa83062.php deleted file mode 100644 index dc3b0f5..0000000 --- a/storage/framework/views/4ca7befeb99700779f107cb53aa83062.php +++ /dev/null @@ -1,48 +0,0 @@ - - - null, -])); - -foreach ($attributes->all() as $__key => $__value) { - if (in_array($__key, $__propNames)) { - $$__key = $$__key ?? $__value; - } else { - $__newAttributes[$__key] = $__value; - } -} - -$attributes = new \Illuminate\View\ComponentAttributeBag($__newAttributes); - -unset($__propNames); -unset($__newAttributes); - -foreach (array_filter(([ - 'as' => null, -]), 'is_string', ARRAY_FILTER_USE_KEY) as $__key => $__value) { - $$__key = $$__key ?? $__value; -} - -$__defined_vars = get_defined_vars(); - -foreach ($attributes->all() as $__key => $__value) { - if (array_key_exists($__key, $__defined_vars)) unset($$__key); -} - -unset($__defined_vars, $__key, $__value); ?> - - - - -
    > - - -
    - - \ No newline at end of file diff --git a/storage/framework/views/59feaf025ac9e0d20fc7f0358aadbd62.php b/storage/framework/views/59feaf025ac9e0d20fc7f0358aadbd62.php deleted file mode 100644 index 81ff6ea..0000000 --- a/storage/framework/views/59feaf025ac9e0d20fc7f0358aadbd62.php +++ /dev/null @@ -1,76 +0,0 @@ - false, - 'badge' => null, - 'icon' => null, - 'color' => null, -])); - -foreach ($attributes->all() as $__key => $__value) { - if (in_array($__key, $__propNames)) { - $$__key = $$__key ?? $__value; - } else { - $__newAttributes[$__key] = $__value; - } -} - -$attributes = new \Illuminate\View\ComponentAttributeBag($__newAttributes); - -unset($__propNames); -unset($__newAttributes); - -foreach (array_filter(([ - 'href', - 'active' => false, - 'badge' => null, - 'icon' => null, - 'color' => null, -]), 'is_string', ARRAY_FILTER_USE_KEY) as $__key => $__value) { - $$__key = $$__key ?? $__value; -} - -$__defined_vars = get_defined_vars(); - -foreach ($attributes->all() as $__key => $__value) { - if (array_key_exists($__key, $__defined_vars)) unset($$__key); -} - -unset($__defined_vars, $__key, $__value); ?> - -
  • - - - - - - '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => $icon,'class' => 'size-4 shrink-0 '.e($color ? 'text-' . $color . '-500' : '').'']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?> -withName('core::icon'); ?> -shouldRender()): ?> -startComponent($component->resolveView(), $component->data()); ?> - -except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?> - -withAttributes(['name' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($icon),'class' => 'size-4 shrink-0 '.e($color ? 'text-' . $color . '-500' : '').'']); ?> -renderComponent(); ?> - - - - - - - - - - - - - - - - - -
  • - \ No newline at end of file diff --git a/storage/framework/views/7426fed20565036bdf8583fa143c46d6.php b/storage/framework/views/7426fed20565036bdf8583fa143c46d6.php deleted file mode 100644 index fe9f432..0000000 --- a/storage/framework/views/7426fed20565036bdf8583fa143c46d6.php +++ /dev/null @@ -1,42 +0,0 @@ - - - 'admin::components.sidebar','data' => ['logo' => '/images/host-uk-raven.svg','logoText' => 'Host Hub','logoRoute' => route('hub.dashboard')]] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?> -withName('admin::sidebar'); ?> -shouldRender()): ?> -startComponent($component->resolveView(), $component->data()); ?> - -except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?> - -withAttributes(['logo' => '/images/host-uk-raven.svg','logoText' => 'Host Hub','logoRoute' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute(route('hub.dashboard'))]); ?> - - -all() : [])); ?> -withName('admin-sidemenu'); ?> -shouldRender()): ?> -startComponent($component->resolveView(), $component->data()); ?> - -except(\Core\Front\Admin\View\Components\Sidemenu::ignoredParameterNames()); ?> - -withAttributes([]); ?> -renderComponent(); ?> - - - - - - - - - - renderComponent(); ?> - - - - - - - - - - - \ No newline at end of file diff --git a/storage/framework/views/7c2eef4dbc7d52e212a190816cc2a217.php b/storage/framework/views/7c2eef4dbc7d52e212a190816cc2a217.php deleted file mode 100644 index d683758..0000000 --- a/storage/framework/views/7c2eef4dbc7d52e212a190816cc2a217.php +++ /dev/null @@ -1,91 +0,0 @@ - null, - 'logoRoute' => '/', - 'logoText' => 'Admin', -])); - -foreach ($attributes->all() as $__key => $__value) { - if (in_array($__key, $__propNames)) { - $$__key = $$__key ?? $__value; - } else { - $__newAttributes[$__key] = $__value; - } -} - -$attributes = new \Illuminate\View\ComponentAttributeBag($__newAttributes); - -unset($__propNames); -unset($__newAttributes); - -foreach (array_filter(([ - 'logo' => null, - 'logoRoute' => '/', - 'logoText' => 'Admin', -]), 'is_string', ARRAY_FILTER_USE_KEY) as $__key => $__value) { - $$__key = $$__key ?? $__value; -} - -$__defined_vars = get_defined_vars(); - -foreach ($attributes->all() as $__key => $__value) { - if (array_key_exists($__key, $__defined_vars)) unset($$__key); -} - -unset($__defined_vars, $__key, $__value); ?> - -
    - - - - - -
    - \ No newline at end of file diff --git a/storage/framework/views/8949b3a7ce3ead3cc3bee0674e4e8057.php b/storage/framework/views/8949b3a7ce3ead3cc3bee0674e4e8057.php deleted file mode 100644 index a0c193d..0000000 --- a/storage/framework/views/8949b3a7ce3ead3cc3bee0674e4e8057.php +++ /dev/null @@ -1,81 +0,0 @@ - null, - 'active' => false, - 'color' => 'gray', - 'badge' => null, -])); - -foreach ($attributes->all() as $__key => $__value) { - if (in_array($__key, $__propNames)) { - $$__key = $$__key ?? $__value; - } else { - $__newAttributes[$__key] = $__value; - } -} - -$attributes = new \Illuminate\View\ComponentAttributeBag($__newAttributes); - -unset($__propNames); -unset($__newAttributes); - -foreach (array_filter(([ - 'href', - 'icon' => null, - 'active' => false, - 'color' => 'gray', - 'badge' => null, -]), 'is_string', ARRAY_FILTER_USE_KEY) as $__key => $__value) { - $$__key = $$__key ?? $__value; -} - -$__defined_vars = get_defined_vars(); - -foreach ($attributes->all() as $__key => $__value) { - if (array_key_exists($__key, $__defined_vars)) unset($$__key); -} - -unset($__defined_vars, $__key, $__value); ?> - -
  • - -
    -
    - - - - '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => $icon,'class' => 'shrink-0 '.e($active ? 'text-violet-500' : 'text-' . $color . '-500').'']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?> -withName('core::icon'); ?> -shouldRender()): ?> -startComponent($component->resolveView(), $component->data()); ?> - -except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?> - -withAttributes(['name' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($icon),'class' => 'shrink-0 '.e($active ? 'text-violet-500' : 'text-' . $color . '-500').'']); ?> -renderComponent(); ?> - - - - - - - - - - - -
    - - - -
    -
    -
  • - \ No newline at end of file diff --git a/storage/framework/views/8fa977306093a3fa3bd0e5cc153799b2.php b/storage/framework/views/8fa977306093a3fa3bd0e5cc153799b2.php deleted file mode 100644 index 26ccf10..0000000 --- a/storage/framework/views/8fa977306093a3fa3bd0e5cc153799b2.php +++ /dev/null @@ -1,82 +0,0 @@ - - - 'bottom end', -])); - -foreach ($attributes->all() as $__key => $__value) { - if (in_array($__key, $__propNames)) { - $$__key = $$__key ?? $__value; - } else { - $__newAttributes[$__key] = $__value; - } -} - -$attributes = new \Illuminate\View\ComponentAttributeBag($__newAttributes); - -unset($__propNames); -unset($__newAttributes); - -foreach (array_filter(([ - 'position' => 'bottom end', -]), 'is_string', ARRAY_FILTER_USE_KEY) as $__key => $__value) { - $$__key = $$__key ?? $__value; -} - -$__defined_vars = get_defined_vars(); - -foreach ($attributes->all() as $__key => $__value) { - if (array_key_exists($__key, $__defined_vars)) unset($$__key); -} - -unset($__defined_vars, $__key, $__value); ?> - - - - - \ No newline at end of file diff --git a/storage/framework/views/adbe419bf3464c90684a916fd827dd64.php b/storage/framework/views/adbe419bf3464c90684a916fd827dd64.php deleted file mode 100644 index c3217bd..0000000 --- a/storage/framework/views/adbe419bf3464c90684a916fd827dd64.php +++ /dev/null @@ -1,367 +0,0 @@ -
    -
    -
    - - -
    - - - - - - mount($__name, $__params, $key); - -echo $__html; - -unset($__html); -unset($__name); -unset($__params); -unset($__split); -if (isset($__slots)) unset($__slots); -?> - -
    - - -
    - - - - - -
    - - -
    - - - - - -
    - - - user(); - $userName = $user?->name ?? 'Guest'; - $userEmail = $user?->email ?? ''; - $userTier = ($user && method_exists($user, 'getTier')) ? ($user->getTier()?->label() ?? 'Free') : 'Free'; - $userInitials = collect(explode(' ', $userName))->map(fn($n) => strtoupper(substr($n, 0, 1)))->take(2)->join(''); - ?> -
    - -
    -
    -
    -
    -
    - -
    -
    - -
    - -
    -
    -
    - \ No newline at end of file diff --git a/storage/framework/views/c324df4f93863f809faae41927c1571b.php b/storage/framework/views/c324df4f93863f809faae41927c1571b.php deleted file mode 100644 index 4d80f08..0000000 --- a/storage/framework/views/c324df4f93863f809faae41927c1571b.php +++ /dev/null @@ -1,145 +0,0 @@ -cookie('dark-mode') === 'true'; -?> - - - - - - - - <?php echo e($title ?? 'Admin'); ?> - <?php echo e(config('app.name', 'Host Hub')); ?> - - - - - - - - make('layouts::partials.fonts', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> - - - - - - - - - - - - - fluxAppearance(); ?> - - - - - - -
    - - make('hub::admin.components.sidebar', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> - - -
    - - make('hub::admin.components.header', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> - -
    - - -
    - -
    - -
    - - -forceAssetInjection(); ?>
    - - - 'e60dd9d2c3a62d619c9acb38f20d5aa5::toast.index','data' => ['position' => 'bottom end']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?> -withName('flux::toast'); ?> -shouldRender()): ?> -startComponent($component->resolveView(), $component->data()); ?> - -except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?> - -withAttributes(['position' => 'bottom end']); ?> -renderComponent(); ?> - - - - - - - - - -
    - - -make('hub::admin.components.developer-bar', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?> - - -forceAssetInjection(); ?> -scripts(); ?> - - -yieldPushContent('scripts'); ?> - - - - - \ No newline at end of file diff --git a/storage/framework/views/c6c667e57d02e625aa1153d61d340928.php b/storage/framework/views/c6c667e57d02e625aa1153d61d340928.php deleted file mode 100644 index f764413..0000000 --- a/storage/framework/views/c6c667e57d02e625aa1153d61d340928.php +++ /dev/null @@ -1,135 +0,0 @@ -
    - - - - -
    -
    -

    -
    -
    - addLoop($__currentLoopData); foreach($__currentLoopData as $slug => $workspace): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?> - - popLoop(); $loop = $__env->getLastLoop(); ?> -
    -
    -
    - - - '8def1252668913628243c4d363bee1ef::icon','data' => ['name' => 'globe']] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?> -withName('core::icon'); ?> -shouldRender()): ?> -startComponent($component->resolveView(), $component->data()); ?> - -except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?> - -withAttributes(['name' => 'globe']); ?> -renderComponent(); ?> - - - - - - - - - - -
    -
    -
    -
    - \ No newline at end of file diff --git a/storage/framework/views/d31e8a81736ff6e85de996c16b4fb5e7.php b/storage/framework/views/d31e8a81736ff6e85de996c16b4fb5e7.php deleted file mode 100644 index 20787e0..0000000 --- a/storage/framework/views/d31e8a81736ff6e85de996c16b4fb5e7.php +++ /dev/null @@ -1,98 +0,0 @@ -
    - -
    -

    Welcome to

    -

    Your application is ready to use.

    -
    - - -
    -
    -
    -
    - - - -
    -
    -

    Users

    -

    -
    -
    -
    - -
    -
    -
    - - - -
    -
    -

    Status

    -

    Active

    -
    -
    -
    - -
    -
    -
    - - - -
    -
    -

    Laravel

    -

    version()); ?>

    -
    -
    -
    -
    - - - - - -
    -

    Logged in as

    -
    -
    - user()->name ?? 'U', 0, 1)); ?> - -
    -
    -

    user()->name ?? 'User'); ?>

    -

    user()->email ?? ''); ?>

    -
    -
    -
    -
    - \ No newline at end of file diff --git a/storage/framework/views/e251b27fa9ac66156a5dfe64a95c98b5.php b/storage/framework/views/e251b27fa9ac66156a5dfe64a95c98b5.php deleted file mode 100644 index b1902a0..0000000 --- a/storage/framework/views/e251b27fa9ac66156a5dfe64a95c98b5.php +++ /dev/null @@ -1,11 +0,0 @@ - - - \ No newline at end of file diff --git a/storage/framework/views/f43a486913a14930ed1266fa70fdefb3.php b/storage/framework/views/f43a486913a14930ed1266fa70fdefb3.php deleted file mode 100644 index 3ebaab9..0000000 --- a/storage/framework/views/f43a486913a14930ed1266fa70fdefb3.php +++ /dev/null @@ -1,194 +0,0 @@ - - -pluck('icon:trailing'); ?> -pluck('icon:variant'); ?> - - 'micro', - 'iconTrailing' => null, - 'variant' => null, - 'color' => null, - 'inset' => null, - 'size' => null, - 'icon' => null, -])); - -foreach ($attributes->all() as $__key => $__value) { - if (in_array($__key, $__propNames)) { - $$__key = $$__key ?? $__value; - } else { - $__newAttributes[$__key] = $__value; - } -} - -$attributes = new \Illuminate\View\ComponentAttributeBag($__newAttributes); - -unset($__propNames); -unset($__newAttributes); - -foreach (array_filter(([ - 'iconVariant' => 'micro', - 'iconTrailing' => null, - 'variant' => null, - 'color' => null, - 'inset' => null, - 'size' => null, - 'icon' => null, -]), 'is_string', ARRAY_FILTER_USE_KEY) as $__key => $__value) { - $$__key = $$__key ?? $__value; -} - -$__defined_vars = get_defined_vars(); - -foreach ($attributes->all() as $__key => $__value) { - if (array_key_exists($__key, $__defined_vars)) unset($$__key); -} - -unset($__defined_vars, $__key, $__value); ?> - -add($iconVariant === 'outline' ? 'size-4' : ''); - -$classes = Flux::classes() - ->add('inline-flex items-center font-medium whitespace-nowrap') - ->add($insetClasses) - ->add('[print-color-adjust:exact]') - ->add(match ($size) { - 'lg' => 'text-sm py-1.5 **:data-flux-badge-icon:me-2', - default => 'text-sm py-1 **:data-flux-badge-icon:me-1.5', - 'sm' => 'text-xs py-1 **:data-flux-badge-icon:size-3 **:data-flux-badge-icon:me-1', - }) - ->add(match ($variant) { - 'pill' => 'rounded-full px-3', - default => 'rounded-md px-2', - }) - /** - * We can't compile classes for each color because of variants color to color and Tailwind's JIT compiler. - * We instead need to write out each one by hand. Sorry... - */ - ->add($variant === 'solid' ? match ($color) { - default => 'text-white dark:text-white bg-zinc-600 dark:bg-zinc-600 [&:is(button)]:hover:bg-zinc-700 dark:[button]:hover:bg-zinc-500', - 'red' => 'text-white dark:text-white bg-red-500 dark:bg-red-600 [&:is(button)]:hover:bg-red-600 dark:[button]:hover:bg-red-500', - 'orange' => 'text-white dark:text-white bg-orange-500 dark:bg-orange-600 [&:is(button)]:hover:bg-orange-600 dark:[button]:hover:bg-orange-500', - 'amber' => 'text-white dark:text-zinc-950 bg-amber-500 dark:bg-amber-500 [&:is(button)]:hover:bg-amber-600 dark:[button]:hover:bg-amber-400', - 'yellow' => 'text-white dark:text-zinc-950 bg-yellow-500 dark:bg-yellow-400 [&:is(button)]:hover:bg-yellow-600 dark:[button]:hover:bg-yellow-300', - 'lime' => 'text-white dark:text-white bg-lime-500 dark:bg-lime-600 [&:is(button)]:hover:bg-lime-600 dark:[button]:hover:bg-lime-500', - 'green' => 'text-white dark:text-white bg-green-500 dark:bg-green-600 [&:is(button)]:hover:bg-green-600 dark:[button]:hover:bg-green-500', - 'emerald' => 'text-white dark:text-white bg-emerald-500 dark:bg-emerald-600 [&:is(button)]:hover:bg-emerald-600 dark:[button]:hover:bg-emerald-500', - 'teal' => 'text-white dark:text-white bg-teal-500 dark:bg-teal-600 [&:is(button)]:hover:bg-teal-600 dark:[button]:hover:bg-teal-500', - 'cyan' => 'text-white dark:text-white bg-cyan-500 dark:bg-cyan-600 [&:is(button)]:hover:bg-cyan-600 dark:[button]:hover:bg-cyan-500', - 'sky' => 'text-white dark:text-white bg-sky-500 dark:bg-sky-600 [&:is(button)]:hover:bg-sky-600 dark:[button]:hover:bg-sky-500', - 'blue' => 'text-white dark:text-white bg-blue-500 dark:bg-blue-600 [&:is(button)]:hover:bg-blue-600 dark:[button]:hover:bg-blue-500', - 'indigo' => 'text-white dark:text-white bg-indigo-500 dark:bg-indigo-600 [&:is(button)]:hover:bg-indigo-600 dark:[button]:hover:bg-indigo-500', - 'violet' => 'text-white dark:text-white bg-violet-500 dark:bg-violet-600 [&:is(button)]:hover:bg-violet-600 dark:[button]:hover:bg-violet-500', - 'purple' => 'text-white dark:text-white bg-purple-500 dark:bg-purple-600 [&:is(button)]:hover:bg-purple-600 dark:[button]:hover:bg-purple-500', - 'fuchsia' => 'text-white dark:text-white bg-fuchsia-500 dark:bg-fuchsia-600 [&:is(button)]:hover:bg-fuchsia-600 dark:[button]:hover:bg-fuchsia-500', - 'pink' => 'text-white dark:text-white bg-pink-500 dark:bg-pink-600 [&:is(button)]:hover:bg-pink-600 dark:[button]:hover:bg-pink-500', - 'rose' => 'text-white dark:text-white bg-rose-500 dark:bg-rose-600 [&:is(button)]:hover:bg-rose-600 dark:[button]:hover:bg-rose-500', - } : match ($color) { - default => 'text-zinc-700 [&_button]:text-zinc-700! dark:text-zinc-200 dark:[&_button]:text-zinc-200! bg-zinc-400/15 dark:bg-zinc-400/40 [&:is(button)]:hover:bg-zinc-400/25 dark:[button]:hover:bg-zinc-400/50', - 'red' => 'text-red-700 [&_button]:text-red-700! dark:text-red-200 dark:[&_button]:text-red-200! bg-red-400/20 dark:bg-red-400/40 [&:is(button)]:hover:bg-red-400/30 dark:[button]:hover:bg-red-400/50', - 'orange' => 'text-orange-700 [&_button]:text-orange-700! dark:text-orange-200 dark:[&_button]:text-orange-200! bg-orange-400/20 dark:bg-orange-400/40 [&:is(button)]:hover:bg-orange-400/30 dark:[button]:hover:bg-orange-400/50', - 'amber' => 'text-amber-700 [&_button]:text-amber-700! dark:text-amber-200 dark:[&_button]:text-amber-200! bg-amber-400/25 dark:bg-amber-400/40 [&:is(button)]:hover:bg-amber-400/40 dark:[button]:hover:bg-amber-400/50', - 'yellow' => 'text-yellow-800 [&_button]:text-yellow-800! dark:text-yellow-200 dark:[&_button]:text-yellow-200! bg-yellow-400/25 dark:bg-yellow-400/40 [&:is(button)]:hover:bg-yellow-400/40 dark:[button]:hover:bg-yellow-400/50', - 'lime' => 'text-lime-800 [&_button]:text-lime-800! dark:text-lime-200 dark:[&_button]:text-lime-200! bg-lime-400/25 dark:bg-lime-400/40 [&:is(button)]:hover:bg-lime-400/35 dark:[button]:hover:bg-lime-400/50', - 'green' => 'text-green-800 [&_button]:text-green-800! dark:text-green-200 dark:[&_button]:text-green-200! bg-green-400/20 dark:bg-green-400/40 [&:is(button)]:hover:bg-green-400/30 dark:[button]:hover:bg-green-400/50', - 'emerald' => 'text-emerald-800 [&_button]:text-emerald-800! dark:text-emerald-200 dark:[&_button]:text-emerald-200! bg-emerald-400/20 dark:bg-emerald-400/40 [&:is(button)]:hover:bg-emerald-400/30 dark:[button]:hover:bg-emerald-400/50', - 'teal' => 'text-teal-800 [&_button]:text-teal-800! dark:text-teal-200 dark:[&_button]:text-teal-200! bg-teal-400/20 dark:bg-teal-400/40 [&:is(button)]:hover:bg-teal-400/30 dark:[button]:hover:bg-teal-400/50', - 'cyan' => 'text-cyan-800 [&_button]:text-cyan-800! dark:text-cyan-200 dark:[&_button]:text-cyan-200! bg-cyan-400/20 dark:bg-cyan-400/40 [&:is(button)]:hover:bg-cyan-400/30 dark:[button]:hover:bg-cyan-400/50', - 'sky' => 'text-sky-800 [&_button]:text-sky-800! dark:text-sky-200 dark:[&_button]:text-sky-200! bg-sky-400/20 dark:bg-sky-400/40 [&:is(button)]:hover:bg-sky-400/30 dark:[button]:hover:bg-sky-400/50', - 'blue' => 'text-blue-800 [&_button]:text-blue-800! dark:text-blue-200 dark:[&_button]:text-blue-200! bg-blue-400/20 dark:bg-blue-400/40 [&:is(button)]:hover:bg-blue-400/30 dark:[button]:hover:bg-blue-400/50', - 'indigo' => 'text-indigo-700 [&_button]:text-indigo-700! dark:text-indigo-200 dark:[&_button]:text-indigo-200! bg-indigo-400/20 dark:bg-indigo-400/40 [&:is(button)]:hover:bg-indigo-400/30 dark:[button]:hover:bg-indigo-400/50', - 'violet' => 'text-violet-700 [&_button]:text-violet-700! dark:text-violet-200 dark:[&_button]:text-violet-200! bg-violet-400/20 dark:bg-violet-400/40 [&:is(button)]:hover:bg-violet-400/30 dark:[button]:hover:bg-violet-400/50', - 'purple' => 'text-purple-700 [&_button]:text-purple-700! dark:text-purple-200 dark:[&_button]:text-purple-200! bg-purple-400/20 dark:bg-purple-400/40 [&:is(button)]:hover:bg-purple-400/30 dark:[button]:hover:bg-purple-400/50', - 'fuchsia' => 'text-fuchsia-700 [&_button]:text-fuchsia-700! dark:text-fuchsia-200 dark:[&_button]:text-fuchsia-200! bg-fuchsia-400/20 dark:bg-fuchsia-400/40 [&:is(button)]:hover:bg-fuchsia-400/30 dark:[button]:hover:bg-fuchsia-400/50', - 'pink' => 'text-pink-700 [&_button]:text-pink-700! dark:text-pink-200 dark:[&_button]:text-pink-200! bg-pink-400/20 dark:bg-pink-400/40 [&:is(button)]:hover:bg-pink-400/30 dark:[button]:hover:bg-pink-400/50', - 'rose' => 'text-rose-700 [&_button]:text-rose-700! dark:text-rose-200 dark:[&_button]:text-rose-200! bg-rose-400/20 dark:bg-rose-400/40 [&:is(button)]:hover:bg-rose-400/30 dark:[button]:hover:bg-rose-400/50', - }); -?> - - - - 'e60dd9d2c3a62d619c9acb38f20d5aa5::button-or-div','data' => ['attributes' => $attributes->class($classes),'dataFluxBadge' => true]] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?> -withName('flux::button-or-div'); ?> -shouldRender()): ?> -startComponent($component->resolveView(), $component->data()); ?> - -except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?> - -withAttributes(['attributes' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($attributes->class($classes)),'data-flux-badge' => true]); ?> - - - - 'e60dd9d2c3a62d619c9acb38f20d5aa5::icon.index','data' => ['icon' => $icon,'variant' => $iconVariant,'class' => $iconClasses,'dataFluxBadgeIcon' => true]] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?> -withName('flux::icon'); ?> -shouldRender()): ?> -startComponent($component->resolveView(), $component->data()); ?> - -except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?> - -withAttributes(['icon' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($icon),'variant' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($iconVariant),'class' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($iconClasses),'data-flux-badge-icon' => true]); ?> -renderComponent(); ?> - - - - - - - - - - - - - - - - - - -
    - - - - 'e60dd9d2c3a62d619c9acb38f20d5aa5::icon.index','data' => ['icon' => $iconTrailing,'variant' => $iconVariant,'class' => $iconClasses]] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?> -withName('flux::icon'); ?> -shouldRender()): ?> -startComponent($component->resolveView(), $component->data()); ?> - -except(\Illuminate\View\AnonymousComponent::ignoredParameterNames()); ?> - -withAttributes(['icon' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($iconTrailing),'variant' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($iconVariant),'class' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute($iconClasses)]); ?> -renderComponent(); ?> - - - - - - - - - - - - - -
    - - renderComponent(); ?> - - - - - - - - - - \ No newline at end of file