diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6e3c72..1487778 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ Before creating bug reports, please check the existing issues to avoid duplicate ### Security Vulnerabilities -**DO NOT** open public issues for security vulnerabilities. Instead, email security concerns to: **dev@host.uk.com** +**DO NOT** open public issues for security vulnerabilities. Instead, email security concerns to: **support@host.uk.com** We take security seriously and will respond promptly to valid security reports. @@ -282,6 +282,6 @@ If you have questions about contributing, feel free to: - Open a **GitHub Discussion** - Create an **issue** labeled "question" -- Email **dev@host.uk.com** +- Email **support@host.uk.com** Thank you for contributing! 🎉 diff --git a/ROADMAP.md b/ROADMAP.md index 0c3ddcf..f84b2d4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -195,7 +195,7 @@ Sponsor development of specific features: - **Silver ($2,000-$4,999)** - Choose a medium feature from v1.x - **Bronze ($500-$1,999)** - Choose a small feature or bug fix -Contact: dev@host.uk.com +Contact: support@host.uk.com --- diff --git a/SECURITY.md b/SECURITY.md index 86a134c..26e576b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,7 +11,7 @@ **Please do not report security vulnerabilities through public GitHub issues.** -Instead, please report them via email to: **dev@host.uk.com** +Instead, please report them via email to: **support@host.uk.com** You should receive a response within 48 hours. If for some reason you do not, please follow up via email to ensure we received your original message. @@ -160,7 +160,7 @@ For sensitive security reports, you may encrypt your message using our PGP key: ## Contact -- **Security Email:** dev@host.uk.com +- **Security Email:** support@host.uk.com - **General Support:** https://github.com/host-uk/core-php/discussions - **GitHub Security Advisories:** https://github.com/host-uk/core-php/security/advisories diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 875f0a5..48b00ea 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -147,4 +147,4 @@ $order = CreateOrder::run($user, $validated); - 📖 [Documentation](https://docs.example.com) - 💬 [GitHub Discussions](https://github.com/host-uk/core-php/discussions) - 🐛 [Issue Tracker](https://github.com/host-uk/core-php/issues) -- 📧 [Email Support](mailto:dev@host.uk.com) +- 📧 [Email Support](mailto:support@host.uk.com) diff --git a/docs/security/changelog.md b/docs/security/changelog.md index 20e69c7..4f13caa 100644 --- a/docs/security/changelog.md +++ b/docs/security/changelog.md @@ -120,7 +120,7 @@ This page documents all security-related changes, fixes, and improvements to Cor If you discover a security vulnerability, please follow our [Responsible Disclosure](/security/responsible-disclosure) policy. -**Contact:** dev@host.uk.com +**Contact:** support@host.uk.com ## Security Update Policy diff --git a/docs/security/overview.md b/docs/security/overview.md index ecb224a..e124e52 100644 --- a/docs/security/overview.md +++ b/docs/security/overview.md @@ -567,7 +567,7 @@ composer update If you discover a security vulnerability, please email: -**dev@host.uk.com** +**support@host.uk.com** Do not create public GitHub issues for security vulnerabilities. diff --git a/docs/security/responsible-disclosure.md b/docs/security/responsible-disclosure.md index bd8ebcb..3722791 100644 --- a/docs/security/responsible-disclosure.md +++ b/docs/security/responsible-disclosure.md @@ -4,7 +4,7 @@ We take the security of Core PHP Framework seriously. If you believe you have fo ## Reporting a Vulnerability -**Email:** dev@host.uk.com +**Email:** support@host.uk.com **PGP Key:** Available on request @@ -158,7 +158,7 @@ https://docs.core-php.dev/security/responsible-disclosure ## Contact -For security issues: dev@host.uk.com +For security issues: support@host.uk.com For general inquiries: https://github.com/host-uk/core-php/issues diff --git a/packages/core-api/src/Mod/Api/Controllers/McpApiController.php b/packages/core-api/src/Mod/Api/Controllers/McpApiController.php index 9aff02b..b980e51 100644 --- a/packages/core-api/src/Mod/Api/Controllers/McpApiController.php +++ b/packages/core-api/src/Mod/Api/Controllers/McpApiController.php @@ -5,10 +5,12 @@ declare(strict_types=1); namespace Core\Mod\Api\Controllers; use Core\Front\Controller; +use Core\Mod\Api\Models\ApiKey; use Core\Mod\Mcp\Models\McpApiRequest; use Core\Mod\Mcp\Models\McpToolCall; +use Core\Mod\Mcp\Models\McpToolVersion; use Core\Mod\Mcp\Services\McpWebhookDispatcher; -use Core\Mod\Api\Models\ApiKey; +use Core\Mod\Mcp\Services\ToolVersionService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; @@ -61,6 +63,9 @@ class McpApiController extends Controller * List tools for a specific server. * * GET /api/v1/mcp/servers/{id}/tools + * + * Query params: + * - include_versions: bool - include version info for each tool */ public function tools(Request $request, string $id): JsonResponse { @@ -70,10 +75,35 @@ class McpApiController extends Controller return response()->json(['error' => 'Server not found'], 404); } + $tools = $server['tools'] ?? []; + $includeVersions = $request->boolean('include_versions', false); + + // Optionally enrich tools with version information + if ($includeVersions) { + $versionService = app(ToolVersionService::class); + $tools = collect($tools)->map(function ($tool) use ($id, $versionService) { + $toolName = $tool['name'] ?? ''; + $latestVersion = $versionService->getLatestVersion($id, $toolName); + + $tool['versioning'] = [ + 'latest_version' => $latestVersion?->version ?? ToolVersionService::DEFAULT_VERSION, + 'is_versioned' => $latestVersion !== null, + 'deprecated' => $latestVersion?->is_deprecated ?? false, + ]; + + // If version exists, use its schema (may differ from YAML) + if ($latestVersion?->input_schema) { + $tool['inputSchema'] = $latestVersion->input_schema; + } + + return $tool; + })->all(); + } + return response()->json([ 'server' => $id, - 'tools' => $server['tools'] ?? [], - 'count' => count($server['tools'] ?? []), + 'tools' => $tools, + 'count' => count($tools), ]); } @@ -81,13 +111,20 @@ class McpApiController extends Controller * Execute a tool on an MCP server. * * POST /api/v1/mcp/tools/call + * + * Request body: + * - server: string (required) + * - tool: string (required) + * - arguments: array (optional) + * - version: string (optional) - semver version to use, defaults to latest */ public function callTool(Request $request): JsonResponse { $validated = $request->validate([ - 'server' => 'required|string', - 'tool' => 'required|string', + 'server' => 'required|string|max:64', + 'tool' => 'required|string|max:128', 'arguments' => 'nullable|array', + 'version' => 'nullable|string|max:32', ]); $server = $this->loadServerFull($validated['server']); @@ -95,12 +132,64 @@ class McpApiController extends Controller return response()->json(['error' => 'Server not found'], 404); } - // Verify tool exists + // Verify tool exists in server definition $toolDef = collect($server['tools'] ?? [])->firstWhere('name', $validated['tool']); if (! $toolDef) { return response()->json(['error' => 'Tool not found'], 404); } + // Version resolution + $versionService = app(ToolVersionService::class); + $versionResult = $versionService->resolveVersion( + $validated['server'], + $validated['tool'], + $validated['version'] ?? null + ); + + // If version was requested but is sunset, block the call + if ($versionResult['error']) { + $error = $versionResult['error']; + + // Sunset versions return 410 Gone + $status = ($error['code'] ?? '') === 'TOOL_VERSION_SUNSET' ? 410 : 400; + + return response()->json([ + 'success' => false, + 'error' => $error['message'] ?? 'Version error', + 'error_code' => $error['code'] ?? 'VERSION_ERROR', + 'server' => $validated['server'], + 'tool' => $validated['tool'], + 'requested_version' => $validated['version'] ?? null, + 'latest_version' => $error['latest_version'] ?? null, + 'migration_notes' => $error['migration_notes'] ?? null, + ], $status); + } + + /** @var McpToolVersion|null $toolVersion */ + $toolVersion = $versionResult['version']; + $deprecationWarning = $versionResult['warning']; + + // Use versioned schema if available for validation + $schemaForValidation = $toolVersion?->input_schema ?? $toolDef['inputSchema'] ?? null; + if ($schemaForValidation) { + $validationErrors = $this->validateToolArguments( + ['inputSchema' => $schemaForValidation], + $validated['arguments'] ?? [] + ); + + if (! empty($validationErrors)) { + return response()->json([ + 'success' => false, + 'error' => 'Validation failed', + 'error_code' => 'VALIDATION_ERROR', + 'validation_errors' => $validationErrors, + 'server' => $validated['server'], + 'tool' => $validated['tool'], + 'version' => $toolVersion?->version ?? 'unversioned', + ], 422); + } + } + // Get API key for logging $apiKey = $request->attributes->get('api_key'); $workspace = $apiKey?->workspace; @@ -127,14 +216,33 @@ class McpApiController extends Controller 'success' => true, 'server' => $validated['server'], 'tool' => $validated['tool'], + 'version' => $toolVersion?->version ?? ToolVersionService::DEFAULT_VERSION, 'result' => $result, 'duration_ms' => $durationMs, ]; + // Include deprecation warning if applicable + if ($deprecationWarning) { + $response['_warnings'] = [$deprecationWarning]; + } + // Log full request for debugging/replay $this->logApiRequest($request, $validated, 200, $response, $durationMs, $apiKey); - return response()->json($response); + // Build response with deprecation headers if needed + $jsonResponse = response()->json($response); + + if ($deprecationWarning) { + $jsonResponse->header('X-MCP-Deprecation-Warning', $deprecationWarning['message'] ?? 'Version deprecated'); + if (isset($deprecationWarning['sunset_at'])) { + $jsonResponse->header('X-MCP-Sunset-At', $deprecationWarning['sunset_at']); + } + if (isset($deprecationWarning['latest_version'])) { + $jsonResponse->header('X-MCP-Latest-Version', $deprecationWarning['latest_version']); + } + } + + return $jsonResponse; } catch (\Throwable $e) { $durationMs = (int) ((microtime(true) - $startTime) * 1000); @@ -148,6 +256,7 @@ class McpApiController extends Controller 'error' => $e->getMessage(), 'server' => $validated['server'], 'tool' => $validated['tool'], + 'version' => $toolVersion?->version ?? ToolVersionService::DEFAULT_VERSION, ]; // Log full request for debugging/replay @@ -157,6 +266,130 @@ class McpApiController extends Controller } } + /** + * Validate tool arguments against a JSON schema. + * + * @return array Validation error messages + */ + protected function validateToolArguments(array $toolDef, array $arguments): array + { + $inputSchema = $toolDef['inputSchema'] ?? null; + + if (! $inputSchema || ! is_array($inputSchema)) { + return []; + } + + $errors = []; + $properties = $inputSchema['properties'] ?? []; + $required = $inputSchema['required'] ?? []; + + // Check required properties + foreach ($required as $requiredProp) { + if (! array_key_exists($requiredProp, $arguments)) { + $errors[] = "Missing required argument: {$requiredProp}"; + } + } + + // Type validation for provided arguments + foreach ($arguments as $key => $value) { + if (! isset($properties[$key])) { + if (($inputSchema['additionalProperties'] ?? true) === false) { + $errors[] = "Unknown argument: {$key}"; + } + + continue; + } + + $propSchema = $properties[$key]; + $expectedType = $propSchema['type'] ?? null; + + if ($expectedType && ! $this->validateType($value, $expectedType)) { + $errors[] = "Argument '{$key}' must be of type {$expectedType}"; + } + + // Validate enum values + if (isset($propSchema['enum']) && ! in_array($value, $propSchema['enum'], true)) { + $allowedValues = implode(', ', $propSchema['enum']); + $errors[] = "Argument '{$key}' must be one of: {$allowedValues}"; + } + } + + return $errors; + } + + /** + * Validate a value against a JSON Schema type. + */ + protected function validateType(mixed $value, string $type): bool + { + return match ($type) { + 'string' => is_string($value), + 'integer' => is_int($value) || (is_numeric($value) && floor((float) $value) == $value), + 'number' => is_numeric($value), + 'boolean' => is_bool($value), + 'array' => is_array($value) && array_is_list($value), + 'object' => is_array($value) && ! array_is_list($value), + 'null' => is_null($value), + default => true, + }; + } + + /** + * Get version history for a specific tool. + * + * GET /api/v1/mcp/servers/{server}/tools/{tool}/versions + */ + public function toolVersions(Request $request, string $server, string $tool): JsonResponse + { + $serverConfig = $this->loadServerFull($server); + if (! $serverConfig) { + return response()->json(['error' => 'Server not found'], 404); + } + + // Verify tool exists in server definition + $toolDef = collect($serverConfig['tools'] ?? [])->firstWhere('name', $tool); + if (! $toolDef) { + return response()->json(['error' => 'Tool not found'], 404); + } + + $versionService = app(ToolVersionService::class); + $versions = $versionService->getVersionHistory($server, $tool); + + return response()->json([ + 'server' => $server, + 'tool' => $tool, + 'versions' => $versions->map(fn (McpToolVersion $v) => $v->toApiArray())->values(), + 'count' => $versions->count(), + ]); + } + + /** + * Get a specific version of a tool. + * + * GET /api/v1/mcp/servers/{server}/tools/{tool}/versions/{version} + */ + public function toolVersion(Request $request, string $server, string $tool, string $version): JsonResponse + { + $versionService = app(ToolVersionService::class); + $toolVersion = $versionService->getToolAtVersion($server, $tool, $version); + + if (! $toolVersion) { + return response()->json(['error' => 'Version not found'], 404); + } + + $response = response()->json($toolVersion->toApiArray()); + + // Add deprecation headers if applicable + if ($deprecationWarning = $toolVersion->getDeprecationWarning()) { + $response->header('X-MCP-Deprecation-Warning', $deprecationWarning['message'] ?? 'Version deprecated'); + if (isset($deprecationWarning['sunset_at'])) { + $response->header('X-MCP-Sunset-At', $deprecationWarning['sunset_at']); + } + } + + return $response; + } + /** * Read a resource from an MCP server. * diff --git a/packages/core-api/src/Mod/Api/Routes/api.php b/packages/core-api/src/Mod/Api/Routes/api.php index 29898fb..2190728 100644 --- a/packages/core-api/src/Mod/Api/Routes/api.php +++ b/packages/core-api/src/Mod/Api/Routes/api.php @@ -2,12 +2,12 @@ declare(strict_types=1); -use Illuminate\Support\Facades\Route; use Core\Mod\Api\Controllers\EntitlementApiController; use Core\Mod\Api\Controllers\McpApiController; use Core\Mod\Api\Controllers\SeoReportController; use Core\Mod\Api\Controllers\UnifiedPixelController; use Core\Mod\Mcp\Middleware\McpApiKeyAuth; +use Illuminate\Support\Facades\Route; /* |-------------------------------------------------------------------------- @@ -84,6 +84,14 @@ Route::middleware(['throttle:120,1', McpApiKeyAuth::class, 'api.scope.enforce']) Route::get('/servers/{id}/tools', [McpApiController::class, 'tools']) ->name('servers.tools'); + // Tool version history (read) + Route::get('/servers/{server}/tools/{tool}/versions', [McpApiController::class, 'toolVersions']) + ->name('tools.versions'); + + // Specific tool version (read) + Route::get('/servers/{server}/tools/{tool}/versions/{version}', [McpApiController::class, 'toolVersion']) + ->name('tools.version'); + // Tool execution (write) Route::post('/tools/call', [McpApiController::class, 'callTool']) ->name('tools.call'); diff --git a/packages/core-mcp/src/Mod/Mcp/Boot.php b/packages/core-mcp/src/Mod/Mcp/Boot.php index 36da2cb..afc6a89 100644 --- a/packages/core-mcp/src/Mod/Mcp/Boot.php +++ b/packages/core-mcp/src/Mod/Mcp/Boot.php @@ -14,6 +14,7 @@ use Core\Mod\Mcp\Services\McpQuotaService; use Core\Mod\Mcp\Services\ToolAnalyticsService; use Core\Mod\Mcp\Services\ToolDependencyService; use Core\Mod\Mcp\Services\ToolRegistry; +use Core\Mod\Mcp\Services\ToolVersionService; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; @@ -45,6 +46,7 @@ class Boot extends ServiceProvider $this->app->singleton(McpQuotaService::class); $this->app->singleton(ToolDependencyService::class); $this->app->singleton(AuditLogService::class); + $this->app->singleton(ToolVersionService::class); } /** @@ -78,6 +80,7 @@ class Boot extends ServiceProvider $event->livewire('mcp.admin.tool-analytics-detail', View\Modal\Admin\ToolAnalyticsDetail::class); $event->livewire('mcp.admin.quota-usage', View\Modal\Admin\QuotaUsage::class); $event->livewire('mcp.admin.audit-log-viewer', View\Modal\Admin\AuditLogViewer::class); + $event->livewire('mcp.admin.tool-version-manager', View\Modal\Admin\ToolVersionManager::class); } public function onConsole(ConsoleBooting $event): void diff --git a/packages/core-mcp/src/Mod/Mcp/Routes/admin.php b/packages/core-mcp/src/Mod/Mcp/Routes/admin.php index db3044d..251c438 100644 --- a/packages/core-mcp/src/Mod/Mcp/Routes/admin.php +++ b/packages/core-mcp/src/Mod/Mcp/Routes/admin.php @@ -4,9 +4,11 @@ use Core\Mod\Mcp\View\Modal\Admin\ApiKeyManager; use Core\Mod\Mcp\View\Modal\Admin\AuditLogViewer; use Core\Mod\Mcp\View\Modal\Admin\McpPlayground; use Core\Mod\Mcp\View\Modal\Admin\Playground; +use Core\Mod\Mcp\View\Modal\Admin\QuotaUsage; use Core\Mod\Mcp\View\Modal\Admin\RequestLog; use Core\Mod\Mcp\View\Modal\Admin\ToolAnalyticsDashboard; use Core\Mod\Mcp\View\Modal\Admin\ToolAnalyticsDetail; +use Core\Mod\Mcp\View\Modal\Admin\ToolVersionManager; use Core\Website\Mcp\Controllers\McpRegistryController; use Core\Website\Mcp\View\Modal\Dashboard; use Illuminate\Support\Facades\Route; @@ -57,4 +59,12 @@ Route::prefix('mcp')->name('mcp.')->group(function () { // Audit log viewer (compliance and security) Route::get('audit-log', AuditLogViewer::class) ->name('audit-log'); + + // Tool version management (Hades only) + Route::get('versions', ToolVersionManager::class) + ->name('versions'); + + // Quota usage overview + Route::get('quotas', QuotaUsage::class) + ->name('quotas'); }); diff --git a/packages/core-mcp/src/Mod/Mcp/Services/ToolRegistry.php b/packages/core-mcp/src/Mod/Mcp/Services/ToolRegistry.php index dff75b0..5738007 100644 --- a/packages/core-mcp/src/Mod/Mcp/Services/ToolRegistry.php +++ b/packages/core-mcp/src/Mod/Mcp/Services/ToolRegistry.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Core\Mod\Mcp\Services; +use Core\Mod\Mcp\Models\McpToolVersion; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Symfony\Component\Yaml\Yaml; @@ -71,11 +72,15 @@ class ToolRegistry /** * Get all tools for a specific server. * - * @return Collection + * @return Collection */ - public function getToolsForServer(string $serverId): Collection + public function getToolsForServer(string $serverId, bool $includeVersionInfo = false): Collection { - return Cache::remember("mcp:playground:tools:{$serverId}", self::CACHE_TTL, function () use ($serverId) { + $cacheKey = $includeVersionInfo + ? "mcp:playground:tools:{$serverId}:versioned" + : "mcp:playground:tools:{$serverId}"; + + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($serverId, $includeVersionInfo) { $server = $this->loadServerFull($serverId); if (! $server) { @@ -83,16 +88,40 @@ class ToolRegistry } return collect($server['tools'] ?? []) - ->map(function ($tool) { + ->map(function ($tool) use ($serverId, $includeVersionInfo) { $name = $tool['name']; + $baseVersion = $tool['version'] ?? ToolVersionService::DEFAULT_VERSION; - return [ + $result = [ 'name' => $name, 'description' => $tool['description'] ?? $tool['purpose'] ?? '', 'category' => $this->extractCategory($tool), 'inputSchema' => $tool['inputSchema'] ?? ['type' => 'object', 'properties' => $tool['parameters'] ?? []], 'examples' => $this->examples[$name] ?? $this->generateExampleFromSchema($tool['inputSchema'] ?? []), + 'version' => $baseVersion, ]; + + // Optionally enrich with database version info + if ($includeVersionInfo) { + $latestVersion = McpToolVersion::forServer($serverId) + ->forTool($name) + ->latest() + ->first(); + + if ($latestVersion) { + $result['version'] = $latestVersion->version; + $result['version_status'] = $latestVersion->status; + $result['is_deprecated'] = $latestVersion->is_deprecated; + $result['sunset_at'] = $latestVersion->sunset_at?->toIso8601String(); + + // Use versioned schema if available + if ($latestVersion->input_schema) { + $result['inputSchema'] = $latestVersion->input_schema; + } + } + } + + return $result; }) ->values(); }); diff --git a/packages/core-mcp/src/Mod/Mcp/Services/ToolVersionService.php b/packages/core-mcp/src/Mod/Mcp/Services/ToolVersionService.php new file mode 100644 index 0000000..83ee630 --- /dev/null +++ b/packages/core-mcp/src/Mod/Mcp/Services/ToolVersionService.php @@ -0,0 +1,478 @@ +isValidSemver($version)) { + throw new \InvalidArgumentException("Invalid semver version: {$version}"); + } + + // Check if version already exists + $existing = McpToolVersion::forServer($serverId) + ->forTool($toolName) + ->forVersion($version) + ->first(); + + if ($existing) { + // Update existing version + $existing->update([ + 'input_schema' => $inputSchema ?? $existing->input_schema, + 'output_schema' => $outputSchema ?? $existing->output_schema, + 'description' => $description ?? $existing->description, + 'changelog' => $options['changelog'] ?? $existing->changelog, + 'migration_notes' => $options['migration_notes'] ?? $existing->migration_notes, + ]); + + if ($options['mark_latest'] ?? false) { + $existing->markAsLatest(); + } + + $this->clearCache($serverId, $toolName); + + return $existing->fresh(); + } + + // Create new version + $toolVersion = McpToolVersion::create([ + 'server_id' => $serverId, + 'tool_name' => $toolName, + 'version' => $version, + 'input_schema' => $inputSchema, + 'output_schema' => $outputSchema, + 'description' => $description, + 'changelog' => $options['changelog'] ?? null, + 'migration_notes' => $options['migration_notes'] ?? null, + 'is_latest' => false, + ]); + + // Mark as latest if requested or if it's the first version + $isFirst = McpToolVersion::forServer($serverId)->forTool($toolName)->count() === 1; + + if (($options['mark_latest'] ?? false) || $isFirst) { + $toolVersion->markAsLatest(); + } + + $this->clearCache($serverId, $toolName); + + Log::info('MCP tool version registered', [ + 'server_id' => $serverId, + 'tool_name' => $toolName, + 'version' => $version, + 'is_latest' => $toolVersion->is_latest, + ]); + + return $toolVersion; + } + + /** + * Get a tool at a specific version. + * + * Returns null if version doesn't exist. Use getLatestVersion() for fallback. + */ + public function getToolAtVersion(string $serverId, string $toolName, string $version): ?McpToolVersion + { + $cacheKey = self::CACHE_PREFIX."{$serverId}:{$toolName}:{$version}"; + + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($serverId, $toolName, $version) { + return McpToolVersion::forServer($serverId) + ->forTool($toolName) + ->forVersion($version) + ->first(); + }); + } + + /** + * Get the latest version of a tool. + */ + public function getLatestVersion(string $serverId, string $toolName): ?McpToolVersion + { + $cacheKey = self::CACHE_PREFIX."{$serverId}:{$toolName}:latest"; + + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($serverId, $toolName) { + // First try to find explicitly marked latest + $latest = McpToolVersion::forServer($serverId) + ->forTool($toolName) + ->latest() + ->first(); + + if ($latest) { + return $latest; + } + + // Fallback to newest version by semver + return McpToolVersion::forServer($serverId) + ->forTool($toolName) + ->active() + ->orderByVersion('desc') + ->first(); + }); + } + + /** + * Resolve a tool version, falling back to latest if not specified. + * + * @return array{version: McpToolVersion|null, warning: array|null, error: array|null} + */ + public function resolveVersion(string $serverId, string $toolName, ?string $requestedVersion = null): array + { + // If no version requested, use latest + if ($requestedVersion === null) { + $version = $this->getLatestVersion($serverId, $toolName); + + return [ + 'version' => $version, + 'warning' => null, + 'error' => $version === null ? [ + 'code' => 'TOOL_NOT_FOUND', + 'message' => "No versions found for tool {$serverId}:{$toolName}", + ] : null, + ]; + } + + // Look up specific version + $version = $this->getToolAtVersion($serverId, $toolName, $requestedVersion); + + if (! $version) { + return [ + 'version' => null, + 'warning' => null, + 'error' => [ + 'code' => 'VERSION_NOT_FOUND', + 'message' => "Version {$requestedVersion} not found for tool {$serverId}:{$toolName}", + ], + ]; + } + + // Check if sunset + if ($version->is_sunset) { + return [ + 'version' => null, + 'warning' => null, + 'error' => $version->getSunsetError(), + ]; + } + + // Check if deprecated (warning, not error) + $warning = $version->getDeprecationWarning(); + + return [ + 'version' => $version, + 'warning' => $warning, + 'error' => null, + ]; + } + + /** + * Check if a version is deprecated. + */ + public function isDeprecated(string $serverId, string $toolName, string $version): bool + { + $toolVersion = $this->getToolAtVersion($serverId, $toolName, $version); + + return $toolVersion?->is_deprecated ?? false; + } + + /** + * Check if a version is sunset (blocked). + */ + public function isSunset(string $serverId, string $toolName, string $version): bool + { + $toolVersion = $this->getToolAtVersion($serverId, $toolName, $version); + + return $toolVersion?->is_sunset ?? false; + } + + /** + * Compare two semver versions. + * + * @return int -1 if $a < $b, 0 if equal, 1 if $a > $b + */ + public function compareVersions(string $a, string $b): int + { + return version_compare( + $this->normalizeSemver($a), + $this->normalizeSemver($b) + ); + } + + /** + * Get version history for a tool. + * + * @return Collection + */ + public function getVersionHistory(string $serverId, string $toolName): Collection + { + return McpToolVersion::forServer($serverId) + ->forTool($toolName) + ->orderByVersion('desc') + ->get(); + } + + /** + * Attempt to migrate a tool call from an old version schema to a new one. + * + * This is a best-effort migration that: + * - Preserves arguments that exist in both schemas + * - Applies defaults for new required arguments where possible + * - Returns warnings for arguments that couldn't be migrated + * + * @return array{arguments: array, warnings: array, success: bool} + */ + public function migrateToolCall( + string $serverId, + string $toolName, + string $fromVersion, + string $toVersion, + array $arguments + ): array { + $fromTool = $this->getToolAtVersion($serverId, $toolName, $fromVersion); + $toTool = $this->getToolAtVersion($serverId, $toolName, $toVersion); + + if (! $fromTool || ! $toTool) { + return [ + 'arguments' => $arguments, + 'warnings' => ['Could not load version schemas for migration'], + 'success' => false, + ]; + } + + $toSchema = $toTool->input_schema ?? []; + $toProperties = $toSchema['properties'] ?? []; + $toRequired = $toSchema['required'] ?? []; + + $migratedArgs = []; + $warnings = []; + + // Copy over arguments that exist in the new schema + foreach ($arguments as $key => $value) { + if (isset($toProperties[$key])) { + $migratedArgs[$key] = $value; + } else { + $warnings[] = "Argument '{$key}' removed in version {$toVersion}"; + } + } + + // Check for new required arguments without defaults + foreach ($toRequired as $requiredKey) { + if (! isset($migratedArgs[$requiredKey])) { + // Try to apply default from schema + if (isset($toProperties[$requiredKey]['default'])) { + $migratedArgs[$requiredKey] = $toProperties[$requiredKey]['default']; + $warnings[] = "Applied default value for new required argument '{$requiredKey}'"; + } else { + $warnings[] = "Missing required argument '{$requiredKey}' added in version {$toVersion}"; + } + } + } + + return [ + 'arguments' => $migratedArgs, + 'warnings' => $warnings, + 'success' => empty(array_filter($warnings, fn ($w) => str_starts_with($w, 'Missing required'))), + ]; + } + + /** + * Deprecate a tool version with optional sunset date. + */ + public function deprecateVersion( + string $serverId, + string $toolName, + string $version, + ?Carbon $sunsetAt = null + ): ?McpToolVersion { + $toolVersion = McpToolVersion::forServer($serverId) + ->forTool($toolName) + ->forVersion($version) + ->first(); + + if (! $toolVersion) { + return null; + } + + $toolVersion->deprecate($sunsetAt); + $this->clearCache($serverId, $toolName); + + Log::info('MCP tool version deprecated', [ + 'server_id' => $serverId, + 'tool_name' => $toolName, + 'version' => $version, + 'sunset_at' => $sunsetAt?->toIso8601String(), + ]); + + return $toolVersion; + } + + /** + * Get all tools with version info for a server. + * + * @return Collection + */ + public function getToolsWithVersions(string $serverId): Collection + { + $versions = McpToolVersion::forServer($serverId) + ->orderByVersion('desc') + ->get(); + + return $versions->groupBy('tool_name') + ->map(function ($toolVersions, $toolName) { + return [ + 'tool_name' => $toolName, + 'latest' => $toolVersions->firstWhere('is_latest', true) ?? $toolVersions->first(), + 'versions' => $toolVersions, + 'version_count' => $toolVersions->count(), + 'has_deprecated' => $toolVersions->contains(fn ($v) => $v->is_deprecated), + 'has_sunset' => $toolVersions->contains(fn ($v) => $v->is_sunset), + ]; + }); + } + + /** + * Get all unique servers that have versioned tools. + */ + public function getServersWithVersions(): Collection + { + return McpToolVersion::select('server_id') + ->distinct() + ->orderBy('server_id') + ->pluck('server_id'); + } + + /** + * Sync tool versions from YAML server definitions. + * + * Call this during deployment to register/update versions from server configs. + * + * @param array $serverConfig Parsed YAML server configuration + * @param string $version Version to register (e.g., from deployment tag) + */ + public function syncFromServerConfig(array $serverConfig, string $version, bool $markLatest = true): int + { + $serverId = $serverConfig['id'] ?? null; + $tools = $serverConfig['tools'] ?? []; + + if (! $serverId || empty($tools)) { + return 0; + } + + $registered = 0; + + foreach ($tools as $tool) { + $toolName = $tool['name'] ?? null; + if (! $toolName) { + continue; + } + + $this->registerVersion( + serverId: $serverId, + toolName: $toolName, + version: $version, + inputSchema: $tool['inputSchema'] ?? null, + outputSchema: $tool['outputSchema'] ?? null, + description: $tool['description'] ?? $tool['purpose'] ?? null, + options: [ + 'mark_latest' => $markLatest, + ] + ); + + $registered++; + } + + return $registered; + } + + /** + * Get statistics about tool versions. + */ + public function getStats(): array + { + return [ + 'total_versions' => McpToolVersion::count(), + 'total_tools' => McpToolVersion::select('server_id', 'tool_name') + ->distinct() + ->count(), + 'deprecated_count' => McpToolVersion::deprecated()->count(), + 'sunset_count' => McpToolVersion::sunset()->count(), + 'servers' => $this->getServersWithVersions()->count(), + ]; + } + + // ------------------------------------------------------------------------- + // Protected Methods + // ------------------------------------------------------------------------- + + /** + * Validate semver format. + */ + protected function isValidSemver(string $version): bool + { + // Basic semver pattern: major.minor.patch with optional prerelease/build + $pattern = '/^(\d+)\.(\d+)\.(\d+)(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/'; + + return (bool) preg_match($pattern, $version); + } + + /** + * Normalize semver for comparison (removes prerelease/build metadata). + */ + protected function normalizeSemver(string $version): string + { + // Remove prerelease and build metadata for basic comparison + return preg_replace('/[-+].*$/', '', $version) ?? $version; + } + + /** + * Clear cache for a tool's versions. + */ + protected function clearCache(string $serverId, string $toolName): void + { + // Clear specific version caches would require tracking all versions + // For simplicity, we use a short TTL and let cache naturally expire + Cache::forget(self::CACHE_PREFIX."{$serverId}:{$toolName}:latest"); + } +} diff --git a/packages/core-mcp/src/Mod/Mcp/Tests/Unit/ToolVersionServiceTest.php b/packages/core-mcp/src/Mod/Mcp/Tests/Unit/ToolVersionServiceTest.php new file mode 100644 index 0000000..caaedd1 --- /dev/null +++ b/packages/core-mcp/src/Mod/Mcp/Tests/Unit/ToolVersionServiceTest.php @@ -0,0 +1,441 @@ +service = new ToolVersionService; + } + + public function test_can_register_new_version(): void + { + $version = $this->service->registerVersion( + serverId: 'test-server', + toolName: 'test_tool', + version: '1.0.0', + inputSchema: ['type' => 'object', 'properties' => ['query' => ['type' => 'string']]], + description: 'A test tool', + options: ['mark_latest' => true] + ); + + $this->assertSame('test-server', $version->server_id); + $this->assertSame('test_tool', $version->tool_name); + $this->assertSame('1.0.0', $version->version); + $this->assertTrue($version->is_latest); + } + + public function test_first_version_is_automatically_latest(): void + { + $version = $this->service->registerVersion( + serverId: 'test-server', + toolName: 'test_tool', + version: '1.0.0', + ); + + $this->assertTrue($version->is_latest); + } + + public function test_can_get_tool_at_specific_version(): void + { + $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $this->service->registerVersion('test-server', 'test_tool', '2.0.0'); + + $v1 = $this->service->getToolAtVersion('test-server', 'test_tool', '1.0.0'); + $v2 = $this->service->getToolAtVersion('test-server', 'test_tool', '2.0.0'); + + $this->assertSame('1.0.0', $v1->version); + $this->assertSame('2.0.0', $v2->version); + } + + public function test_get_latest_version(): void + { + $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $v2 = $this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]); + + $latest = $this->service->getLatestVersion('test-server', 'test_tool'); + + $this->assertSame('2.0.0', $latest->version); + $this->assertTrue($latest->is_latest); + } + + public function test_resolve_version_returns_latest_when_no_version_specified(): void + { + $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]); + + $result = $this->service->resolveVersion('test-server', 'test_tool', null); + + $this->assertNotNull($result['version']); + $this->assertSame('2.0.0', $result['version']->version); + $this->assertNull($result['warning']); + $this->assertNull($result['error']); + } + + public function test_resolve_version_returns_specific_version(): void + { + $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]); + + $result = $this->service->resolveVersion('test-server', 'test_tool', '1.0.0'); + + $this->assertNotNull($result['version']); + $this->assertSame('1.0.0', $result['version']->version); + } + + public function test_resolve_version_returns_error_for_nonexistent_version(): void + { + $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + + $result = $this->service->resolveVersion('test-server', 'test_tool', '9.9.9'); + + $this->assertNull($result['version']); + $this->assertNotNull($result['error']); + $this->assertSame('VERSION_NOT_FOUND', $result['error']['code']); + } + + public function test_resolve_deprecated_version_returns_warning(): void + { + $version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $version->deprecate(Carbon::now()->addDays(30)); + + $this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]); + + $result = $this->service->resolveVersion('test-server', 'test_tool', '1.0.0'); + + $this->assertNotNull($result['version']); + $this->assertNotNull($result['warning']); + $this->assertSame('TOOL_VERSION_DEPRECATED', $result['warning']['code']); + } + + public function test_resolve_sunset_version_returns_error(): void + { + $version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $version->deprecated_at = Carbon::now()->subDays(60); + $version->sunset_at = Carbon::now()->subDays(30); + $version->save(); + + $this->service->registerVersion('test-server', 'test_tool', '2.0.0', options: ['mark_latest' => true]); + + $result = $this->service->resolveVersion('test-server', 'test_tool', '1.0.0'); + + $this->assertNull($result['version']); + $this->assertNotNull($result['error']); + $this->assertSame('TOOL_VERSION_SUNSET', $result['error']['code']); + } + + public function test_is_deprecated(): void + { + $version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $version->deprecate(); + + $this->assertTrue($this->service->isDeprecated('test-server', 'test_tool', '1.0.0')); + } + + public function test_is_sunset(): void + { + $version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $version->deprecated_at = Carbon::now()->subDays(60); + $version->sunset_at = Carbon::now()->subDays(30); + $version->save(); + + $this->assertTrue($this->service->isSunset('test-server', 'test_tool', '1.0.0')); + } + + public function test_compare_versions(): void + { + $this->assertSame(-1, $this->service->compareVersions('1.0.0', '2.0.0')); + $this->assertSame(0, $this->service->compareVersions('1.0.0', '1.0.0')); + $this->assertSame(1, $this->service->compareVersions('2.0.0', '1.0.0')); + $this->assertSame(-1, $this->service->compareVersions('1.0.0', '1.0.1')); + $this->assertSame(-1, $this->service->compareVersions('1.0.0', '1.1.0')); + } + + public function test_get_version_history(): void + { + $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $this->service->registerVersion('test-server', 'test_tool', '1.1.0'); + $this->service->registerVersion('test-server', 'test_tool', '2.0.0'); + + $history = $this->service->getVersionHistory('test-server', 'test_tool'); + + $this->assertCount(3, $history); + // Should be ordered by version desc + $this->assertSame('2.0.0', $history[0]->version); + $this->assertSame('1.1.0', $history[1]->version); + $this->assertSame('1.0.0', $history[2]->version); + } + + public function test_migrate_tool_call(): void + { + $this->service->registerVersion( + serverId: 'test-server', + toolName: 'test_tool', + version: '1.0.0', + inputSchema: [ + 'type' => 'object', + 'properties' => ['query' => ['type' => 'string']], + 'required' => ['query'], + ] + ); + + $this->service->registerVersion( + serverId: 'test-server', + toolName: 'test_tool', + version: '2.0.0', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'query' => ['type' => 'string'], + 'limit' => ['type' => 'integer', 'default' => 10], + ], + 'required' => ['query', 'limit'], + ] + ); + + $result = $this->service->migrateToolCall( + serverId: 'test-server', + toolName: 'test_tool', + fromVersion: '1.0.0', + toVersion: '2.0.0', + arguments: ['query' => 'SELECT * FROM users'] + ); + + $this->assertTrue($result['success']); + $this->assertSame('SELECT * FROM users', $result['arguments']['query']); + $this->assertSame(10, $result['arguments']['limit']); // Default applied + } + + public function test_deprecate_version(): void + { + $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + + $sunsetDate = Carbon::now()->addDays(30); + $deprecatedVersion = $this->service->deprecateVersion( + 'test-server', + 'test_tool', + '1.0.0', + $sunsetDate + ); + + $this->assertNotNull($deprecatedVersion->deprecated_at); + $this->assertSame($sunsetDate->toDateString(), $deprecatedVersion->sunset_at->toDateString()); + } + + public function test_get_tools_with_versions(): void + { + $this->service->registerVersion('test-server', 'tool_a', '1.0.0'); + $this->service->registerVersion('test-server', 'tool_a', '2.0.0', options: ['mark_latest' => true]); + $this->service->registerVersion('test-server', 'tool_b', '1.0.0'); + + $tools = $this->service->getToolsWithVersions('test-server'); + + $this->assertCount(2, $tools); + $this->assertArrayHasKey('tool_a', $tools); + $this->assertArrayHasKey('tool_b', $tools); + $this->assertSame(2, $tools['tool_a']['version_count']); + $this->assertSame(1, $tools['tool_b']['version_count']); + } + + public function test_get_servers_with_versions(): void + { + $this->service->registerVersion('server-a', 'tool', '1.0.0'); + $this->service->registerVersion('server-b', 'tool', '1.0.0'); + + $servers = $this->service->getServersWithVersions(); + + $this->assertCount(2, $servers); + $this->assertContains('server-a', $servers); + $this->assertContains('server-b', $servers); + } + + public function test_sync_from_server_config(): void + { + $config = [ + 'id' => 'test-server', + 'tools' => [ + [ + 'name' => 'tool_a', + 'description' => 'Tool A', + 'inputSchema' => ['type' => 'object'], + ], + [ + 'name' => 'tool_b', + 'purpose' => 'Tool B purpose', + ], + ], + ]; + + $registered = $this->service->syncFromServerConfig($config, '1.0.0'); + + $this->assertSame(2, $registered); + + $toolA = $this->service->getToolAtVersion('test-server', 'tool_a', '1.0.0'); + $toolB = $this->service->getToolAtVersion('test-server', 'tool_b', '1.0.0'); + + $this->assertNotNull($toolA); + $this->assertNotNull($toolB); + $this->assertSame('Tool A', $toolA->description); + $this->assertSame('Tool B purpose', $toolB->description); + } + + public function test_get_stats(): void + { + $this->service->registerVersion('server-a', 'tool_a', '1.0.0'); + $this->service->registerVersion('server-a', 'tool_a', '2.0.0'); + $this->service->registerVersion('server-b', 'tool_b', '1.0.0'); + + $stats = $this->service->getStats(); + + $this->assertSame(3, $stats['total_versions']); + $this->assertSame(2, $stats['total_tools']); + $this->assertSame(2, $stats['servers']); + } + + public function test_invalid_semver_throws_exception(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid semver version'); + + $this->service->registerVersion('test-server', 'test_tool', 'invalid'); + } + + public function test_valid_semver_formats(): void + { + // Basic versions + $v1 = $this->service->registerVersion('test-server', 'tool', '1.0.0'); + $this->assertSame('1.0.0', $v1->version); + + // Prerelease + $v2 = $this->service->registerVersion('test-server', 'tool', '2.0.0-beta'); + $this->assertSame('2.0.0-beta', $v2->version); + + // Prerelease with dots + $v3 = $this->service->registerVersion('test-server', 'tool', '2.0.0-alpha.1'); + $this->assertSame('2.0.0-alpha.1', $v3->version); + + // Build metadata + $v4 = $this->service->registerVersion('test-server', 'tool', '2.0.0+build.123'); + $this->assertSame('2.0.0+build.123', $v4->version); + } + + public function test_updating_existing_version(): void + { + $original = $this->service->registerVersion( + serverId: 'test-server', + toolName: 'test_tool', + version: '1.0.0', + description: 'Original description' + ); + + $updated = $this->service->registerVersion( + serverId: 'test-server', + toolName: 'test_tool', + version: '1.0.0', + description: 'Updated description' + ); + + $this->assertSame($original->id, $updated->id); + $this->assertSame('Updated description', $updated->description); + } + + public function test_model_compare_schema_with(): void + { + $v1 = $this->service->registerVersion( + serverId: 'test-server', + toolName: 'test_tool', + version: '1.0.0', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'query' => ['type' => 'string'], + 'format' => ['type' => 'string'], + ], + ] + ); + + $v2 = $this->service->registerVersion( + serverId: 'test-server', + toolName: 'test_tool', + version: '2.0.0', + inputSchema: [ + 'type' => 'object', + 'properties' => [ + 'query' => ['type' => 'string', 'maxLength' => 1000], // Changed + 'limit' => ['type' => 'integer'], // Added + ], + ] + ); + + $diff = $v1->compareSchemaWith($v2); + + $this->assertContains('limit', $diff['added']); + $this->assertContains('format', $diff['removed']); + $this->assertArrayHasKey('query', $diff['changed']); + } + + public function test_model_mark_as_latest(): void + { + $v1 = $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + $v2 = $this->service->registerVersion('test-server', 'test_tool', '2.0.0'); + + $v2->markAsLatest(); + + $this->assertFalse($v1->fresh()->is_latest); + $this->assertTrue($v2->fresh()->is_latest); + } + + public function test_model_status_attribute(): void + { + $version = $this->service->registerVersion('test-server', 'test_tool', '1.0.0'); + + $this->assertSame('latest', $version->status); + + $version->is_latest = false; + $version->save(); + $this->assertSame('active', $version->fresh()->status); + + $version->deprecated_at = Carbon::now()->subDay(); + $version->save(); + $this->assertSame('deprecated', $version->fresh()->status); + + $version->sunset_at = Carbon::now()->subDay(); + $version->save(); + $this->assertSame('sunset', $version->fresh()->status); + } + + public function test_model_to_api_array(): void + { + $version = $this->service->registerVersion( + serverId: 'test-server', + toolName: 'test_tool', + version: '1.0.0', + inputSchema: ['type' => 'object'], + description: 'Test tool', + options: ['changelog' => 'Initial release'] + ); + + $array = $version->toApiArray(); + + $this->assertSame('test-server', $array['server_id']); + $this->assertSame('test_tool', $array['tool_name']); + $this->assertSame('1.0.0', $array['version']); + $this->assertTrue($array['is_latest']); + $this->assertSame('latest', $array['status']); + $this->assertSame('Test tool', $array['description']); + $this->assertSame('Initial release', $array['changelog']); + } +} diff --git a/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/tool-version-manager.blade.php b/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/tool-version-manager.blade.php new file mode 100644 index 0000000..5d6b424 --- /dev/null +++ b/packages/core-mcp/src/Mod/Mcp/View/Blade/admin/tool-version-manager.blade.php @@ -0,0 +1,537 @@ +{{-- +MCP Tool Version Manager. + +Admin interface for managing tool version lifecycles, +viewing schema changes between versions, and setting deprecation schedules. +--}} + +
+ {{-- Header --}} +
+
+ {{ __('Tool Versions') }} + Manage MCP tool version lifecycles and backwards compatibility +
+
+ + Register Version + +
+
+ + {{-- Stats Cards --}} +
+
+
Total Versions
+
+ {{ number_format($this->stats['total_versions']) }} +
+
+
+
Unique Tools
+
+ {{ number_format($this->stats['total_tools']) }} +
+
+
+
Servers
+
+ {{ number_format($this->stats['servers']) }} +
+
+
+
Deprecated
+
+ {{ number_format($this->stats['deprecated_count']) }} +
+
+
+
Sunset
+
+ {{ number_format($this->stats['sunset_count']) }} +
+
+
+ + {{-- Filters --}} +
+
+ +
+ + All servers + @foreach ($this->servers as $serverId) + {{ $serverId }} + @endforeach + + + All statuses + Latest + Active (non-latest) + Deprecated + Sunset + + @if($search || $server || $status) + Clear + @endif +
+ + {{-- Versions Table --}} + + + Tool + Server + Version + Status + Deprecated + Sunset + Created + + + + + @forelse ($this->versions as $version) + + +
{{ $version->tool_name }}
+ @if($version->description) +
{{ $version->description }}
+ @endif +
+ + {{ $version->server_id }} + + + + {{ $version->version }} + + + + + {{ ucfirst($version->status) }} + + + + @if($version->deprecated_at) + {{ $version->deprecated_at->format('M j, Y') }} + @else + - + @endif + + + @if($version->sunset_at) + + {{ $version->sunset_at->format('M j, Y') }} + + @else + - + @endif + + + {{ $version->created_at->format('M j, Y') }} + + + + + + + View Details + + @if(!$version->is_latest && !$version->is_sunset) + + Mark as Latest + + @endif + @if(!$version->is_deprecated && !$version->is_sunset) + + Deprecate + + @endif + + + +
+ @empty + + +
+
+ +
+ No tool versions found + Register tool versions to enable backwards compatibility. +
+
+
+ @endforelse +
+
+ + @if($this->versions->hasPages()) +
+ {{ $this->versions->links() }} +
+ @endif + + {{-- Version Detail Modal --}} + @if($showVersionDetail && $this->selectedVersion) + +
+
+
+ {{ $this->selectedVersion->tool_name }} +
+ + {{ $this->selectedVersion->version }} + + + {{ ucfirst($this->selectedVersion->status) }} + +
+
+ +
+ + {{-- Metadata --}} +
+
+
Server
+
{{ $this->selectedVersion->server_id }}
+
+
+
Created
+
{{ $this->selectedVersion->created_at->format('Y-m-d H:i:s') }}
+
+ @if($this->selectedVersion->deprecated_at) +
+
Deprecated
+
+ {{ $this->selectedVersion->deprecated_at->format('Y-m-d') }} +
+
+ @endif + @if($this->selectedVersion->sunset_at) +
+
Sunset
+
+ {{ $this->selectedVersion->sunset_at->format('Y-m-d') }} +
+
+ @endif +
+ + @if($this->selectedVersion->description) +
+
Description
+
{{ $this->selectedVersion->description }}
+
+ @endif + + @if($this->selectedVersion->changelog) +
+
Changelog
+
+ {!! nl2br(e($this->selectedVersion->changelog)) !!} +
+
+ @endif + + @if($this->selectedVersion->migration_notes) +
+
+ + Migration Notes +
+
+ {!! nl2br(e($this->selectedVersion->migration_notes)) !!} +
+
+ @endif + + {{-- Input Schema --}} + @if($this->selectedVersion->input_schema) +
+
Input Schema
+
{{ $this->formatSchema($this->selectedVersion->input_schema) }}
+
+ @endif + + {{-- Output Schema --}} + @if($this->selectedVersion->output_schema) +
+
Output Schema
+
{{ $this->formatSchema($this->selectedVersion->output_schema) }}
+
+ @endif + + {{-- Version History --}} + @if($this->versionHistory->count() > 1) +
+
Version History
+
+ @foreach($this->versionHistory as $index => $historyVersion) +
+
+ + {{ $historyVersion->version }} + + + {{ ucfirst($historyVersion->status) }} + + + {{ $historyVersion->created_at->format('M j, Y') }} + +
+ @if($historyVersion->id !== $this->selectedVersion->id && $index < $this->versionHistory->count() - 1) + @php $nextVersion = $this->versionHistory[$index + 1] @endphp + + Compare + + @endif +
+ @endforeach +
+
+ @endif +
+
+ @endif + + {{-- Compare Schemas Modal --}} + @if($showCompareModal && $this->schemaComparison) + +
+
+ Schema Comparison + +
+ +
+
+ + {{ $this->schemaComparison['from']->version }} + +
+ +
+ + {{ $this->schemaComparison['to']->version }} + +
+
+ + @php $changes = $this->schemaComparison['changes'] @endphp + + @if(empty($changes['added']) && empty($changes['removed']) && empty($changes['changed'])) +
+
+ + No schema changes between versions +
+
+ @else +
+ @if(!empty($changes['added'])) +
+
+ Added Properties ({{ count($changes['added']) }}) +
+
    + @foreach($changes['added'] as $prop) +
  • {{ $prop }}
  • + @endforeach +
+
+ @endif + + @if(!empty($changes['removed'])) +
+
+ Removed Properties ({{ count($changes['removed']) }}) +
+
    + @foreach($changes['removed'] as $prop) +
  • {{ $prop }}
  • + @endforeach +
+
+ @endif + + @if(!empty($changes['changed'])) +
+
+ Changed Properties ({{ count($changes['changed']) }}) +
+
+ @foreach($changes['changed'] as $prop => $change) +
+ {{ $prop }} +
+
+
Before:
+
{{ json_encode($change['from'], JSON_PRETTY_PRINT) }}
+
+
+
After:
+
{{ json_encode($change['to'], JSON_PRETTY_PRINT) }}
+
+
+
+ @endforeach +
+
+ @endif +
+ @endif + +
+ Close +
+
+
+ @endif + + {{-- Deprecate Modal --}} + @if($showDeprecateModal) + @php $deprecateVersion = \Core\Mod\Mcp\Models\McpToolVersion::find($deprecateVersionId) @endphp + @if($deprecateVersion) + +
+
+ Deprecate Version + +
+ +
+
+ + {{ $deprecateVersion->tool_name }} v{{ $deprecateVersion->version }} +
+

+ Deprecated versions will show warnings to agents but remain usable until sunset. +

+
+ +
+ Sunset Date (optional) + + + After this date, the version will be blocked and return errors. + +
+ +
+ Cancel + + Deprecate Version + +
+
+
+ @endif + @endif + + {{-- Register Version Modal --}} + @if($showRegisterModal) + +
+
+ Register Tool Version + +
+ +
+
+
+ Server ID + + @error('registerServer') {{ $message }} @enderror +
+
+ Tool Name + + @error('registerTool') {{ $message }} @enderror +
+
+ +
+
+ Version (semver) + + @error('registerVersion') {{ $message }} @enderror +
+
+ +
+
+ +
+ Description + + @error('registerDescription') {{ $message }} @enderror +
+ +
+ Changelog + + @error('registerChangelog') {{ $message }} @enderror +
+ +
+ Migration Notes + + @error('registerMigrationNotes') {{ $message }} @enderror +
+ +
+ Input Schema (JSON) + + @error('registerInputSchema') {{ $message }} @enderror +
+ +
+ Cancel + Register Version +
+
+
+
+ @endif +
diff --git a/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/ToolVersionManager.php b/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/ToolVersionManager.php new file mode 100644 index 0000000..bea8367 --- /dev/null +++ b/packages/core-mcp/src/Mod/Mcp/View/Modal/Admin/ToolVersionManager.php @@ -0,0 +1,349 @@ +checkHadesAccess(); + } + + #[Computed] + public function versions(): LengthAwarePaginator + { + $query = McpToolVersion::query() + ->orderByDesc('created_at'); + + if ($this->search) { + $query->where(function ($q) { + $q->where('tool_name', 'like', "%{$this->search}%") + ->orWhere('server_id', 'like', "%{$this->search}%") + ->orWhere('version', 'like', "%{$this->search}%") + ->orWhere('description', 'like', "%{$this->search}%"); + }); + } + + if ($this->server) { + $query->forServer($this->server); + } + + if ($this->status === 'latest') { + $query->latest(); + } elseif ($this->status === 'deprecated') { + $query->deprecated(); + } elseif ($this->status === 'sunset') { + $query->sunset(); + } elseif ($this->status === 'active') { + $query->active()->where('is_latest', false); + } + + return $query->paginate($this->perPage); + } + + #[Computed] + public function servers(): Collection + { + return app(ToolVersionService::class)->getServersWithVersions(); + } + + #[Computed] + public function stats(): array + { + return app(ToolVersionService::class)->getStats(); + } + + #[Computed] + public function selectedVersion(): ?McpToolVersion + { + if (! $this->selectedVersionId) { + return null; + } + + return McpToolVersion::find($this->selectedVersionId); + } + + #[Computed] + public function versionHistory(): Collection + { + if (! $this->selectedVersion) { + return collect(); + } + + return app(ToolVersionService::class)->getVersionHistory( + $this->selectedVersion->server_id, + $this->selectedVersion->tool_name + ); + } + + #[Computed] + public function schemaComparison(): ?array + { + if (! $this->compareFromId || ! $this->compareToId) { + return null; + } + + $from = McpToolVersion::find($this->compareFromId); + $to = McpToolVersion::find($this->compareToId); + + if (! $from || ! $to) { + return null; + } + + return [ + 'from' => $from, + 'to' => $to, + 'changes' => $from->compareSchemaWith($to), + ]; + } + + // ------------------------------------------------------------------------- + // Actions + // ------------------------------------------------------------------------- + + public function viewVersion(int $id): void + { + $this->selectedVersionId = $id; + $this->showVersionDetail = true; + } + + public function closeVersionDetail(): void + { + $this->showVersionDetail = false; + $this->selectedVersionId = null; + } + + public function openCompareModal(int $fromId, int $toId): void + { + $this->compareFromId = $fromId; + $this->compareToId = $toId; + $this->showCompareModal = true; + } + + public function closeCompareModal(): void + { + $this->showCompareModal = false; + $this->compareFromId = null; + $this->compareToId = null; + } + + public function openDeprecateModal(int $versionId): void + { + $this->deprecateVersionId = $versionId; + $this->deprecateSunsetDate = ''; + $this->showDeprecateModal = true; + } + + public function closeDeprecateModal(): void + { + $this->showDeprecateModal = false; + $this->deprecateVersionId = null; + $this->deprecateSunsetDate = ''; + } + + public function deprecateVersion(): void + { + $version = McpToolVersion::find($this->deprecateVersionId); + if (! $version) { + return; + } + + $sunsetAt = $this->deprecateSunsetDate + ? Carbon::parse($this->deprecateSunsetDate) + : null; + + app(ToolVersionService::class)->deprecateVersion( + $version->server_id, + $version->tool_name, + $version->version, + $sunsetAt + ); + + $this->closeDeprecateModal(); + $this->dispatch('version-deprecated'); + } + + public function markAsLatest(int $versionId): void + { + $version = McpToolVersion::find($versionId); + if (! $version) { + return; + } + + $version->markAsLatest(); + $this->dispatch('version-marked-latest'); + } + + public function openRegisterModal(): void + { + $this->resetRegisterForm(); + $this->showRegisterModal = true; + } + + public function closeRegisterModal(): void + { + $this->showRegisterModal = false; + $this->resetRegisterForm(); + } + + public function registerVersion(): void + { + $this->validate([ + 'registerServer' => 'required|string|max:64', + 'registerTool' => 'required|string|max:128', + 'registerVersion' => 'required|string|max:32|regex:/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$/', + 'registerDescription' => 'nullable|string|max:1000', + 'registerChangelog' => 'nullable|string|max:5000', + 'registerMigrationNotes' => 'nullable|string|max:5000', + 'registerInputSchema' => 'nullable|string', + ]); + + $inputSchema = null; + if ($this->registerInputSchema) { + $inputSchema = json_decode($this->registerInputSchema, true); + if (json_last_error() !== JSON_ERROR_NONE) { + $this->addError('registerInputSchema', 'Invalid JSON'); + + return; + } + } + + app(ToolVersionService::class)->registerVersion( + serverId: $this->registerServer, + toolName: $this->registerTool, + version: $this->registerVersion, + inputSchema: $inputSchema, + description: $this->registerDescription ?: null, + options: [ + 'changelog' => $this->registerChangelog ?: null, + 'migration_notes' => $this->registerMigrationNotes ?: null, + 'mark_latest' => $this->registerMarkLatest, + ] + ); + + $this->closeRegisterModal(); + $this->dispatch('version-registered'); + } + + public function clearFilters(): void + { + $this->search = ''; + $this->server = ''; + $this->status = ''; + $this->resetPage(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + public function getStatusBadgeColor(string $status): string + { + return match ($status) { + 'latest' => 'green', + 'active' => 'zinc', + 'deprecated' => 'amber', + 'sunset' => 'red', + default => 'zinc', + }; + } + + public function formatSchema(array $schema): string + { + return json_encode($schema, JSON_PRETTY_PRINT); + } + + private function resetRegisterForm(): void + { + $this->registerServer = ''; + $this->registerTool = ''; + $this->registerVersion = ''; + $this->registerDescription = ''; + $this->registerChangelog = ''; + $this->registerMigrationNotes = ''; + $this->registerInputSchema = ''; + $this->registerMarkLatest = false; + } + + private function checkHadesAccess(): void + { + if (! auth()->user()?->isHades()) { + abort(403, 'Hades access required'); + } + } + + public function render() + { + return view('mcp::admin.tool-version-manager'); + } +} diff --git a/packages/core-php/composer.json b/packages/core-php/composer.json index c5bc91e..8bb8d24 100644 --- a/packages/core-php/composer.json +++ b/packages/core-php/composer.json @@ -6,7 +6,7 @@ "authors": [ { "name": "Host UK", - "email": "dev@host.uk.com" + "email": "support@host.uk.com" } ], "require": { diff --git a/packages/core-php/src/Core/Headers/CspNonceService.php b/packages/core-php/src/Core/Headers/CspNonceService.php index 411465d..5ab1fce 100644 --- a/packages/core-php/src/Core/Headers/CspNonceService.php +++ b/packages/core-php/src/Core/Headers/CspNonceService.php @@ -26,7 +26,7 @@ namespace Core\Headers; * * * * ``` * diff --git a/packages/core-php/src/Core/RELEASE-BLOCKERS.md b/packages/core-php/src/Core/RELEASE-BLOCKERS.md index 07960c7..aaa9a40 100644 --- a/packages/core-php/src/Core/RELEASE-BLOCKERS.md +++ b/packages/core-php/src/Core/RELEASE-BLOCKERS.md @@ -249,7 +249,7 @@ Add warning comment explaining DEBUG_TOKEN should never be committed. **File:** `composer.json` **Issue:** -Author email is company-specific: `dev@host.uk.com` +Author email is company-specific: `support@host.uk.com` **Suggested Fix:** Update to generic project contact or maintainer email. diff --git a/packages/core-php/src/Mod/Tenant/Boot.php b/packages/core-php/src/Mod/Tenant/Boot.php index 23d9781..354c780 100644 --- a/packages/core-php/src/Mod/Tenant/Boot.php +++ b/packages/core-php/src/Mod/Tenant/Boot.php @@ -79,6 +79,11 @@ class Boot extends ServiceProvider \Core\Mod\Tenant\Services\EntitlementWebhookService::class ); + $this->app->singleton( + \Core\Mod\Tenant\Services\WorkspaceTeamService::class, + \Core\Mod\Tenant\Services\WorkspaceTeamService::class + ); + $this->registerBackwardCompatAliases(); } @@ -157,6 +162,7 @@ class Boot extends ServiceProvider public function onConsole(ConsoleBooting $event): void { $event->middleware('admin.domain', Middleware\RequireAdminDomain::class); + $event->middleware('workspace.permission', Middleware\CheckWorkspacePermission::class); // Artisan commands $event->command(Console\Commands\RefreshUserStats::class); diff --git a/packages/core-php/src/Mod/Tenant/Lang/en_GB/tenant.php b/packages/core-php/src/Mod/Tenant/Lang/en_GB/tenant.php index 7e1584d..82b6a57 100644 --- a/packages/core-php/src/Mod/Tenant/Lang/en_GB/tenant.php +++ b/packages/core-php/src/Mod/Tenant/Lang/en_GB/tenant.php @@ -298,6 +298,228 @@ return [ 'usage_reset' => 'Usage counters have been reset for the new billing period.', ], + /* + |-------------------------------------------------------------------------- + | Common + |-------------------------------------------------------------------------- + */ + 'common' => [ + 'na' => 'N/A', + 'none' => 'None', + 'unknown' => 'Unknown', + ], + + /* + |-------------------------------------------------------------------------- + | Errors + |-------------------------------------------------------------------------- + */ + 'errors' => [ + 'hades_required' => 'Hades tier required for this feature.', + 'unauthenticated' => 'You must be logged in to access this resource.', + 'no_workspace' => 'No workspace context available.', + 'insufficient_permissions' => 'You do not have permission to perform this action.', + ], + + /* + |-------------------------------------------------------------------------- + | Admin - Team Manager + |-------------------------------------------------------------------------- + */ + 'admin' => [ + // ... existing admin translations will be merged ... + + 'team_manager' => [ + 'title' => 'Workspace Teams', + 'subtitle' => 'Manage teams and role-based permissions for workspaces', + + 'stats' => [ + 'total_teams' => 'Total Teams', + 'total_members' => 'Total Members', + 'members_assigned' => 'Assigned to Teams', + ], + + 'search' => [ + 'placeholder' => 'Search teams by name...', + ], + + 'filter' => [ + 'all_workspaces' => 'All Workspaces', + ], + + 'columns' => [ + 'team' => 'Team', + 'workspace' => 'Workspace', + 'members' => 'Members', + 'permissions' => 'Permissions', + 'actions' => 'Actions', + ], + + 'labels' => [ + 'permissions' => 'permissions', + ], + + 'badges' => [ + 'system' => 'System', + 'default' => 'Default', + ], + + 'actions' => [ + 'create_team' => 'Create Team', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'view_members' => 'View Members', + 'seed_defaults' => 'Seed Defaults', + 'migrate_members' => 'Migrate Members', + ], + + 'confirm' => [ + 'delete_team' => 'Are you sure you want to delete this team? Members will be unassigned.', + ], + + 'empty_state' => [ + 'title' => 'No teams found', + 'description' => 'Create teams to organise members and control permissions in your workspaces.', + ], + + 'modal' => [ + 'title_create' => 'Create Team', + 'title_edit' => 'Edit Team', + + 'fields' => [ + 'workspace' => 'Workspace', + 'select_workspace' => 'Select workspace...', + 'name' => 'Name', + 'name_placeholder' => 'e.g. Editors', + 'slug' => 'Slug', + 'slug_placeholder' => 'e.g. editors', + 'slug_description' => 'Leave blank to auto-generate from name.', + 'description' => 'Description', + 'colour' => 'Colour', + 'is_default' => 'Default team for new members', + 'permissions' => 'Permissions', + ], + + 'actions' => [ + 'cancel' => 'Cancel', + 'create' => 'Create Team', + 'update' => 'Update Team', + ], + ], + + 'messages' => [ + 'team_created' => 'Team created successfully.', + 'team_updated' => 'Team updated successfully.', + 'team_deleted' => 'Team deleted successfully.', + 'cannot_delete_system' => 'Cannot delete system teams.', + 'cannot_delete_has_members' => 'Cannot delete team with :count assigned member(s). Remove members first.', + 'defaults_seeded' => 'Default teams have been seeded successfully.', + 'members_migrated' => ':count member(s) have been migrated to teams.', + ], + ], + + 'member_manager' => [ + 'title' => 'Workspace Members', + 'subtitle' => 'Manage member team assignments and custom permissions', + + 'stats' => [ + 'total_members' => 'Total Members', + 'with_team' => 'Assigned to Team', + 'with_custom' => 'With Custom Permissions', + ], + + 'search' => [ + 'placeholder' => 'Search members by name or email...', + ], + + 'filter' => [ + 'all_workspaces' => 'All Workspaces', + 'all_teams' => 'All Teams', + ], + + 'columns' => [ + 'member' => 'Member', + 'workspace' => 'Workspace', + 'team' => 'Team', + 'role' => 'Legacy Role', + 'permissions' => 'Custom', + 'actions' => 'Actions', + ], + + 'labels' => [ + 'no_team' => 'No team', + 'inherited' => 'Inherited', + ], + + 'actions' => [ + 'assign_team' => 'Assign to Team', + 'remove_from_team' => 'Remove from Team', + 'custom_permissions' => 'Custom Permissions', + 'clear_permissions' => 'Clear Custom Permissions', + ], + + 'confirm' => [ + 'clear_permissions' => 'Are you sure you want to clear all custom permissions for this member?', + 'bulk_remove_team' => 'Are you sure you want to remove the selected members from their teams?', + 'bulk_clear_permissions' => 'Are you sure you want to clear custom permissions for all selected members?', + ], + + 'bulk' => [ + 'selected' => ':count selected', + 'assign_team' => 'Assign Team', + 'remove_team' => 'Remove Team', + 'clear_permissions' => 'Clear Permissions', + 'clear' => 'Clear', + ], + + 'empty_state' => [ + 'title' => 'No members found', + 'description' => 'No members match your current filter criteria.', + ], + + 'modal' => [ + 'actions' => [ + 'cancel' => 'Cancel', + 'save' => 'Save', + 'assign' => 'Assign', + ], + ], + + 'assign_modal' => [ + 'title' => 'Assign to Team', + 'team' => 'Team', + 'no_team' => 'No team (remove assignment)', + ], + + 'permissions_modal' => [ + 'title' => 'Custom Permissions', + 'team_permissions' => 'Team: :team', + 'description' => 'Custom permissions override the team permissions. Grant additional permissions or revoke specific ones.', + 'grant_label' => 'Grant Additional Permissions', + 'revoke_label' => 'Revoke Permissions', + ], + + 'bulk_assign_modal' => [ + 'title' => 'Bulk Assign Team', + 'description' => 'Assign :count selected member(s) to a team.', + 'team' => 'Team', + 'no_team' => 'No team (remove assignment)', + ], + + 'messages' => [ + 'team_assigned' => 'Member assigned to team successfully.', + 'removed_from_team' => 'Member removed from team successfully.', + 'permissions_updated' => 'Custom permissions updated successfully.', + 'permissions_cleared' => 'Custom permissions cleared successfully.', + 'no_members_selected' => 'No members selected.', + 'invalid_team' => 'Invalid team selected.', + 'bulk_team_assigned' => ':count member(s) assigned to team.', + 'bulk_removed_from_team' => ':count member(s) removed from team.', + 'bulk_permissions_cleared' => 'Custom permissions cleared for :count member(s).', + ], + ], + ], + /* |-------------------------------------------------------------------------- | Entitlement Webhooks diff --git a/packages/core-php/src/Mod/Tenant/Middleware/CheckWorkspacePermission.php b/packages/core-php/src/Mod/Tenant/Middleware/CheckWorkspacePermission.php new file mode 100644 index 0000000..be217e3 --- /dev/null +++ b/packages/core-php/src/Mod/Tenant/Middleware/CheckWorkspacePermission.php @@ -0,0 +1,96 @@ +user(); + + if (! $user) { + abort(403, __('tenant::tenant.errors.unauthenticated')); + } + + // Get current workspace from request or user's default + $workspace = $this->getWorkspace($request); + + if (! $workspace) { + abort(403, __('tenant::tenant.errors.no_workspace')); + } + + // Set up the team service with the workspace context + $this->teamService->forWorkspace($workspace); + + // Check if user has any of the required permissions + if (! $this->teamService->hasAnyPermission($user, $permissions)) { + abort(403, __('tenant::tenant.errors.insufficient_permissions')); + } + + // Store the workspace and member in request for later use + $request->attributes->set('workspace_model', $workspace); + + $member = $this->teamService->getMember($user); + if ($member) { + $request->attributes->set('workspace_member', $member); + } + + return $next($request); + } + + protected function getWorkspace(Request $request): ?Workspace + { + // First try to get from request attributes (already resolved by other middleware) + if ($request->attributes->has('workspace_model')) { + return $request->attributes->get('workspace_model'); + } + + // Try to get from route parameter + $workspaceParam = $request->route('workspace'); + if ($workspaceParam instanceof Workspace) { + return $workspaceParam; + } + + if (is_string($workspaceParam) || is_int($workspaceParam)) { + return Workspace::where('slug', $workspaceParam) + ->orWhere('id', $workspaceParam) + ->first(); + } + + // Try to get from session + $sessionSlug = session('workspace'); + if ($sessionSlug) { + return Workspace::where('slug', $sessionSlug)->first(); + } + + // Fall back to user's default workspace + $user = $request->user(); + if ($user && method_exists($user, 'defaultHostWorkspace')) { + return $user->defaultHostWorkspace(); + } + + return null; + } +} diff --git a/packages/core-php/src/Mod/Tenant/Migrations/2026_01_26_140000_create_workspace_teams_table.php b/packages/core-php/src/Mod/Tenant/Migrations/2026_01_26_140000_create_workspace_teams_table.php new file mode 100644 index 0000000..cf8f09e --- /dev/null +++ b/packages/core-php/src/Mod/Tenant/Migrations/2026_01_26_140000_create_workspace_teams_table.php @@ -0,0 +1,59 @@ +id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('slug'); + $table->text('description')->nullable(); + $table->json('permissions')->nullable(); + $table->boolean('is_default')->default(false); + $table->boolean('is_system')->default(false); + $table->string('colour', 32)->default('zinc'); + $table->integer('sort_order')->default(0); + $table->timestamps(); + + $table->unique(['workspace_id', 'slug']); + $table->index(['workspace_id', 'is_default']); + }); + + // 2. Enhance user_workspace pivot table + Schema::table('user_workspace', function (Blueprint $table) { + $table->foreignId('team_id')->nullable() + ->after('role') + ->constrained('workspace_teams') + ->nullOnDelete(); + $table->json('custom_permissions')->nullable()->after('team_id'); + $table->timestamp('joined_at')->nullable()->after('custom_permissions'); + $table->foreignId('invited_by')->nullable() + ->after('joined_at') + ->constrained('users') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('user_workspace', function (Blueprint $table) { + $table->dropForeign(['team_id']); + $table->dropForeign(['invited_by']); + $table->dropColumn(['team_id', 'custom_permissions', 'joined_at', 'invited_by']); + }); + + Schema::dropIfExists('workspace_teams'); + } +}; diff --git a/packages/core-php/src/Mod/Tenant/Models/Workspace.php b/packages/core-php/src/Mod/Tenant/Models/Workspace.php index fd637f8..1612846 100644 --- a/packages/core-php/src/Mod/Tenant/Models/Workspace.php +++ b/packages/core-php/src/Mod/Tenant/Models/Workspace.php @@ -82,10 +82,26 @@ class Workspace extends Model public function users(): BelongsToMany { return $this->belongsToMany(User::class, 'user_workspace') - ->withPivot(['role', 'is_default']) + ->withPivot(['role', 'is_default', 'team_id', 'custom_permissions', 'joined_at', 'invited_by']) ->withTimestamps(); } + /** + * Get workspace members (via the enhanced pivot model). + */ + public function members(): HasMany + { + return $this->hasMany(WorkspaceMember::class); + } + + /** + * Get teams defined for this workspace. + */ + public function teams(): HasMany + { + return $this->hasMany(WorkspaceTeam::class); + } + /** * Get the workspace owner (user with 'owner' role). */ @@ -96,6 +112,14 @@ class Workspace extends Model ->first(); } + /** + * Get the default team for new members. + */ + public function defaultTeam(): ?WorkspaceTeam + { + return $this->teams()->where('is_default', true)->first(); + } + /** * Active package assignments for this workspace. */ diff --git a/packages/core-php/src/Mod/Tenant/Models/WorkspaceMember.php b/packages/core-php/src/Mod/Tenant/Models/WorkspaceMember.php new file mode 100644 index 0000000..6d49df7 --- /dev/null +++ b/packages/core-php/src/Mod/Tenant/Models/WorkspaceMember.php @@ -0,0 +1,377 @@ + 'array', + 'is_default' => 'boolean', + 'joined_at' => 'datetime', + ]; + + // ───────────────────────────────────────────────────────────────────────── + // Role Constants (legacy, for backwards compatibility) + // ───────────────────────────────────────────────────────────────────────── + + public const ROLE_OWNER = 'owner'; + + public const ROLE_ADMIN = 'admin'; + + public const ROLE_MEMBER = 'member'; + + // ───────────────────────────────────────────────────────────────────────── + // Relationships + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get the user for this membership. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the workspace for this membership. + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * Get the team for this membership. + */ + public function team(): BelongsTo + { + return $this->belongsTo(WorkspaceTeam::class, 'team_id'); + } + + /** + * Get the user who invited this member. + */ + public function inviter(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_by'); + } + + // ───────────────────────────────────────────────────────────────────────── + // Scopes + // ───────────────────────────────────────────────────────────────────────── + + /** + * Scope to a specific workspace. + */ + public function scopeForWorkspace($query, Workspace|int $workspace) + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + return $query->where('workspace_id', $workspaceId); + } + + /** + * Scope to a specific user. + */ + public function scopeForUser($query, User|int $user) + { + $userId = $user instanceof User ? $user->id : $user; + + return $query->where('user_id', $userId); + } + + /** + * Scope to members with a specific role. + */ + public function scopeWithRole($query, string $role) + { + return $query->where('role', $role); + } + + /** + * Scope to members in a specific team. + */ + public function scopeInTeam($query, WorkspaceTeam|int $team) + { + $teamId = $team instanceof WorkspaceTeam ? $team->id : $team; + + return $query->where('team_id', $teamId); + } + + /** + * Scope to owners only. + */ + public function scopeOwners($query) + { + return $query->where('role', self::ROLE_OWNER); + } + + // ───────────────────────────────────────────────────────────────────────── + // Permission Helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get all effective permissions for this member. + * + * Merges team permissions with custom permission overrides. + */ + public function getEffectivePermissions(): array + { + // Start with team permissions + $permissions = $this->team?->permissions ?? []; + + // Merge custom permissions (overrides) + $customPermissions = $this->custom_permissions ?? []; + + // Custom permissions can grant (+permission) or revoke (-permission) + foreach ($customPermissions as $permission) { + if (str_starts_with($permission, '-')) { + // Remove permission + $toRemove = substr($permission, 1); + $permissions = array_values(array_filter( + $permissions, + fn ($p) => $p !== $toRemove + )); + } elseif (str_starts_with($permission, '+')) { + // Add permission (explicit add) + $toAdd = substr($permission, 1); + if (! in_array($toAdd, $permissions, true)) { + $permissions[] = $toAdd; + } + } else { + // Treat as add if no prefix + if (! in_array($permission, $permissions, true)) { + $permissions[] = $permission; + } + } + } + + // Legacy fallback: if no team, derive from role + if (! $this->team_id) { + $rolePermissions = match ($this->role) { + self::ROLE_OWNER => WorkspaceTeam::getDefaultPermissionsFor(WorkspaceTeam::TEAM_OWNER), + self::ROLE_ADMIN => WorkspaceTeam::getDefaultPermissionsFor(WorkspaceTeam::TEAM_ADMIN), + default => WorkspaceTeam::getDefaultPermissionsFor(WorkspaceTeam::TEAM_MEMBER), + }; + $permissions = array_unique(array_merge($permissions, $rolePermissions)); + } + + return array_values(array_unique($permissions)); + } + + /** + * Check if this member has a specific permission. + */ + public function hasPermission(string $permission): bool + { + $permissions = $this->getEffectivePermissions(); + + // Check for exact match + if (in_array($permission, $permissions, true)) { + return true; + } + + // Check for wildcard permissions + foreach ($permissions as $perm) { + if (str_ends_with($perm, '.*')) { + $prefix = substr($perm, 0, -1); + if (str_starts_with($permission, $prefix)) { + return true; + } + } + } + + return false; + } + + /** + * Check if this member has any of the given permissions. + */ + public function hasAnyPermission(array $permissions): bool + { + foreach ($permissions as $permission) { + if ($this->hasPermission($permission)) { + return true; + } + } + + return false; + } + + /** + * Check if this member has all of the given permissions. + */ + public function hasAllPermissions(array $permissions): bool + { + foreach ($permissions as $permission) { + if (! $this->hasPermission($permission)) { + return false; + } + } + + return true; + } + + /** + * Add a custom permission override. + */ + public function grantCustomPermission(string $permission): self + { + $custom = $this->custom_permissions ?? []; + + // Remove any revocation of this permission + $custom = array_filter($custom, fn ($p) => $p !== '-'.$permission); + + // Add the permission if not already present + if (! in_array($permission, $custom, true) && ! in_array('+'.$permission, $custom, true)) { + $custom[] = '+'.$permission; + } + + $this->update(['custom_permissions' => array_values($custom)]); + + return $this; + } + + /** + * Revoke a permission via custom override. + */ + public function revokeCustomPermission(string $permission): self + { + $custom = $this->custom_permissions ?? []; + + // Remove any grant of this permission + $custom = array_filter($custom, fn ($p) => $p !== $permission && $p !== '+'.$permission); + + // Add revocation + if (! in_array('-'.$permission, $custom, true)) { + $custom[] = '-'.$permission; + } + + $this->update(['custom_permissions' => array_values($custom)]); + + return $this; + } + + /** + * Clear all custom permission overrides. + */ + public function clearCustomPermissions(): self + { + $this->update(['custom_permissions' => null]); + + return $this; + } + + // ───────────────────────────────────────────────────────────────────────── + // Helper Methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Check if this member is the workspace owner. + */ + public function isOwner(): bool + { + return $this->role === self::ROLE_OWNER + || $this->team?->slug === WorkspaceTeam::TEAM_OWNER; + } + + /** + * Check if this member is an admin. + */ + public function isAdmin(): bool + { + return $this->isOwner() + || $this->role === self::ROLE_ADMIN + || $this->team?->slug === WorkspaceTeam::TEAM_ADMIN; + } + + /** + * Assign this member to a team. + */ + public function assignToTeam(WorkspaceTeam|int $team): self + { + $teamId = $team instanceof WorkspaceTeam ? $team->id : $team; + + $this->update(['team_id' => $teamId]); + + return $this; + } + + /** + * Remove this member from their team. + */ + public function removeFromTeam(): self + { + $this->update(['team_id' => null]); + + return $this; + } + + /** + * Get the display name for this membership (team name or role). + */ + public function getDisplayRole(): string + { + if ($this->team) { + return $this->team->name; + } + + return match ($this->role) { + self::ROLE_OWNER => 'Owner', + self::ROLE_ADMIN => 'Admin', + default => 'Member', + }; + } + + /** + * Get the colour for this membership's role badge. + */ + public function getRoleColour(): string + { + if ($this->team) { + return $this->team->colour; + } + + return match ($this->role) { + self::ROLE_OWNER => 'violet', + self::ROLE_ADMIN => 'blue', + default => 'zinc', + }; + } +} diff --git a/packages/core-php/src/Mod/Tenant/Models/WorkspaceTeam.php b/packages/core-php/src/Mod/Tenant/Models/WorkspaceTeam.php new file mode 100644 index 0000000..7e11644 --- /dev/null +++ b/packages/core-php/src/Mod/Tenant/Models/WorkspaceTeam.php @@ -0,0 +1,517 @@ + 'array', + 'is_default' => 'boolean', + 'is_system' => 'boolean', + 'sort_order' => 'integer', + ]; + + // ───────────────────────────────────────────────────────────────────────── + // Boot + // ───────────────────────────────────────────────────────────────────────── + + protected static function boot(): void + { + parent::boot(); + + static::creating(function (self $team) { + if (empty($team->slug)) { + $team->slug = Str::slug($team->name); + } + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Relationships + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get members assigned to this team via the pivot. + */ + public function members(): HasMany + { + return $this->hasMany(WorkspaceMember::class, 'team_id'); + } + + // ───────────────────────────────────────────────────────────────────────── + // Scopes + // ───────────────────────────────────────────────────────────────────────── + + /** + * Scope to default teams only. + */ + public function scopeDefault($query) + { + return $query->where('is_default', true); + } + + /** + * Scope to system teams only. + */ + public function scopeSystem($query) + { + return $query->where('is_system', true); + } + + /** + * Scope to custom (non-system) teams only. + */ + public function scopeCustom($query) + { + return $query->where('is_system', false); + } + + /** + * Scope ordered by sort_order. + */ + public function scopeOrdered($query) + { + return $query->orderBy('sort_order'); + } + + // ───────────────────────────────────────────────────────────────────────── + // Permission Helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Check if this team has a specific permission. + */ + public function hasPermission(string $permission): bool + { + $permissions = $this->permissions ?? []; + + // Check for exact match + if (in_array($permission, $permissions, true)) { + return true; + } + + // Check for wildcard permissions (e.g., 'bio.*' matches 'bio.read') + foreach ($permissions as $perm) { + if (str_ends_with($perm, '.*')) { + $prefix = substr($perm, 0, -1); // Remove the '*' + if (str_starts_with($permission, $prefix)) { + return true; + } + } + } + + return false; + } + + /** + * Check if this team has any of the given permissions. + */ + public function hasAnyPermission(array $permissions): bool + { + foreach ($permissions as $permission) { + if ($this->hasPermission($permission)) { + return true; + } + } + + return false; + } + + /** + * Check if this team has all of the given permissions. + */ + public function hasAllPermissions(array $permissions): bool + { + foreach ($permissions as $permission) { + if (! $this->hasPermission($permission)) { + return false; + } + } + + return true; + } + + /** + * Grant a permission to this team. + */ + public function grantPermission(string $permission): self + { + $permissions = $this->permissions ?? []; + + if (! in_array($permission, $permissions, true)) { + $permissions[] = $permission; + $this->update(['permissions' => $permissions]); + } + + return $this; + } + + /** + * Revoke a permission from this team. + */ + public function revokePermission(string $permission): self + { + $permissions = $this->permissions ?? []; + $permissions = array_values(array_filter($permissions, fn ($p) => $p !== $permission)); + + $this->update(['permissions' => $permissions]); + + return $this; + } + + /** + * Set all permissions for this team. + */ + public function setPermissions(array $permissions): self + { + $this->update(['permissions' => $permissions]); + + return $this; + } + + // ───────────────────────────────────────────────────────────────────────── + // Static Helpers + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get all available permissions grouped by category. + */ + public static function getAvailablePermissions(): array + { + return [ + 'workspace' => [ + 'label' => 'Workspace', + 'permissions' => [ + self::PERM_WORKSPACE_SETTINGS => 'Manage settings', + self::PERM_WORKSPACE_MEMBERS => 'Manage members', + self::PERM_WORKSPACE_TEAMS => 'Manage teams', + self::PERM_WORKSPACE_BILLING => 'Manage billing', + self::PERM_WORKSPACE_DELETE => 'Delete workspace', + ], + ], + 'bio' => [ + 'label' => 'BioHost', + 'permissions' => [ + self::PERM_BIO_READ => 'View pages', + self::PERM_BIO_WRITE => 'Create and edit pages', + self::PERM_BIO_ADMIN => 'Full access', + ], + ], + 'social' => [ + 'label' => 'SocialHost', + 'permissions' => [ + self::PERM_SOCIAL_READ => 'View posts and accounts', + self::PERM_SOCIAL_WRITE => 'Create and edit posts', + self::PERM_SOCIAL_ADMIN => 'Full access', + ], + ], + 'analytics' => [ + 'label' => 'AnalyticsHost', + 'permissions' => [ + self::PERM_ANALYTICS_READ => 'View analytics', + self::PERM_ANALYTICS_WRITE => 'Configure tracking', + self::PERM_ANALYTICS_ADMIN => 'Full access', + ], + ], + 'trust' => [ + 'label' => 'TrustHost', + 'permissions' => [ + self::PERM_TRUST_READ => 'View campaigns', + self::PERM_TRUST_WRITE => 'Create and edit campaigns', + self::PERM_TRUST_ADMIN => 'Full access', + ], + ], + 'notify' => [ + 'label' => 'NotifyHost', + 'permissions' => [ + self::PERM_NOTIFY_READ => 'View notifications', + self::PERM_NOTIFY_WRITE => 'Send notifications', + self::PERM_NOTIFY_ADMIN => 'Full access', + ], + ], + 'support' => [ + 'label' => 'SupportHost', + 'permissions' => [ + self::PERM_SUPPORT_READ => 'View conversations', + self::PERM_SUPPORT_WRITE => 'Reply to conversations', + self::PERM_SUPPORT_ADMIN => 'Full access', + ], + ], + 'commerce' => [ + 'label' => 'Commerce', + 'permissions' => [ + self::PERM_COMMERCE_READ => 'View orders and invoices', + self::PERM_COMMERCE_WRITE => 'Manage orders', + self::PERM_COMMERCE_ADMIN => 'Full access', + ], + ], + 'api' => [ + 'label' => 'API', + 'permissions' => [ + self::PERM_API_READ => 'View API keys', + self::PERM_API_WRITE => 'Create API keys', + self::PERM_API_ADMIN => 'Full access', + ], + ], + ]; + } + + /** + * Get flat list of all permission keys. + */ + public static function getAllPermissionKeys(): array + { + $keys = []; + foreach (self::getAvailablePermissions() as $group) { + $keys = array_merge($keys, array_keys($group['permissions'])); + } + + return $keys; + } + + /** + * Get default permissions for a given team type. + */ + public static function getDefaultPermissionsFor(string $teamSlug): array + { + return match ($teamSlug) { + self::TEAM_OWNER => self::getAllPermissionKeys(), // Owner gets all permissions + self::TEAM_ADMIN => array_filter( + self::getAllPermissionKeys(), + fn ($p) => ! in_array($p, [ + self::PERM_WORKSPACE_DELETE, + self::PERM_WORKSPACE_BILLING, + ], true) + ), + self::TEAM_MEMBER => [ + self::PERM_BIO_READ, + self::PERM_BIO_WRITE, + self::PERM_SOCIAL_READ, + self::PERM_SOCIAL_WRITE, + self::PERM_ANALYTICS_READ, + self::PERM_TRUST_READ, + self::PERM_TRUST_WRITE, + self::PERM_NOTIFY_READ, + self::PERM_NOTIFY_WRITE, + self::PERM_SUPPORT_READ, + self::PERM_SUPPORT_WRITE, + self::PERM_COMMERCE_READ, + self::PERM_API_READ, + ], + self::TEAM_VIEWER => [ + self::PERM_BIO_READ, + self::PERM_SOCIAL_READ, + self::PERM_ANALYTICS_READ, + self::PERM_TRUST_READ, + self::PERM_NOTIFY_READ, + self::PERM_SUPPORT_READ, + self::PERM_COMMERCE_READ, + self::PERM_API_READ, + ], + default => [], + }; + } + + /** + * Get the default team definitions for seeding. + */ + public static function getDefaultTeamDefinitions(): array + { + return [ + [ + 'name' => 'Owner', + 'slug' => self::TEAM_OWNER, + 'description' => 'Full ownership access to the workspace.', + 'permissions' => self::getDefaultPermissionsFor(self::TEAM_OWNER), + 'is_system' => true, + 'colour' => 'violet', + 'sort_order' => 1, + ], + [ + 'name' => 'Admin', + 'slug' => self::TEAM_ADMIN, + 'description' => 'Administrative access without billing or deletion rights.', + 'permissions' => self::getDefaultPermissionsFor(self::TEAM_ADMIN), + 'is_system' => true, + 'colour' => 'blue', + 'sort_order' => 2, + ], + [ + 'name' => 'Member', + 'slug' => self::TEAM_MEMBER, + 'description' => 'Standard member access to create and edit content.', + 'permissions' => self::getDefaultPermissionsFor(self::TEAM_MEMBER), + 'is_system' => true, + 'is_default' => true, + 'colour' => 'emerald', + 'sort_order' => 3, + ], + [ + 'name' => 'Viewer', + 'slug' => self::TEAM_VIEWER, + 'description' => 'Read-only access to view content.', + 'permissions' => self::getDefaultPermissionsFor(self::TEAM_VIEWER), + 'is_system' => true, + 'colour' => 'zinc', + 'sort_order' => 4, + ], + ]; + } + + /** + * Get available colour options for teams. + */ + public static function getColourOptions(): array + { + return [ + 'zinc' => 'Grey', + 'red' => 'Red', + 'orange' => 'Orange', + 'amber' => 'Amber', + 'yellow' => 'Yellow', + 'lime' => 'Lime', + 'green' => 'Green', + 'emerald' => 'Emerald', + 'teal' => 'Teal', + 'cyan' => 'Cyan', + 'sky' => 'Sky', + 'blue' => 'Blue', + 'indigo' => 'Indigo', + 'violet' => 'Violet', + 'purple' => 'Purple', + 'fuchsia' => 'Fuchsia', + 'pink' => 'Pink', + 'rose' => 'Rose', + ]; + } +} diff --git a/packages/core-php/src/Mod/Tenant/Services/WorkspaceTeamService.php b/packages/core-php/src/Mod/Tenant/Services/WorkspaceTeamService.php new file mode 100644 index 0000000..34dcf0e --- /dev/null +++ b/packages/core-php/src/Mod/Tenant/Services/WorkspaceTeamService.php @@ -0,0 +1,629 @@ +workspace = $workspace; + } + + /** + * Set the workspace context. + */ + public function forWorkspace(Workspace $workspace): self + { + $this->workspace = $workspace; + + return $this; + } + + /** + * Get the current workspace, resolving from context if needed. + */ + protected function getWorkspace(): ?Workspace + { + if ($this->workspace) { + return $this->workspace; + } + + // Try authenticated user's default workspace first + $this->workspace = auth()->user()?->defaultHostWorkspace(); + + // Fall back to session workspace if set + if (! $this->workspace) { + $sessionWorkspaceId = session('workspace_id'); + if ($sessionWorkspaceId) { + $this->workspace = Workspace::find($sessionWorkspaceId); + } + } + + return $this->workspace; + } + + // ───────────────────────────────────────────────────────────────────────── + // Team Management + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get all teams for the workspace. + */ + public function getTeams(): Collection + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return new Collection; + } + + return WorkspaceTeam::where('workspace_id', $workspace->id) + ->ordered() + ->get(); + } + + /** + * Get a specific team by ID. + */ + public function getTeam(int $teamId): ?WorkspaceTeam + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return null; + } + + return WorkspaceTeam::where('workspace_id', $workspace->id) + ->where('id', $teamId) + ->first(); + } + + /** + * Get a specific team by slug. + */ + public function getTeamBySlug(string $slug): ?WorkspaceTeam + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return null; + } + + return WorkspaceTeam::where('workspace_id', $workspace->id) + ->where('slug', $slug) + ->first(); + } + + /** + * Get the default team for new members. + */ + public function getDefaultTeam(): ?WorkspaceTeam + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return null; + } + + return WorkspaceTeam::where('workspace_id', $workspace->id) + ->where('is_default', true) + ->first(); + } + + /** + * Create a new team. + */ + public function createTeam(array $data): WorkspaceTeam + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + throw new \RuntimeException('No workspace context available.'); + } + + $team = WorkspaceTeam::create([ + 'workspace_id' => $workspace->id, + 'name' => $data['name'], + 'slug' => $data['slug'] ?? null, + 'description' => $data['description'] ?? null, + 'permissions' => $data['permissions'] ?? [], + 'is_default' => $data['is_default'] ?? false, + 'is_system' => $data['is_system'] ?? false, + 'colour' => $data['colour'] ?? 'zinc', + 'sort_order' => $data['sort_order'] ?? 0, + ]); + + // If this is the new default, unset other defaults + if ($team->is_default) { + WorkspaceTeam::where('workspace_id', $workspace->id) + ->where('id', '!=', $team->id) + ->where('is_default', true) + ->update(['is_default' => false]); + } + + Log::info('Workspace team created', [ + 'team_id' => $team->id, + 'team_name' => $team->name, + 'workspace_id' => $workspace->id, + ]); + + return $team; + } + + /** + * Update an existing team. + */ + public function updateTeam(WorkspaceTeam $team, array $data): WorkspaceTeam + { + $workspace = $this->getWorkspace(); + + // Don't allow updating system teams' slug + if ($team->is_system && isset($data['slug'])) { + unset($data['slug']); + } + + $team->update($data); + + // If this is the new default, unset other defaults + if (($data['is_default'] ?? false) && $workspace) { + WorkspaceTeam::where('workspace_id', $workspace->id) + ->where('id', '!=', $team->id) + ->where('is_default', true) + ->update(['is_default' => false]); + } + + Log::info('Workspace team updated', [ + 'team_id' => $team->id, + 'team_name' => $team->name, + 'workspace_id' => $team->workspace_id, + ]); + + return $team; + } + + /** + * Delete a team (only non-system teams). + */ + public function deleteTeam(WorkspaceTeam $team): bool + { + if ($team->is_system) { + throw new \RuntimeException('Cannot delete system teams.'); + } + + // Check if team has any members assigned + $memberCount = WorkspaceMember::where('team_id', $team->id)->count(); + if ($memberCount > 0) { + throw new \RuntimeException( + "Cannot delete team with {$memberCount} assigned members. Remove members first." + ); + } + + $teamId = $team->id; + $teamName = $team->name; + $workspaceId = $team->workspace_id; + + $team->delete(); + + Log::info('Workspace team deleted', [ + 'team_id' => $teamId, + 'team_name' => $teamName, + 'workspace_id' => $workspaceId, + ]); + + return true; + } + + // ───────────────────────────────────────────────────────────────────────── + // Member Management + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get a member record for a user in the workspace. + */ + public function getMember(User|int $user): ?WorkspaceMember + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return null; + } + + $userId = $user instanceof User ? $user->id : $user; + + return WorkspaceMember::where('workspace_id', $workspace->id) + ->where('user_id', $userId) + ->first(); + } + + /** + * Get all members in the workspace. + */ + public function getMembers(): Collection + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return new Collection; + } + + return WorkspaceMember::where('workspace_id', $workspace->id) + ->with(['user', 'team', 'inviter']) + ->get(); + } + + /** + * Get all members in a specific team. + */ + public function getTeamMembers(WorkspaceTeam|int $team): Collection + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return new Collection; + } + + $teamId = $team instanceof WorkspaceTeam ? $team->id : $team; + + return WorkspaceMember::where('workspace_id', $workspace->id) + ->where('team_id', $teamId) + ->with(['user', 'team', 'inviter']) + ->get(); + } + + /** + * Add a member to a team. + */ + public function addMemberToTeam(User|int $user, WorkspaceTeam|int $team): WorkspaceMember + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + throw new \RuntimeException('No workspace context available.'); + } + + $userId = $user instanceof User ? $user->id : $user; + $teamId = $team instanceof WorkspaceTeam ? $team->id : $team; + + // Verify team belongs to workspace + $teamModel = WorkspaceTeam::where('workspace_id', $workspace->id) + ->where('id', $teamId) + ->first(); + + if (! $teamModel) { + throw new \RuntimeException('Team does not belong to the current workspace.'); + } + + $member = WorkspaceMember::where('workspace_id', $workspace->id) + ->where('user_id', $userId) + ->first(); + + if (! $member) { + throw new \RuntimeException('User is not a member of this workspace.'); + } + + $member->update(['team_id' => $teamId]); + + Log::info('Member added to team', [ + 'user_id' => $userId, + 'team_id' => $teamId, + 'team_name' => $teamModel->name, + 'workspace_id' => $workspace->id, + ]); + + return $member->fresh(); + } + + /** + * Remove a member from their team. + */ + public function removeMemberFromTeam(User|int $user): WorkspaceMember + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + throw new \RuntimeException('No workspace context available.'); + } + + $userId = $user instanceof User ? $user->id : $user; + + $member = WorkspaceMember::where('workspace_id', $workspace->id) + ->where('user_id', $userId) + ->first(); + + if (! $member) { + throw new \RuntimeException('User is not a member of this workspace.'); + } + + $oldTeamId = $member->team_id; + $member->update(['team_id' => null]); + + Log::info('Member removed from team', [ + 'user_id' => $userId, + 'old_team_id' => $oldTeamId, + 'workspace_id' => $workspace->id, + ]); + + return $member->fresh(); + } + + /** + * Set custom permissions for a member. + */ + public function setMemberCustomPermissions(User|int $user, array $customPermissions): WorkspaceMember + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + throw new \RuntimeException('No workspace context available.'); + } + + $userId = $user instanceof User ? $user->id : $user; + + $member = WorkspaceMember::where('workspace_id', $workspace->id) + ->where('user_id', $userId) + ->first(); + + if (! $member) { + throw new \RuntimeException('User is not a member of this workspace.'); + } + + $member->update(['custom_permissions' => $customPermissions]); + + Log::info('Member custom permissions updated', [ + 'user_id' => $userId, + 'workspace_id' => $workspace->id, + 'custom_permissions' => $customPermissions, + ]); + + return $member->fresh(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Permission Checks + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get all effective permissions for a user in the workspace. + */ + public function getMemberPermissions(User|int $user): array + { + $member = $this->getMember($user); + + if (! $member) { + return []; + } + + return $member->getEffectivePermissions(); + } + + /** + * Check if a user has a specific permission in the workspace. + */ + public function hasPermission(User|int $user, string $permission): bool + { + $member = $this->getMember($user); + + if (! $member) { + return false; + } + + return $member->hasPermission($permission); + } + + /** + * Check if a user has any of the given permissions. + */ + public function hasAnyPermission(User|int $user, array $permissions): bool + { + $member = $this->getMember($user); + + if (! $member) { + return false; + } + + return $member->hasAnyPermission($permissions); + } + + /** + * Check if a user has all of the given permissions. + */ + public function hasAllPermissions(User|int $user, array $permissions): bool + { + $member = $this->getMember($user); + + if (! $member) { + return false; + } + + return $member->hasAllPermissions($permissions); + } + + /** + * Check if a user is the workspace owner. + */ + public function isOwner(User|int $user): bool + { + $member = $this->getMember($user); + + return $member?->isOwner() ?? false; + } + + /** + * Check if a user is a workspace admin. + */ + public function isAdmin(User|int $user): bool + { + $member = $this->getMember($user); + + return $member?->isAdmin() ?? false; + } + + // ───────────────────────────────────────────────────────────────────────── + // Member Queries + // ───────────────────────────────────────────────────────────────────────── + + /** + * Get members with a specific permission. + */ + public function getMembersWithPermission(string $permission): Collection + { + $members = $this->getMembers(); + + return $members->filter(fn ($member) => $member->hasPermission($permission)); + } + + /** + * Count members in the workspace. + */ + public function countMembers(): int + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return 0; + } + + return WorkspaceMember::where('workspace_id', $workspace->id)->count(); + } + + /** + * Count members in a specific team. + */ + public function countTeamMembers(WorkspaceTeam|int $team): int + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return 0; + } + + $teamId = $team instanceof WorkspaceTeam ? $team->id : $team; + + return WorkspaceMember::where('workspace_id', $workspace->id) + ->where('team_id', $teamId) + ->count(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Seeding + // ───────────────────────────────────────────────────────────────────────── + + /** + * Seed default teams for a workspace. + */ + public function seedDefaultTeams(?Workspace $workspace = null): Collection + { + $workspace = $workspace ?? $this->getWorkspace(); + if (! $workspace) { + throw new \RuntimeException('No workspace context available for seeding.'); + } + + $teams = new Collection; + + foreach (WorkspaceTeam::getDefaultTeamDefinitions() as $definition) { + // Check if team already exists + $existing = WorkspaceTeam::where('workspace_id', $workspace->id) + ->where('slug', $definition['slug']) + ->first(); + + if ($existing) { + $teams->push($existing); + + continue; + } + + $team = WorkspaceTeam::create([ + 'workspace_id' => $workspace->id, + 'name' => $definition['name'], + 'slug' => $definition['slug'], + 'description' => $definition['description'], + 'permissions' => $definition['permissions'], + 'is_default' => $definition['is_default'] ?? false, + 'is_system' => $definition['is_system'] ?? false, + 'colour' => $definition['colour'] ?? 'zinc', + 'sort_order' => $definition['sort_order'] ?? 0, + ]); + + $teams->push($team); + } + + Log::info('Default workspace teams seeded', [ + 'workspace_id' => $workspace->id, + 'teams_count' => $teams->count(), + ]); + + return $teams; + } + + /** + * Ensure default teams exist for the workspace, creating them if needed. + */ + public function ensureDefaultTeams(): Collection + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return new Collection; + } + + // Check if any teams exist + $existingCount = WorkspaceTeam::where('workspace_id', $workspace->id)->count(); + + if ($existingCount === 0) { + return $this->seedDefaultTeams($workspace); + } + + return $this->getTeams(); + } + + /** + * Migrate existing members to appropriate teams based on their role. + */ + public function migrateExistingMembers(): int + { + $workspace = $this->getWorkspace(); + if (! $workspace) { + return 0; + } + + // Ensure teams exist + $this->ensureDefaultTeams(); + + $ownerTeam = $this->getTeamBySlug(WorkspaceTeam::TEAM_OWNER); + $adminTeam = $this->getTeamBySlug(WorkspaceTeam::TEAM_ADMIN); + $memberTeam = $this->getTeamBySlug(WorkspaceTeam::TEAM_MEMBER); + + $migrated = 0; + + DB::transaction(function () use ($workspace, $ownerTeam, $adminTeam, $memberTeam, &$migrated) { + // Get members without team assignments + $members = WorkspaceMember::where('workspace_id', $workspace->id) + ->whereNull('team_id') + ->get(); + + foreach ($members as $member) { + $teamId = match ($member->role) { + WorkspaceMember::ROLE_OWNER => $ownerTeam?->id, + WorkspaceMember::ROLE_ADMIN => $adminTeam?->id, + default => $memberTeam?->id, + }; + + if ($teamId) { + $member->update([ + 'team_id' => $teamId, + 'joined_at' => $member->joined_at ?? $member->created_at, + ]); + $migrated++; + } + } + }); + + Log::info('Workspace members migrated to teams', [ + 'workspace_id' => $workspace->id, + 'migrated_count' => $migrated, + ]); + + return $migrated; + } +}