feat(workspace): implement workspace teams and permissions management with enhanced member model

This commit is contained in:
Snider 2026-01-26 19:00:50 +00:00
parent 537f01672b
commit b0e3ef461f
27 changed files with 4045 additions and 27 deletions

View file

@ -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! 🎉

View file

@ -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
---

View file

@ -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

View file

@ -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)

View file

@ -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

View file

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

View file

@ -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

View file

@ -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<string> 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.
*

View file

@ -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');

View file

@ -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

View file

@ -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');
});

View file

@ -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<int, array{name: string, description: string, category: string, inputSchema: array, examples: array}>
* @return Collection<int, array{name: string, description: string, category: string, inputSchema: array, examples: array, version: string|null}>
*/
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();
});

View file

@ -0,0 +1,478 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Services;
use Core\Mod\Mcp\Models\McpToolVersion;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* Tool Version Service - manages MCP tool versioning for backwards compatibility.
*
* Provides version registration, lookup, comparison, and migration support
* for maintaining compatibility with running agents during tool schema changes.
*/
class ToolVersionService
{
/**
* Cache key prefix for version lookups.
*/
protected const CACHE_PREFIX = 'mcp:tool_version:';
/**
* Cache TTL for version data (5 minutes).
*/
protected const CACHE_TTL = 300;
/**
* Default version for unversioned tools.
*/
public const DEFAULT_VERSION = '1.0.0';
/**
* Register a new tool version.
*
* @param array $options Additional options (changelog, migration_notes, mark_latest)
*/
public function registerVersion(
string $serverId,
string $toolName,
string $version,
?array $inputSchema = null,
?array $outputSchema = null,
?string $description = null,
array $options = []
): McpToolVersion {
// Validate semver format
if (! $this->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<int, McpToolVersion>
*/
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<string, array{latest: McpToolVersion|null, versions: 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");
}
}

View file

@ -0,0 +1,441 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\Tests\Unit;
use Core\Mod\Mcp\Services\ToolVersionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class ToolVersionServiceTest extends TestCase
{
use RefreshDatabase;
protected ToolVersionService $service;
protected function setUp(): void
{
parent::setUp();
$this->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']);
}
}

View file

@ -0,0 +1,537 @@
{{--
MCP Tool Version Manager.
Admin interface for managing tool version lifecycles,
viewing schema changes between versions, and setting deprecation schedules.
--}}
<div class="space-y-6">
{{-- Header --}}
<div class="flex items-center justify-between">
<div>
<core:heading size="xl">{{ __('Tool Versions') }}</core:heading>
<core:subheading>Manage MCP tool version lifecycles and backwards compatibility</core:subheading>
</div>
<div class="flex items-center gap-2">
<flux:button wire:click="openRegisterModal" icon="plus">
Register Version
</flux:button>
</div>
</div>
{{-- Stats Cards --}}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Total Versions</div>
<div class="mt-1 text-2xl font-semibold text-zinc-900 dark:text-white">
{{ number_format($this->stats['total_versions']) }}
</div>
</div>
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Unique Tools</div>
<div class="mt-1 text-2xl font-semibold text-zinc-900 dark:text-white">
{{ number_format($this->stats['total_tools']) }}
</div>
</div>
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Servers</div>
<div class="mt-1 text-2xl font-semibold text-zinc-900 dark:text-white">
{{ number_format($this->stats['servers']) }}
</div>
</div>
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Deprecated</div>
<div class="mt-1 text-2xl font-semibold text-amber-600 dark:text-amber-400">
{{ number_format($this->stats['deprecated_count']) }}
</div>
</div>
<div class="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800">
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Sunset</div>
<div class="mt-1 text-2xl font-semibold text-red-600 dark:text-red-400">
{{ number_format($this->stats['sunset_count']) }}
</div>
</div>
</div>
{{-- Filters --}}
<div class="flex flex-wrap items-end gap-4">
<div class="flex-1 min-w-[200px]">
<flux:input wire:model.live.debounce.300ms="search" placeholder="Search tools, servers, versions..." icon="magnifying-glass" />
</div>
<flux:select wire:model.live="server" placeholder="All servers">
<flux:select.option value="">All servers</flux:select.option>
@foreach ($this->servers as $serverId)
<flux:select.option value="{{ $serverId }}">{{ $serverId }}</flux:select.option>
@endforeach
</flux:select>
<flux:select wire:model.live="status" placeholder="All statuses">
<flux:select.option value="">All statuses</flux:select.option>
<flux:select.option value="latest">Latest</flux:select.option>
<flux:select.option value="active">Active (non-latest)</flux:select.option>
<flux:select.option value="deprecated">Deprecated</flux:select.option>
<flux:select.option value="sunset">Sunset</flux:select.option>
</flux:select>
@if($search || $server || $status)
<flux:button wire:click="clearFilters" variant="ghost" size="sm" icon="x-mark">Clear</flux:button>
@endif
</div>
{{-- Versions Table --}}
<flux:table>
<flux:table.columns>
<flux:table.column>Tool</flux:table.column>
<flux:table.column>Server</flux:table.column>
<flux:table.column>Version</flux:table.column>
<flux:table.column>Status</flux:table.column>
<flux:table.column>Deprecated</flux:table.column>
<flux:table.column>Sunset</flux:table.column>
<flux:table.column>Created</flux:table.column>
<flux:table.column></flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse ($this->versions as $version)
<flux:table.row wire:key="version-{{ $version->id }}">
<flux:table.cell>
<div class="font-medium text-zinc-900 dark:text-white">{{ $version->tool_name }}</div>
@if($version->description)
<div class="text-xs text-zinc-500 truncate max-w-xs">{{ $version->description }}</div>
@endif
</flux:table.cell>
<flux:table.cell class="text-sm text-zinc-600 dark:text-zinc-400">
{{ $version->server_id }}
</flux:table.cell>
<flux:table.cell>
<code class="rounded bg-zinc-100 px-2 py-1 text-sm font-mono dark:bg-zinc-800">
{{ $version->version }}
</code>
</flux:table.cell>
<flux:table.cell>
<flux:badge size="sm" :color="$this->getStatusBadgeColor($version->status)">
{{ ucfirst($version->status) }}
</flux:badge>
</flux:table.cell>
<flux:table.cell class="text-sm text-zinc-500">
@if($version->deprecated_at)
{{ $version->deprecated_at->format('M j, Y') }}
@else
<span class="text-zinc-400">-</span>
@endif
</flux:table.cell>
<flux:table.cell class="text-sm text-zinc-500">
@if($version->sunset_at)
<span class="{{ $version->is_sunset ? 'text-red-600 dark:text-red-400' : '' }}">
{{ $version->sunset_at->format('M j, Y') }}
</span>
@else
<span class="text-zinc-400">-</span>
@endif
</flux:table.cell>
<flux:table.cell class="text-sm text-zinc-500">
{{ $version->created_at->format('M j, Y') }}
</flux:table.cell>
<flux:table.cell>
<flux:dropdown>
<flux:button variant="ghost" size="xs" icon="ellipsis-horizontal" />
<flux:menu>
<flux:menu.item wire:click="viewVersion({{ $version->id }})" icon="eye">
View Details
</flux:menu.item>
@if(!$version->is_latest && !$version->is_sunset)
<flux:menu.item wire:click="markAsLatest({{ $version->id }})" icon="star">
Mark as Latest
</flux:menu.item>
@endif
@if(!$version->is_deprecated && !$version->is_sunset)
<flux:menu.item wire:click="openDeprecateModal({{ $version->id }})" icon="archive-box">
Deprecate
</flux:menu.item>
@endif
</flux:menu>
</flux:dropdown>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="8">
<div class="flex flex-col items-center py-12">
<div class="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mb-4">
<flux:icon name="cube" class="size-8 text-zinc-400" />
</div>
<flux:heading size="lg">No tool versions found</flux:heading>
<flux:subheading class="mt-1">Register tool versions to enable backwards compatibility.</flux:subheading>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
@if($this->versions->hasPages())
<div class="border-t border-zinc-200 px-6 py-4 dark:border-zinc-700">
{{ $this->versions->links() }}
</div>
@endif
{{-- Version Detail Modal --}}
@if($showVersionDetail && $this->selectedVersion)
<flux:modal wire:model="showVersionDetail" name="version-detail" class="max-w-4xl">
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<flux:heading size="lg">{{ $this->selectedVersion->tool_name }}</flux:heading>
<div class="flex items-center gap-2 mt-1">
<code class="rounded bg-zinc-100 px-2 py-1 text-sm font-mono dark:bg-zinc-800">
{{ $this->selectedVersion->version }}
</code>
<flux:badge size="sm" :color="$this->getStatusBadgeColor($this->selectedVersion->status)">
{{ ucfirst($this->selectedVersion->status) }}
</flux:badge>
</div>
</div>
<flux:button wire:click="closeVersionDetail" variant="ghost" icon="x-mark" />
</div>
{{-- Metadata --}}
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Server</div>
<div class="mt-1">{{ $this->selectedVersion->server_id }}</div>
</div>
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Created</div>
<div class="mt-1">{{ $this->selectedVersion->created_at->format('Y-m-d H:i:s') }}</div>
</div>
@if($this->selectedVersion->deprecated_at)
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Deprecated</div>
<div class="mt-1 text-amber-600 dark:text-amber-400">
{{ $this->selectedVersion->deprecated_at->format('Y-m-d') }}
</div>
</div>
@endif
@if($this->selectedVersion->sunset_at)
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Sunset</div>
<div class="mt-1 {{ $this->selectedVersion->is_sunset ? 'text-red-600 dark:text-red-400' : 'text-zinc-600' }}">
{{ $this->selectedVersion->sunset_at->format('Y-m-d') }}
</div>
</div>
@endif
</div>
@if($this->selectedVersion->description)
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Description</div>
<div class="mt-1">{{ $this->selectedVersion->description }}</div>
</div>
@endif
@if($this->selectedVersion->changelog)
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Changelog</div>
<div class="mt-1 prose prose-sm dark:prose-invert">
{!! nl2br(e($this->selectedVersion->changelog)) !!}
</div>
</div>
@endif
@if($this->selectedVersion->migration_notes)
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<div class="flex items-center gap-2 text-blue-700 dark:text-blue-300">
<flux:icon name="arrow-path" class="size-5" />
<span class="font-medium">Migration Notes</span>
</div>
<div class="mt-2 text-sm text-blue-600 dark:text-blue-400">
{!! nl2br(e($this->selectedVersion->migration_notes)) !!}
</div>
</div>
@endif
{{-- Input Schema --}}
@if($this->selectedVersion->input_schema)
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400 mb-2">Input Schema</div>
<pre class="overflow-auto rounded-lg bg-zinc-100 p-4 text-xs dark:bg-zinc-800 max-h-60">{{ $this->formatSchema($this->selectedVersion->input_schema) }}</pre>
</div>
@endif
{{-- Output Schema --}}
@if($this->selectedVersion->output_schema)
<div>
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400 mb-2">Output Schema</div>
<pre class="overflow-auto rounded-lg bg-zinc-100 p-4 text-xs dark:bg-zinc-800 max-h-60">{{ $this->formatSchema($this->selectedVersion->output_schema) }}</pre>
</div>
@endif
{{-- Version History --}}
@if($this->versionHistory->count() > 1)
<div class="border-t border-zinc-200 pt-4 dark:border-zinc-700">
<div class="text-sm font-medium text-zinc-500 dark:text-zinc-400 mb-3">Version History</div>
<div class="space-y-2">
@foreach($this->versionHistory as $index => $historyVersion)
<div class="flex items-center justify-between rounded-lg border border-zinc-200 p-3 dark:border-zinc-700 {{ $historyVersion->id === $this->selectedVersion->id ? 'bg-zinc-50 dark:bg-zinc-800/50' : '' }}">
<div class="flex items-center gap-3">
<code class="rounded bg-zinc-100 px-2 py-1 text-sm font-mono dark:bg-zinc-800">
{{ $historyVersion->version }}
</code>
<flux:badge size="sm" :color="$this->getStatusBadgeColor($historyVersion->status)">
{{ ucfirst($historyVersion->status) }}
</flux:badge>
<span class="text-xs text-zinc-500">
{{ $historyVersion->created_at->format('M j, Y') }}
</span>
</div>
@if($historyVersion->id !== $this->selectedVersion->id && $index < $this->versionHistory->count() - 1)
@php $nextVersion = $this->versionHistory[$index + 1] @endphp
<flux:button
wire:click="openCompareModal({{ $nextVersion->id }}, {{ $historyVersion->id }})"
variant="ghost"
size="xs"
icon="arrows-right-left"
>
Compare
</flux:button>
@endif
</div>
@endforeach
</div>
</div>
@endif
</div>
</flux:modal>
@endif
{{-- Compare Schemas Modal --}}
@if($showCompareModal && $this->schemaComparison)
<flux:modal wire:model="showCompareModal" name="compare-modal" class="max-w-4xl">
<div class="space-y-6">
<div class="flex items-center justify-between">
<flux:heading size="lg">Schema Comparison</flux:heading>
<flux:button wire:click="closeCompareModal" variant="ghost" icon="x-mark" />
</div>
<div class="flex items-center justify-center gap-4">
<div class="text-center">
<code class="rounded bg-zinc-100 px-3 py-1 text-sm font-mono dark:bg-zinc-800">
{{ $this->schemaComparison['from']->version }}
</code>
</div>
<flux:icon name="arrow-right" class="size-5 text-zinc-400" />
<div class="text-center">
<code class="rounded bg-zinc-100 px-3 py-1 text-sm font-mono dark:bg-zinc-800">
{{ $this->schemaComparison['to']->version }}
</code>
</div>
</div>
@php $changes = $this->schemaComparison['changes'] @endphp
@if(empty($changes['added']) && empty($changes['removed']) && empty($changes['changed']))
<div class="rounded-lg bg-green-50 p-4 dark:bg-green-900/20">
<div class="flex items-center gap-2 text-green-700 dark:text-green-300">
<flux:icon name="check-circle" class="size-5" />
<span>No schema changes between versions</span>
</div>
</div>
@else
<div class="space-y-4">
@if(!empty($changes['added']))
<div class="rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-900/20">
<div class="font-medium text-green-700 dark:text-green-300 mb-2">
Added Properties ({{ count($changes['added']) }})
</div>
<ul class="list-disc list-inside text-sm text-green-600 dark:text-green-400">
@foreach($changes['added'] as $prop)
<li><code>{{ $prop }}</code></li>
@endforeach
</ul>
</div>
@endif
@if(!empty($changes['removed']))
<div class="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
<div class="font-medium text-red-700 dark:text-red-300 mb-2">
Removed Properties ({{ count($changes['removed']) }})
</div>
<ul class="list-disc list-inside text-sm text-red-600 dark:text-red-400">
@foreach($changes['removed'] as $prop)
<li><code>{{ $prop }}</code></li>
@endforeach
</ul>
</div>
@endif
@if(!empty($changes['changed']))
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20">
<div class="font-medium text-amber-700 dark:text-amber-300 mb-2">
Changed Properties ({{ count($changes['changed']) }})
</div>
<div class="space-y-3">
@foreach($changes['changed'] as $prop => $change)
<div class="text-sm">
<code class="font-medium text-amber-700 dark:text-amber-300">{{ $prop }}</code>
<div class="mt-1 grid grid-cols-2 gap-2 text-xs">
<div class="rounded bg-red-100 p-2 dark:bg-red-900/30">
<div class="text-red-600 dark:text-red-400 mb-1">Before:</div>
<pre class="text-red-700 dark:text-red-300 overflow-auto">{{ json_encode($change['from'], JSON_PRETTY_PRINT) }}</pre>
</div>
<div class="rounded bg-green-100 p-2 dark:bg-green-900/30">
<div class="text-green-600 dark:text-green-400 mb-1">After:</div>
<pre class="text-green-700 dark:text-green-300 overflow-auto">{{ json_encode($change['to'], JSON_PRETTY_PRINT) }}</pre>
</div>
</div>
</div>
@endforeach
</div>
</div>
@endif
</div>
@endif
<div class="flex justify-end">
<flux:button wire:click="closeCompareModal" variant="primary">Close</flux:button>
</div>
</div>
</flux:modal>
@endif
{{-- Deprecate Modal --}}
@if($showDeprecateModal)
@php $deprecateVersion = \Core\Mod\Mcp\Models\McpToolVersion::find($deprecateVersionId) @endphp
@if($deprecateVersion)
<flux:modal wire:model="showDeprecateModal" name="deprecate-modal" class="max-w-md">
<div class="space-y-6">
<div class="flex items-center justify-between">
<flux:heading size="lg">Deprecate Version</flux:heading>
<flux:button wire:click="closeDeprecateModal" variant="ghost" icon="x-mark" />
</div>
<div class="rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
<div class="flex items-center gap-2 text-amber-700 dark:text-amber-300">
<flux:icon name="exclamation-triangle" class="size-5" />
<span class="font-medium">{{ $deprecateVersion->tool_name }} v{{ $deprecateVersion->version }}</span>
</div>
<p class="mt-2 text-sm text-amber-600 dark:text-amber-400">
Deprecated versions will show warnings to agents but remain usable until sunset.
</p>
</div>
<div>
<flux:label>Sunset Date (optional)</flux:label>
<flux:input
type="date"
wire:model="deprecateSunsetDate"
:min="now()->addDay()->format('Y-m-d')"
/>
<flux:description class="mt-1">
After this date, the version will be blocked and return errors.
</flux:description>
</div>
<div class="flex justify-end gap-2">
<flux:button wire:click="closeDeprecateModal" variant="ghost">Cancel</flux:button>
<flux:button wire:click="deprecateVersion" variant="primary" color="amber">
Deprecate Version
</flux:button>
</div>
</div>
</flux:modal>
@endif
@endif
{{-- Register Version Modal --}}
@if($showRegisterModal)
<flux:modal wire:model="showRegisterModal" name="register-modal" class="max-w-2xl">
<div class="space-y-6">
<div class="flex items-center justify-between">
<flux:heading size="lg">Register Tool Version</flux:heading>
<flux:button wire:click="closeRegisterModal" variant="ghost" icon="x-mark" />
</div>
<form wire:submit="registerVersion" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<flux:label>Server ID</flux:label>
<flux:input
wire:model="registerServer"
placeholder="e.g., hub-agent"
required
/>
@error('registerServer') <flux:error>{{ $message }}</flux:error> @enderror
</div>
<div>
<flux:label>Tool Name</flux:label>
<flux:input
wire:model="registerTool"
placeholder="e.g., query_database"
required
/>
@error('registerTool') <flux:error>{{ $message }}</flux:error> @enderror
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<flux:label>Version (semver)</flux:label>
<flux:input
wire:model="registerVersion"
placeholder="1.0.0"
required
/>
@error('registerVersion') <flux:error>{{ $message }}</flux:error> @enderror
</div>
<div class="flex items-end">
<flux:checkbox wire:model="registerMarkLatest" label="Mark as latest version" />
</div>
</div>
<div>
<flux:label>Description</flux:label>
<flux:input
wire:model="registerDescription"
placeholder="Brief description of the tool"
/>
@error('registerDescription') <flux:error>{{ $message }}</flux:error> @enderror
</div>
<div>
<flux:label>Changelog</flux:label>
<flux:textarea
wire:model="registerChangelog"
placeholder="What changed in this version..."
rows="3"
/>
@error('registerChangelog') <flux:error>{{ $message }}</flux:error> @enderror
</div>
<div>
<flux:label>Migration Notes</flux:label>
<flux:textarea
wire:model="registerMigrationNotes"
placeholder="Guidance for upgrading from previous version..."
rows="3"
/>
@error('registerMigrationNotes') <flux:error>{{ $message }}</flux:error> @enderror
</div>
<div>
<flux:label>Input Schema (JSON)</flux:label>
<flux:textarea
wire:model="registerInputSchema"
placeholder='{"type": "object", "properties": {...}}'
rows="6"
class="font-mono text-sm"
/>
@error('registerInputSchema') <flux:error>{{ $message }}</flux:error> @enderror
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-zinc-200 dark:border-zinc-700">
<flux:button type="button" wire:click="closeRegisterModal" variant="ghost">Cancel</flux:button>
<flux:button type="submit" variant="primary">Register Version</flux:button>
</div>
</form>
</div>
</flux:modal>
@endif
</div>

View file

@ -0,0 +1,349 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Mcp\View\Modal\Admin;
use Core\Mod\Mcp\Models\McpToolVersion;
use Core\Mod\Mcp\Services\ToolVersionService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
/**
* MCP Tool Version Manager.
*
* Admin interface for managing tool version lifecycles,
* viewing schema changes, and setting deprecation schedules.
*/
#[Title('Tool Versions')]
#[Layout('hub::admin.layouts.app')]
class ToolVersionManager extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $server = '';
#[Url]
public string $status = '';
public int $perPage = 25;
// Modal state
public bool $showVersionDetail = false;
public ?int $selectedVersionId = null;
public bool $showCompareModal = false;
public ?int $compareFromId = null;
public ?int $compareToId = null;
public bool $showDeprecateModal = false;
public ?int $deprecateVersionId = null;
public string $deprecateSunsetDate = '';
public bool $showRegisterModal = false;
public string $registerServer = '';
public string $registerTool = '';
public string $registerVersion = '';
public string $registerDescription = '';
public string $registerChangelog = '';
public string $registerMigrationNotes = '';
public string $registerInputSchema = '';
public bool $registerMarkLatest = false;
public function mount(): void
{
$this->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');
}
}

View file

@ -6,7 +6,7 @@
"authors": [
{
"name": "Host UK",
"email": "dev@host.uk.com"
"email": "support@host.uk.com"
}
],
"require": {

View file

@ -26,7 +26,7 @@ namespace Core\Headers;
* </script>
*
* <style nonce="{{ csp_nonce() }}">
* /* Your inline CSS */
* // Your inline CSS
* </style>
* ```
*

View file

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

View file

@ -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);

View file

@ -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

View file

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Tenant\Middleware;
use Closure;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\WorkspaceTeamService;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Middleware to check if the current user has a specific workspace permission.
*
* Usage in routes:
* Route::middleware('workspace.permission:bio.write')
* Route::middleware('workspace.permission:workspace.manage_settings,workspace.manage_members')
*
* The middleware checks if the user has ANY of the specified permissions (OR logic).
* Use multiple middleware definitions for AND logic.
*/
class CheckWorkspacePermission
{
public function __construct(
protected WorkspaceTeamService $teamService
) {}
public function handle(Request $request, Closure $next, string ...$permissions): Response
{
$user = $request->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;
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Workspace teams and enhanced member pivot for role-based access control.
*/
public function up(): void
{
// 1. Create workspace teams table
Schema::create('workspace_teams', function (Blueprint $table) {
$table->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');
}
};

View file

@ -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.
*/

View file

@ -0,0 +1,377 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Tenant\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Workspace Member - enhanced pivot model for user-workspace relationship.
*
* This model wraps the user_workspace pivot table to provide team-based
* access control with custom permission overrides.
*
* @property int $id
* @property int $user_id
* @property int $workspace_id
* @property string $role
* @property int|null $team_id
* @property array|null $custom_permissions
* @property bool $is_default
* @property \Carbon\Carbon|null $joined_at
* @property int|null $invited_by
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class WorkspaceMember extends Model
{
protected $table = 'user_workspace';
protected $fillable = [
'user_id',
'workspace_id',
'role',
'team_id',
'custom_permissions',
'is_default',
'joined_at',
'invited_by',
];
protected $casts = [
'custom_permissions' => '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',
};
}
}

View file

@ -0,0 +1,517 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Tenant\Models;
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
/**
* Workspace Team - defines permissions for members within a workspace.
*
* Teams provide role-based access control at the workspace level. Members
* can belong to a team and optionally have custom permission overrides.
*
* @property int $id
* @property int $workspace_id
* @property string $name
* @property string $slug
* @property string|null $description
* @property array|null $permissions
* @property bool $is_default
* @property bool $is_system
* @property string $colour
* @property int $sort_order
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class WorkspaceTeam extends Model
{
use BelongsToWorkspace;
use HasFactory;
protected $table = 'workspace_teams';
// ─────────────────────────────────────────────────────────────────────────
// Team Constants
// ─────────────────────────────────────────────────────────────────────────
public const TEAM_OWNER = 'owner';
public const TEAM_ADMIN = 'admin';
public const TEAM_MEMBER = 'member';
public const TEAM_VIEWER = 'viewer';
// ─────────────────────────────────────────────────────────────────────────
// Permission Constants - Workspace
// ─────────────────────────────────────────────────────────────────────────
public const PERM_WORKSPACE_SETTINGS = 'workspace.manage_settings';
public const PERM_WORKSPACE_MEMBERS = 'workspace.manage_members';
public const PERM_WORKSPACE_BILLING = 'workspace.manage_billing';
public const PERM_WORKSPACE_TEAMS = 'workspace.manage_teams';
public const PERM_WORKSPACE_DELETE = 'workspace.delete';
// ─────────────────────────────────────────────────────────────────────────
// Permission Constants - Products (generic pattern: [product].read/write/admin)
// ─────────────────────────────────────────────────────────────────────────
// Bio service
public const PERM_BIO_READ = 'bio.read';
public const PERM_BIO_WRITE = 'bio.write';
public const PERM_BIO_ADMIN = 'bio.admin';
// Social service
public const PERM_SOCIAL_READ = 'social.read';
public const PERM_SOCIAL_WRITE = 'social.write';
public const PERM_SOCIAL_ADMIN = 'social.admin';
// Analytics service
public const PERM_ANALYTICS_READ = 'analytics.read';
public const PERM_ANALYTICS_WRITE = 'analytics.write';
public const PERM_ANALYTICS_ADMIN = 'analytics.admin';
// Trust service
public const PERM_TRUST_READ = 'trust.read';
public const PERM_TRUST_WRITE = 'trust.write';
public const PERM_TRUST_ADMIN = 'trust.admin';
// Notify service
public const PERM_NOTIFY_READ = 'notify.read';
public const PERM_NOTIFY_WRITE = 'notify.write';
public const PERM_NOTIFY_ADMIN = 'notify.admin';
// Support service
public const PERM_SUPPORT_READ = 'support.read';
public const PERM_SUPPORT_WRITE = 'support.write';
public const PERM_SUPPORT_ADMIN = 'support.admin';
// Commerce/billing
public const PERM_COMMERCE_READ = 'commerce.read';
public const PERM_COMMERCE_WRITE = 'commerce.write';
public const PERM_COMMERCE_ADMIN = 'commerce.admin';
// API management
public const PERM_API_READ = 'api.read';
public const PERM_API_WRITE = 'api.write';
public const PERM_API_ADMIN = 'api.admin';
protected $fillable = [
'workspace_id',
'name',
'slug',
'description',
'permissions',
'is_default',
'is_system',
'colour',
'sort_order',
];
protected $casts = [
'permissions' => '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',
];
}
}

View file

@ -0,0 +1,629 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Tenant\Services;
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Models\WorkspaceMember;
use Core\Mod\Tenant\Models\WorkspaceTeam;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Workspace Team Service - manages workspace teams and member permissions.
*/
class WorkspaceTeamService
{
protected ?Workspace $workspace = null;
public function __construct(?Workspace $workspace = null)
{
$this->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;
}
}