diff --git a/.gitignore b/.gitignore
index 3ca00a5..ee4c9de 100755
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,6 @@ settings.local.json
# Upload files (keep folder structure, ignore actual uploads)
uploads/*
!uploads/.gitkeep
-!uploads/.htaccess
\ No newline at end of file
+!uploads/.htaccess
+# Composer dependencies (used only by the MCP endpoint; installed at deploy time)
+vendor/
diff --git a/.phpcs.xml b/.phpcs.xml
index 58389b9..74d6774 100644
--- a/.phpcs.xml
+++ b/.phpcs.xml
@@ -6,6 +6,7 @@
*/uploads/*
*/migrations/*
*/.gitea/*
+ */vendor/*
diff --git a/api/add_comment.php b/api/add_comment.php
index 0a17e8b..9e14e65 100644
--- a/api/add_comment.php
+++ b/api/add_comment.php
@@ -80,155 +80,22 @@ try {
exit;
}
- $ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
- if (!ctype_digit($ticketId) || (int)$ticketId <= 0) {
- http_response_code(400);
+ // Validation, access check, mentions, audit log and notifications live in
+ // CommentService so the MCP add_comment tool runs the same code path.
+ require_once dirname(__DIR__) . '/services/CommentService.php';
+ $result = CommentService::addComment($conn, $currentUser, $data);
+
+ if (!empty($result['http_status'])) {
+ http_response_code($result['http_status']);
+ unset($result['http_status']);
ob_end_clean();
header('Content-Type: application/json');
- echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
+ echo json_encode($result);
exit;
}
- // Reject empty/whitespace-only comments
- $commentTextRaw = isset($data['comment_text']) ? trim((string)$data['comment_text']) : '';
- if ($commentTextRaw === '') {
- http_response_code(400);
- ob_end_clean();
- header('Content-Type: application/json');
- echo json_encode(['success' => false, 'error' => 'Comment text cannot be empty']);
- exit;
- }
-
- // Persist the trimmed text (not the raw client value) — matches update_comment.php
- // and keeps stored comment_text free of leading whitespace that could shift a
- // markdown-enabled comment's first line out of column 0 on reload.
- $data['comment_text'] = $commentTextRaw;
-
- // Never trust a client-supplied display name — always attribute the comment to
- // the authenticated session user.
- $data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
-
- // Verify user can access the ticket before allowing a comment
- $ticketModel = new TicketModel($conn);
- $ticket = $ticketModel->getTicketById($ticketId);
- if (!$ticket) {
- http_response_code(404);
- ob_end_clean();
- header('Content-Type: application/json');
- echo json_encode(['success' => false, 'error' => 'Ticket not found']);
- exit;
- }
- if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
- http_response_code(403);
- ob_end_clean();
- header('Content-Type: application/json');
- echo json_encode(['success' => false, 'error' => 'Access denied']);
- exit;
- }
-
- // Initialize models
- $commentModel = new CommentModel($conn);
- $auditLog = new AuditLogModel($conn);
-
- // If replying, the parent comment must belong to this same (accessible) ticket.
- if (isset($data['parent_comment_id']) && $data['parent_comment_id'] !== null && $data['parent_comment_id'] !== '') {
- $parentComment = $commentModel->getCommentById((int)$data['parent_comment_id']);
- if (!$parentComment || (string)$parentComment['ticket_id'] !== (string)$ticketId) {
- http_response_code(400);
- ob_end_clean();
- header('Content-Type: application/json');
- echo json_encode(['success' => false, 'error' => 'Invalid parent comment']);
- exit;
- }
- }
-
- // Extract @mentions from comment text
- $mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
- $mentionedUsers = [];
- if (!empty($mentions)) {
- $mentionedUsers = $commentModel->getMentionedUsers($mentions);
- }
-
- // Add comment with user tracking
- $result = $commentModel->addComment($ticketId, $data, $userId);
-
- // Log comment creation to audit log
- if ($result['success'] && isset($result['comment_id'])) {
- $auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
-
- // Log mentions to audit log
- foreach ($mentionedUsers as $mentionedUser) {
- $auditLog->log(
- $userId,
- 'mention',
- 'user',
- (string)$mentionedUser['user_id'],
- [
- 'ticket_id' => $ticketId,
- 'comment_id' => $result['comment_id'],
- 'mentioned_username' => $mentionedUser['username']
- ]
- );
- }
-
- // Matrix notifications
- $authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
- $commentText = $data['comment_text'] ?? '';
- $ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
- $ticketVisibility = $ticket['visibility'] ?? 'public';
-
- // @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API.
- // Only notify mentioned users who actually have access to this ticket;
- // otherwise a mention would DM them the ticket's title and comment text
- // even though canUserAccessTicket() would deny them the ticket itself.
- $accessibleMentionedUsers = array_filter(
- $mentionedUsers,
- fn($u) => $ticketModel->canUserAccessTicket($ticket, $u)
- );
- if (!empty($accessibleMentionedUsers)) {
- $mentionedUsernames = array_column($accessibleMentionedUsers, 'username');
- $mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
- if (!empty($mentionedMatrixIds)) {
- NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
- }
- }
-
- // General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
- if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
- NotificationHelper::sendCommentNotification(
- $ticketId,
- $ticketTitle,
- $commentText,
- $authorDisplay,
- $ticketVisibility !== 'public',
- $ticketVisibility
- );
- }
-
- // Notify watchers of the new comment
- NotificationHelper::notifyWatchers(
- $conn,
- $ticketId,
- $ticketTitle,
- 'comment_added',
- ['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
- (int)$userId,
- $ticketVisibility
- );
-
- // Add mentioned users to result for frontend
- $result['mentions'] = array_map(function ($u) {
- return $u['username'];
- }, $mentionedUsers);
- }
-
- // Add user info to result for frontend avatar rendering
- if ($result['success']) {
- $result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
- $result['user_id'] = $userId;
- if (isset($newCsrfToken)) {
- $result['csrf_token'] = $newCsrfToken;
- }
+ if ($result['success'] && isset($newCsrfToken)) {
+ $result['csrf_token'] = $newCsrfToken;
}
// Discard any unexpected output
diff --git a/api/assign_ticket.php b/api/assign_ticket.php
index 1046909..28ced6c 100644
--- a/api/assign_ticket.php
+++ b/api/assign_ticket.php
@@ -1,11 +1,7 @@
false, 'error' => 'Ticket ID required']);
- exit;
-}
-$ticketId = $ticketIdRaw;
-
-$ticketModel = new TicketModel($conn);
-$auditLogModel = new AuditLogModel($conn);
-$userModel = new UserModel($conn);
-
-// Verify ticket exists and user can access it
-$ticket = $ticketModel->getTicketById($ticketId);
-if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
- http_response_code(404);
- echo json_encode(['success' => false, 'error' => 'Ticket not found']);
- exit;
-}
-
-// Authorization: only admins or the ticket creator/assignee can reassign
-if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) {
- http_response_code(403);
- echo json_encode(['success' => false, 'error' => 'Permission denied']);
- exit;
-}
-
-if ($assignedTo === null || $assignedTo === '') {
- // Unassign ticket
- $success = $ticketModel->unassignTicket($ticketId, $userId);
- if ($success) {
- $auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
- }
-} else {
- // Validate assigned_to is a valid user ID
- $assignedTo = (int)$assignedTo;
- $targetUser = $userModel->getUserById($assignedTo);
- if (!$targetUser) {
- http_response_code(400);
- echo json_encode(['success' => false, 'error' => 'Invalid user ID']);
- exit;
- }
-
- // Assign ticket
- $success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
- if ($success) {
- $auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
-
- if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) {
- $changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
- $assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null;
- $assigneeMatrix = isset($targetUser['username'])
- ? SynapseHelper::resolveUsername($targetUser['username'])
- : null;
- NotificationHelper::sendAssignmentNotification(
- $ticketId,
- $ticket['title'] ?? "Ticket #{$ticketId}",
- $assigneeName,
- $assigneeMatrix,
- $changedByDisplay,
- $ticket['visibility'] ?? 'public'
- );
- }
- }
-}
-
-if (!$success) {
- http_response_code(500);
- apiRespond(['success' => false, 'error' => 'Failed to update ticket assignment']);
-} else {
- require_once dirname(__DIR__) . '/models/StatsModel.php';
- (new StatsModel($conn))->invalidateCache();
- apiRespond(['success' => true]);
+if (!empty($result['http_status'])) {
+ http_response_code($result['http_status']);
+ unset($result['http_status']);
}
+apiRespond($result);
diff --git a/api/update_ticket.php b/api/update_ticket.php
index 08c4c22..c8825b0 100644
--- a/api/update_ticket.php
+++ b/api/update_ticket.php
@@ -62,262 +62,7 @@ try {
$userId = $currentUser['user_id'];
$isAdmin = $currentUser['is_admin'] ?? false;
- // Updated controller class that handles partial updates
- class ApiTicketController
- {
- private $conn;
- private $ticketModel;
- private $commentModel;
- private $auditLog;
- private $workflowModel;
- private $userId;
- private $isAdmin;
- private $currentUser;
-
- public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = [])
- {
- $this->conn = $conn;
- $this->ticketModel = new TicketModel($conn);
- $this->commentModel = new CommentModel($conn);
- $this->auditLog = new AuditLogModel($conn);
- $this->workflowModel = new WorkflowModel($conn);
- $this->userId = $userId;
- $this->isAdmin = $isAdmin;
- $this->currentUser = $currentUser;
- }
-
- public function update($id, $data)
- {
- // First, get the current ticket data to fill in missing fields
- $currentTicket = $this->ticketModel->getTicketById($id);
- if (!$currentTicket) {
- return [
- 'success' => false,
- 'error' => 'Ticket not found'
- ];
- }
-
- // Visibility check: return 404 for tickets the user cannot access
- if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) {
- return [
- 'success' => false,
- 'error' => 'Ticket not found',
- 'http_status' => 404
- ];
- }
-
- // Any authenticated team member can update tickets.
- // Admin-only operations (delete, bulk actions) are enforced separately.
-
- // Merge current data with updates, keeping existing values for missing fields
- $updateData = [
- 'ticket_id' => $id,
- 'title' => $data['title'] ?? $currentTicket['title'],
- 'description' => $data['description'] ?? $currentTicket['description'],
- 'category' => $data['category'] ?? $currentTicket['category'],
- 'type' => $data['type'] ?? $currentTicket['type'],
- 'status' => $data['status'] ?? $currentTicket['status'],
- 'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority']
- ];
-
- // Validate required fields
- if (empty($updateData['title'])) {
- return [
- 'success' => false,
- 'error' => 'Title cannot be empty',
- 'http_status' => 400
- ];
- }
-
- // Validate priority range
- if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
- return [
- 'success' => false,
- 'error' => 'Priority must be between 1 and 5',
- 'http_status' => 400
- ];
- }
-
- // Validate visibility BEFORE any DB write so a bad payload can't leave the
- // ticket half-updated (core fields committed but request reported as failed).
- $visibilityGroups = null;
- if (isset($data['visibility'])) {
- $visibilityGroups = $data['visibility_groups'] ?? null;
- // Convert array to comma-separated string if needed
- if (is_array($visibilityGroups)) {
- $visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
- }
-
- // Authorization: only an admin or the ticket's creator may change
- // visibility. Enforce only when the requested visibility actually
- // differs so ordinary edits that re-send the same value aren't blocked.
- $currentVisibility = $currentTicket['visibility'] ?? 'public';
- $currentGroups = $currentTicket['visibility_groups'] ?? null;
- $groupsProvided = array_key_exists('visibility_groups', $data);
- $visibilityChanged = ($data['visibility'] !== $currentVisibility)
- || ($groupsProvided && (string)$visibilityGroups !== (string)$currentGroups);
- if ($visibilityChanged) {
- $isCreator = $this->userId !== null
- && (int)($currentTicket['created_by'] ?? 0) === (int)$this->userId;
- if (!$this->isAdmin && !$isCreator) {
- return [
- 'success' => false,
- 'error' => 'You do not have permission to change ticket visibility',
- 'http_status' => 403
- ];
- }
- }
-
- // Internal visibility requires at least one group
- if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
- return [
- 'success' => false,
- 'error' => 'Internal visibility requires at least one group to be specified',
- 'http_status' => 400
- ];
- }
- }
-
- // Validate status transition using workflow model
- if ($currentTicket['status'] !== $updateData['status']) {
- $allowed = $this->workflowModel->isTransitionAllowed(
- $currentTicket['status'],
- $updateData['status'],
- $this->isAdmin
- );
-
- if (!$allowed) {
- return [
- 'success' => false,
- 'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
- ];
- }
-
- // Enforce requires_comment transitions server-side.
- if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) {
- $statusChangeComment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
- if ($statusChangeComment === '') {
- return [
- 'success' => false,
- 'error' => 'A comment is required for this status change',
- 'requires_comment' => true,
- 'http_status' => 400
- ];
- }
- }
- }
-
- // A comment accompanying a status change (required or optional) is
- // persisted in the SAME transaction as the status update below, so
- // a failure partway through can't leave an orphaned "reason"
- // comment attached with no matching status change — the two
- // previously ran as separate, non-transactional HTTP calls from
- // the client (add_comment.php then update_ticket.php).
- $statusChangeComment = $statusChangeComment ?? trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
-
- $result = null;
- $this->conn->begin_transaction();
- try {
- if ($statusChangeComment !== '' && $currentTicket['status'] !== $updateData['status']) {
- $commentResult = $this->commentModel->addComment($id, [
- 'user_name' => $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? 'User',
- 'comment_text' => $statusChangeComment,
- 'markdown_enabled' => !empty($data['markdown_enabled']),
- ], $this->userId);
- if (empty($commentResult['success'])) {
- throw new Exception($commentResult['error'] ?? 'Failed to add comment');
- }
- }
-
- // Update ticket with user tracking and optional optimistic locking
- $expectedUpdatedAt = $data['expected_updated_at'] ?? null;
- $result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
- if (!$result['success']) {
- throw new Exception($result['error'] ?? 'Failed to update ticket in database');
- }
-
- // Handle visibility update if provided (already validated above)
- if (isset($data['visibility'])) {
- $visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
- if (!$visResult) {
- throw new Exception('Failed to update ticket visibility');
- }
- }
-
- $this->conn->commit();
- } catch (Exception $e) {
- $this->conn->rollback();
- $response = ['success' => false, 'error' => $e->getMessage()];
- if (is_array($result) && !empty($result['conflict'])) {
- $response['conflict'] = true;
- $response['current_updated_at'] = $result['current_updated_at'] ?? null;
- }
- return $response;
- }
-
- if (isset($data['visibility']) && $this->userId) {
- $this->auditLog->log(
- $this->userId,
- 'update',
- 'ticket',
- (string)$id,
- [
- 'field' => 'visibility',
- 'from' => $currentTicket['visibility'] ?? 'public',
- 'to' => $data['visibility'],
- 'groups' => $visibilityGroups
- ]
- );
- }
-
- // Log ticket update to audit log — only the changed fields (delta)
- if ($this->userId) {
- $trackFields = ['title', 'priority', 'status', 'description', 'category', 'type'];
- $delta = [];
- foreach ($trackFields as $field) {
- $oldVal = (string)($currentTicket[$field] ?? '');
- $newVal = (string)($updateData[$field] ?? '');
- if ($oldVal !== $newVal) {
- $delta[$field] = ['from' => $oldVal, 'to' => $newVal];
- }
- }
- if (!empty($delta)) {
- $this->auditLog->logTicketUpdate($this->userId, $id, $delta);
- }
- }
-
- // Notify on status change (global notify list + watchers)
- if ($currentTicket['status'] !== $updateData['status']) {
- $changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null;
- NotificationHelper::sendStatusChangeNotification(
- $id,
- $currentTicket['status'],
- $updateData['status'],
- $updateData['title'],
- $changedBy,
- $currentTicket['visibility'] ?? 'public'
- );
- NotificationHelper::notifyWatchers(
- $this->conn,
- $id,
- $updateData['title'],
- 'status_changed',
- ['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
- (int)$this->userId,
- $currentTicket['visibility'] ?? 'public'
- );
- }
-
- return [
- 'success' => true,
- 'status' => $updateData['status'],
- 'priority' => $updateData['priority'],
- 'updated_at' => date('Y-m-d H:i:s'),
- 'message' => 'Ticket updated successfully',
- 'csrf_token' => $GLOBALS['newCsrfToken'] ?? null
- ];
- }
- }
+ require_once dirname(__DIR__) . '/controllers/ApiTicketController.php';
// Use centralized database connection
$conn = Database::getConnection();
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..59dcc71
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,33 @@
+{
+ "name": "lotusguild/tinker-tickets",
+ "description": "Tinker Tickets. Composer is used ONLY by the MCP endpoint (mcp/); nothing else may require vendor/autoload.php.",
+ "type": "project",
+ "license": "proprietary",
+ "require": {
+ "php": ">=8.2",
+ "ext-openssl": "*",
+ "firebase/php-jwt": "^7.0",
+ "laminas/laminas-httphandlerrunner": "^2.12",
+ "mcp/sdk": "0.8.1",
+ "nyholm/psr7": "^1.8",
+ "nyholm/psr7-server": "^1.1",
+ "psr/simple-cache": "^3.0",
+ "symfony/cache": "^7.3",
+ "symfony/http-client": "^7.3"
+ },
+ "autoload": {
+ "psr-4": {
+ "TinkerTickets\\Mcp\\": "mcp/src/"
+ }
+ },
+ "config": {
+ "platform": {
+ "php": "8.2.0"
+ },
+ "sort-packages": true,
+ "optimize-autoloader": true,
+ "allow-plugins": {
+ "php-http/discovery": false
+ }
+ }
+}
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..5815cf3
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,2421 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "dd83586e9ecd3408852359dc0e84a4be",
+ "packages": [
+ {
+ "name": "doctrine/deprecations",
+ "version": "1.1.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/deprecations.git",
+ "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
+ "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "phpunit/phpunit": "<=7.5 || >=14"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^9 || ^12 || ^14",
+ "phpstan/phpstan": "1.4.10 || 2.1.30",
+ "phpstan/phpstan-phpunit": "^1.0 || ^2",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
+ "psr/log": "^1 || ^2 || ^3"
+ },
+ "suggest": {
+ "psr/log": "Allows logging deprecations via PSR-3 logger implementation"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Deprecations\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
+ "homepage": "https://www.doctrine-project.org/",
+ "support": {
+ "issues": "https://github.com/doctrine/deprecations/issues",
+ "source": "https://github.com/doctrine/deprecations/tree/1.1.6"
+ },
+ "time": "2026-02-07T07:09:04+00:00"
+ },
+ {
+ "name": "firebase/php-jwt",
+ "version": "v7.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/googleapis/php-jwt.git",
+ "reference": "f502cdbf279cd7532060b041f7c22a05208c0b93"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/f502cdbf279cd7532060b041f7c22a05208c0b93",
+ "reference": "f502cdbf279cd7532060b041f7c22a05208c0b93",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.0"
+ },
+ "require-dev": {
+ "guzzlehttp/guzzle": "^7.4||^8.0",
+ "phpfastcache/phpfastcache": "^9.2",
+ "phpseclib/phpseclib": "~3.0",
+ "phpspec/prophecy-phpunit": "^2.2",
+ "phpunit/phpunit": "^9.5",
+ "psr/cache": "^2.0||^3.0",
+ "psr/http-client": "^1.0",
+ "psr/http-factory": "^1.0"
+ },
+ "suggest": {
+ "ext-sodium": "Support EdDSA (Ed25519) signatures",
+ "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present",
+ "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures"
+ },
+ "type": "library",
+ "extra": {
+ "component": {
+ "id": "jwt",
+ "path": "Jwt",
+ "entry": "README.md",
+ "target": "googleapis/php-jwt.git"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Firebase\\JWT\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Neuman Vong",
+ "email": "neuman+pear@twilio.com",
+ "role": "Developer"
+ },
+ {
+ "name": "Anant Narayanan",
+ "email": "anant@php.net",
+ "role": "Developer"
+ }
+ ],
+ "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
+ "homepage": "https://github.com/googleapis/php-jwt",
+ "keywords": [
+ "jwt",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/googleapis/php-jwt/issues",
+ "source": "https://github.com/googleapis/php-jwt/tree/v7.2.0"
+ },
+ "time": "2026-09-21T19:29:58+00:00"
+ },
+ {
+ "name": "laminas/laminas-httphandlerrunner",
+ "version": "2.14.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laminas/laminas-httphandlerrunner.git",
+ "reference": "14d5182a4ba180c998dfbba9d19f9a70337c02b4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laminas/laminas-httphandlerrunner/zipball/14d5182a4ba180c998dfbba9d19f9a70337c02b4",
+ "reference": "14d5182a4ba180c998dfbba9d19f9a70337c02b4",
+ "shasum": ""
+ },
+ "require": {
+ "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
+ "psr/http-message": "^1.0 || ^2.0",
+ "psr/http-message-implementation": "^1.0 || ^2.0",
+ "psr/http-server-handler": "^1.0"
+ },
+ "require-dev": {
+ "laminas/laminas-coding-standard": "~3.1.0",
+ "laminas/laminas-diactoros": "^3.6.0",
+ "phpunit/phpunit": "^10.5.46",
+ "psalm/plugin-phpunit": "^0.19.5",
+ "vimeo/psalm": "^6.10.3"
+ },
+ "type": "library",
+ "extra": {
+ "laminas": {
+ "config-provider": "Laminas\\HttpHandlerRunner\\ConfigProvider"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laminas\\HttpHandlerRunner\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "description": "Execute PSR-15 RequestHandlerInterface instances and emit responses they generate.",
+ "homepage": "https://laminas.dev",
+ "keywords": [
+ "components",
+ "laminas",
+ "mezzio",
+ "psr-15",
+ "psr-7"
+ ],
+ "support": {
+ "chat": "https://laminas.dev/chat",
+ "docs": "https://docs.laminas.dev/laminas-httphandlerrunner/",
+ "forum": "https://discourse.laminas.dev",
+ "issues": "https://github.com/laminas/laminas-httphandlerrunner/issues",
+ "rss": "https://github.com/laminas/laminas-httphandlerrunner/releases.atom",
+ "source": "https://github.com/laminas/laminas-httphandlerrunner"
+ },
+ "funding": [
+ {
+ "url": "https://crowdfunding.linuxfoundation.org/initiatives/laminas-project",
+ "type": "custom"
+ }
+ ],
+ "time": "2026-09-21T10:14:52+00:00"
+ },
+ {
+ "name": "mcp/sdk",
+ "version": "v0.8.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/modelcontextprotocol/php-sdk.git",
+ "reference": "c5dbfb64e5a2a30872ec3927b4cf13a2eb05e10e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/modelcontextprotocol/php-sdk/zipball/c5dbfb64e5a2a30872ec3927b4cf13a2eb05e10e",
+ "reference": "c5dbfb64e5a2a30872ec3927b4cf13a2eb05e10e",
+ "shasum": ""
+ },
+ "require": {
+ "ext-fileinfo": "*",
+ "opis/json-schema": "^2.4",
+ "php": "^8.1",
+ "php-http/discovery": "^1.20",
+ "phpdocumentor/reflection-docblock": "^5.6 || ^6.0",
+ "psr/clock": "^1.0",
+ "psr/container": "^1.0 || ^2.0",
+ "psr/event-dispatcher": "^1.0",
+ "psr/http-client": "^1.0",
+ "psr/http-factory": "^1.1",
+ "psr/http-message": "^1.1 || ^2.0",
+ "psr/http-server-handler": "^1.0",
+ "psr/http-server-middleware": "^1.0",
+ "psr/log": "^1.0 || ^2.0 || ^3.0",
+ "symfony/deprecation-contracts": "^2.5 || ^3.0",
+ "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0"
+ },
+ "require-dev": {
+ "composer/semver": "^3.0",
+ "ext-openssl": "*",
+ "firebase/php-jwt": "^6.10 || ^7.0",
+ "laminas/laminas-httphandlerrunner": "^2.12",
+ "nyholm/psr7": "^1.8",
+ "nyholm/psr7-server": "^1.1",
+ "phar-io/composer-distributor": "^1.0.2",
+ "php-cs-fixer/shim": "^3.91",
+ "phpdocumentor/shim": "^3",
+ "phpstan/phpstan": "^2.1",
+ "phpunit/phpunit": "^10.5",
+ "psr/simple-cache": "^2.0 || ^3.0",
+ "symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0",
+ "symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0",
+ "symfony/finder": "^5.4 || ^6.4 || ^7.3 || ^8.0",
+ "symfony/http-client": "^5.4 || ^6.4 || ^7.3 || ^8.0",
+ "symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0"
+ },
+ "suggest": {
+ "symfony/finder": "Required for file-based discovery."
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Mcp\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "Christopher Hertel",
+ "email": "mail@christopher-hertel.de"
+ },
+ {
+ "name": "Kyrian Obikwelu",
+ "email": "koshnawaza@gmail.com"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com"
+ }
+ ],
+ "description": "Model Context Protocol SDK for Client and Server applications in PHP",
+ "support": {
+ "issues": "https://github.com/modelcontextprotocol/php-sdk/issues",
+ "source": "https://github.com/modelcontextprotocol/php-sdk/tree/v0.8.1"
+ },
+ "time": "2026-08-29T23:10:56+00:00"
+ },
+ {
+ "name": "nyholm/psr7",
+ "version": "1.8.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Nyholm/psr7.git",
+ "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
+ "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2",
+ "psr/http-factory": "^1.0",
+ "psr/http-message": "^1.1 || ^2.0"
+ },
+ "provide": {
+ "php-http/message-factory-implementation": "1.0",
+ "psr/http-factory-implementation": "1.0",
+ "psr/http-message-implementation": "1.0"
+ },
+ "require-dev": {
+ "http-interop/http-factory-tests": "^0.9",
+ "php-http/message-factory": "^1.0",
+ "php-http/psr7-integration-tests": "^1.0",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4",
+ "symfony/error-handler": "^4.4"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.8-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Nyholm\\Psr7\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com"
+ },
+ {
+ "name": "Martijn van der Ven",
+ "email": "martijn@vanderven.se"
+ }
+ ],
+ "description": "A fast PHP7 implementation of PSR-7",
+ "homepage": "https://tnyholm.se",
+ "keywords": [
+ "psr-17",
+ "psr-7"
+ ],
+ "support": {
+ "issues": "https://github.com/Nyholm/psr7/issues",
+ "source": "https://github.com/Nyholm/psr7/tree/1.8.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Zegnat",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nyholm",
+ "type": "github"
+ }
+ ],
+ "time": "2024-09-09T07:06:30+00:00"
+ },
+ {
+ "name": "nyholm/psr7-server",
+ "version": "1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Nyholm/psr7-server.git",
+ "reference": "4335801d851f554ca43fa6e7d2602141538854dc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Nyholm/psr7-server/zipball/4335801d851f554ca43fa6e7d2602141538854dc",
+ "reference": "4335801d851f554ca43fa6e7d2602141538854dc",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0",
+ "psr/http-factory": "^1.0",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "require-dev": {
+ "nyholm/nsa": "^1.1",
+ "nyholm/psr7": "^1.3",
+ "phpunit/phpunit": "^7.0 || ^8.5 || ^9.3"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Nyholm\\Psr7Server\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com"
+ },
+ {
+ "name": "Martijn van der Ven",
+ "email": "martijn@vanderven.se"
+ }
+ ],
+ "description": "Helper classes to handle PSR-7 server requests",
+ "homepage": "http://tnyholm.se",
+ "keywords": [
+ "psr-17",
+ "psr-7"
+ ],
+ "support": {
+ "issues": "https://github.com/Nyholm/psr7-server/issues",
+ "source": "https://github.com/Nyholm/psr7-server/tree/1.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Zegnat",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nyholm",
+ "type": "github"
+ }
+ ],
+ "time": "2023-11-08T09:30:43+00:00"
+ },
+ {
+ "name": "opis/json-schema",
+ "version": "2.6.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/opis/json-schema.git",
+ "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/opis/json-schema/zipball/8458763e0dd0b6baa310e04f1829fc73da4e8c8a",
+ "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "opis/string": "^2.1",
+ "opis/uri": "^1.0",
+ "php": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "ext-bcmath": "*",
+ "ext-intl": "*",
+ "phpunit/phpunit": "^9.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Opis\\JsonSchema\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "Sorin Sarca",
+ "email": "sarca_sorin@hotmail.com"
+ },
+ {
+ "name": "Marius Sarca",
+ "email": "marius.sarca@gmail.com"
+ }
+ ],
+ "description": "Json Schema Validator for PHP",
+ "homepage": "https://opis.io/json-schema",
+ "keywords": [
+ "json",
+ "json-schema",
+ "schema",
+ "validation",
+ "validator"
+ ],
+ "support": {
+ "issues": "https://github.com/opis/json-schema/issues",
+ "source": "https://github.com/opis/json-schema/tree/2.6.0"
+ },
+ "time": "2025-10-17T12:46:48+00:00"
+ },
+ {
+ "name": "opis/string",
+ "version": "2.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/opis/string.git",
+ "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/opis/string/zipball/3e4d2aaff518ac518530b89bb26ed40f4503635e",
+ "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e",
+ "shasum": ""
+ },
+ "require": {
+ "ext-iconv": "*",
+ "ext-json": "*",
+ "php": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Opis\\String\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "Marius Sarca",
+ "email": "marius.sarca@gmail.com"
+ },
+ {
+ "name": "Sorin Sarca",
+ "email": "sarca_sorin@hotmail.com"
+ }
+ ],
+ "description": "Multibyte strings as objects",
+ "homepage": "https://opis.io/string",
+ "keywords": [
+ "multi-byte",
+ "opis",
+ "string",
+ "string manipulation",
+ "utf-8"
+ ],
+ "support": {
+ "issues": "https://github.com/opis/string/issues",
+ "source": "https://github.com/opis/string/tree/2.1.0"
+ },
+ "time": "2025-10-17T12:38:41+00:00"
+ },
+ {
+ "name": "opis/uri",
+ "version": "1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/opis/uri.git",
+ "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a",
+ "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a",
+ "shasum": ""
+ },
+ "require": {
+ "opis/string": "^2.0",
+ "php": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Opis\\Uri\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "Apache-2.0"
+ ],
+ "authors": [
+ {
+ "name": "Marius Sarca",
+ "email": "marius.sarca@gmail.com"
+ },
+ {
+ "name": "Sorin Sarca",
+ "email": "sarca_sorin@hotmail.com"
+ }
+ ],
+ "description": "Build, parse and validate URIs and URI-templates",
+ "homepage": "https://opis.io",
+ "keywords": [
+ "URI Template",
+ "parse url",
+ "punycode",
+ "uri",
+ "uri components",
+ "url",
+ "validate uri"
+ ],
+ "support": {
+ "issues": "https://github.com/opis/uri/issues",
+ "source": "https://github.com/opis/uri/tree/1.1.0"
+ },
+ "time": "2021-05-22T15:57:08+00:00"
+ },
+ {
+ "name": "php-http/discovery",
+ "version": "1.20.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-http/discovery.git",
+ "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d",
+ "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d",
+ "shasum": ""
+ },
+ "require": {
+ "composer-plugin-api": "^1.0|^2.0",
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "nyholm/psr7": "<1.0",
+ "zendframework/zend-diactoros": "*"
+ },
+ "provide": {
+ "php-http/async-client-implementation": "*",
+ "php-http/client-implementation": "*",
+ "psr/http-client-implementation": "*",
+ "psr/http-factory-implementation": "*",
+ "psr/http-message-implementation": "*"
+ },
+ "require-dev": {
+ "composer/composer": "^1.0.2|^2.0",
+ "graham-campbell/phpspec-skip-example-extension": "^5.0",
+ "php-http/httplug": "^1.0 || ^2.0",
+ "php-http/message-factory": "^1.0",
+ "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3",
+ "sebastian/comparator": "^3.0.5 || ^4.0.8",
+ "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1"
+ },
+ "type": "composer-plugin",
+ "extra": {
+ "class": "Http\\Discovery\\Composer\\Plugin",
+ "plugin-optional": true
+ },
+ "autoload": {
+ "psr-4": {
+ "Http\\Discovery\\": "src/"
+ },
+ "exclude-from-classmap": [
+ "src/Composer/Plugin.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com"
+ }
+ ],
+ "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations",
+ "homepage": "http://php-http.org",
+ "keywords": [
+ "adapter",
+ "client",
+ "discovery",
+ "factory",
+ "http",
+ "message",
+ "psr17",
+ "psr7"
+ ],
+ "support": {
+ "issues": "https://github.com/php-http/discovery/issues",
+ "source": "https://github.com/php-http/discovery/tree/1.20.0"
+ },
+ "time": "2024-10-02T11:20:13+00:00"
+ },
+ {
+ "name": "phpdocumentor/reflection-common",
+ "version": "2.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
+ "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b",
+ "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-2.x": "2.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jaap van Otterdijk",
+ "email": "opensource@ijaap.nl"
+ }
+ ],
+ "description": "Common reflection classes used by phpdocumentor to reflect the code structure",
+ "homepage": "http://www.phpdoc.org",
+ "keywords": [
+ "FQSEN",
+ "phpDocumentor",
+ "phpdoc",
+ "reflection",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
+ "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x"
+ },
+ "time": "2020-06-27T09:03:43+00:00"
+ },
+ {
+ "name": "phpdocumentor/reflection-docblock",
+ "version": "6.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
+ "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582",
+ "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/deprecations": "^1.1",
+ "ext-filter": "*",
+ "php": "^7.4 || ^8.0",
+ "phpdocumentor/reflection-common": "^2.2",
+ "phpdocumentor/type-resolver": "^2.0",
+ "phpstan/phpdoc-parser": "^2.0",
+ "webmozart/assert": "^1.9.1 || ^2"
+ },
+ "require-dev": {
+ "mockery/mockery": "~1.3.5 || ~1.6.0",
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "^1.8",
+ "phpstan/phpstan-mockery": "^1.1",
+ "phpstan/phpstan-webmozart-assert": "^1.2",
+ "phpunit/phpunit": "^9.5",
+ "psalm/phar": "^5.26",
+ "shipmonk/dead-code-detector": "^0.5.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mike van Riel",
+ "email": "me@mikevanriel.com"
+ },
+ {
+ "name": "Jaap van Otterdijk",
+ "email": "opensource@ijaap.nl"
+ }
+ ],
+ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
+ "support": {
+ "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
+ "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3"
+ },
+ "time": "2026-03-18T20:49:53+00:00"
+ },
+ {
+ "name": "phpdocumentor/type-resolver",
+ "version": "2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpDocumentor/TypeResolver.git",
+ "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9",
+ "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/deprecations": "^1.0",
+ "php": "^7.4 || ^8.0",
+ "phpdocumentor/reflection-common": "^2.0",
+ "phpstan/phpdoc-parser": "^2.0"
+ },
+ "require-dev": {
+ "ext-tokenizer": "*",
+ "phpbench/phpbench": "^1.2",
+ "phpstan/extension-installer": "^1.4",
+ "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpunit/phpunit": "^9.5",
+ "psalm/phar": "^4"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-1.x": "1.x-dev",
+ "dev-2.x": "2.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "phpDocumentor\\Reflection\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mike van Riel",
+ "email": "me@mikevanriel.com"
+ }
+ ],
+ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
+ "support": {
+ "issues": "https://github.com/phpDocumentor/TypeResolver/issues",
+ "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0"
+ },
+ "time": "2026-01-06T21:53:42+00:00"
+ },
+ {
+ "name": "phpstan/phpdoc-parser",
+ "version": "2.3.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpstan/phpdoc-parser.git",
+ "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9",
+ "reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0"
+ },
+ "require-dev": {
+ "doctrine/annotations": "^2.0",
+ "nikic/php-parser": "^5.3.0",
+ "php-parallel-lint/php-parallel-lint": "^1.2",
+ "phpstan/extension-installer": "^1.0",
+ "phpstan/phpstan": "^2.0",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/phpunit": "^9.6",
+ "symfony/process": "^5.2"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PHPStan\\PhpDocParser\\": [
+ "src/"
+ ]
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHPDoc parser with support for nullable, intersection and generic types",
+ "support": {
+ "issues": "https://github.com/phpstan/phpdoc-parser/issues",
+ "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5"
+ },
+ "time": "2026-08-31T16:05:28+00:00"
+ },
+ {
+ "name": "psr/cache",
+ "version": "3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/cache.git",
+ "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
+ "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Cache\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for caching libraries",
+ "keywords": [
+ "cache",
+ "psr",
+ "psr-6"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/cache/tree/3.0.0"
+ },
+ "time": "2021-02-03T23:26:27+00:00"
+ },
+ {
+ "name": "psr/clock",
+ "version": "1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/clock.git",
+ "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d",
+ "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Psr\\Clock\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for reading the clock.",
+ "homepage": "https://github.com/php-fig/clock",
+ "keywords": [
+ "clock",
+ "now",
+ "psr",
+ "psr-20",
+ "time"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/clock/issues",
+ "source": "https://github.com/php-fig/clock/tree/1.0.0"
+ },
+ "time": "2022-11-25T14:36:26+00:00"
+ },
+ {
+ "name": "psr/container",
+ "version": "2.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/container.git",
+ "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+ "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.4.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Container\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common Container Interface (PHP FIG PSR-11)",
+ "homepage": "https://github.com/php-fig/container",
+ "keywords": [
+ "PSR-11",
+ "container",
+ "container-interface",
+ "container-interop",
+ "psr"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/container/issues",
+ "source": "https://github.com/php-fig/container/tree/2.0.2"
+ },
+ "time": "2021-11-05T16:47:00+00:00"
+ },
+ {
+ "name": "psr/event-dispatcher",
+ "version": "1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/event-dispatcher.git",
+ "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
+ "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\EventDispatcher\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Standard interfaces for event handling.",
+ "keywords": [
+ "events",
+ "psr",
+ "psr-14"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/event-dispatcher/issues",
+ "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0"
+ },
+ "time": "2019-01-08T18:20:26+00:00"
+ },
+ {
+ "name": "psr/http-client",
+ "version": "1.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-client.git",
+ "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
+ "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0 || ^8.0",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Client\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP clients",
+ "homepage": "https://github.com/php-fig/http-client",
+ "keywords": [
+ "http",
+ "http-client",
+ "psr",
+ "psr-18"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-client"
+ },
+ "time": "2023-09-23T14:17:50+00:00"
+ },
+ {
+ "name": "psr/http-factory",
+ "version": "1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-factory.git",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
+ "keywords": [
+ "factory",
+ "http",
+ "message",
+ "psr",
+ "psr-17",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-factory"
+ },
+ "time": "2024-04-15T12:06:14+00:00"
+ },
+ {
+ "name": "psr/http-message",
+ "version": "2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-message.git",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP messages",
+ "homepage": "https://github.com/php-fig/http-message",
+ "keywords": [
+ "http",
+ "http-message",
+ "psr",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-message/tree/2.0"
+ },
+ "time": "2023-04-04T09:54:51+00:00"
+ },
+ {
+ "name": "psr/http-server-handler",
+ "version": "1.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-server-handler.git",
+ "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4",
+ "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.0",
+ "psr/http-message": "^1.0 || ^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Server\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP server-side request handler",
+ "keywords": [
+ "handler",
+ "http",
+ "http-interop",
+ "psr",
+ "psr-15",
+ "psr-7",
+ "request",
+ "response",
+ "server"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2"
+ },
+ "time": "2023-04-10T20:06:20+00:00"
+ },
+ {
+ "name": "psr/http-server-middleware",
+ "version": "1.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-server-middleware.git",
+ "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829",
+ "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.0",
+ "psr/http-message": "^1.0 || ^2.0",
+ "psr/http-server-handler": "^1.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Server\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP server-side middleware",
+ "keywords": [
+ "http",
+ "http-interop",
+ "middleware",
+ "psr",
+ "psr-15",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/http-server-middleware/issues",
+ "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2"
+ },
+ "time": "2023-04-11T06:14:47+00:00"
+ },
+ {
+ "name": "psr/log",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/log.git",
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Log\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for logging libraries",
+ "homepage": "https://github.com/php-fig/log",
+ "keywords": [
+ "log",
+ "psr",
+ "psr-3"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/log/tree/3.0.2"
+ },
+ "time": "2024-09-11T13:17:53+00:00"
+ },
+ {
+ "name": "psr/simple-cache",
+ "version": "3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/simple-cache.git",
+ "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
+ "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\SimpleCache\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interfaces for simple caching",
+ "keywords": [
+ "cache",
+ "caching",
+ "psr",
+ "psr-16",
+ "simple-cache"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
+ },
+ "time": "2021-10-29T13:26:27+00:00"
+ },
+ {
+ "name": "symfony/cache",
+ "version": "v7.4.19",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/cache.git",
+ "reference": "e037fd41e9f8ec9ac270e2a4c76a1992d18d808c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/cache/zipball/e037fd41e9f8ec9ac270e2a4c76a1992d18d808c",
+ "reference": "e037fd41e9f8ec9ac270e2a4c76a1992d18d808c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "psr/cache": "^2.0|^3.0",
+ "psr/log": "^1.1|^2|^3",
+ "symfony/cache-contracts": "^3.6",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/service-contracts": "^2.5|^3",
+ "symfony/var-exporter": "^6.4|^7.0|^8.0"
+ },
+ "conflict": {
+ "doctrine/dbal": "<3.6",
+ "ext-relay": "<0.12.1",
+ "symfony/dependency-injection": "<6.4",
+ "symfony/http-kernel": "<6.4",
+ "symfony/var-dumper": "<6.4"
+ },
+ "provide": {
+ "psr/cache-implementation": "2.0|3.0",
+ "psr/simple-cache-implementation": "1.0|2.0|3.0",
+ "symfony/cache-implementation": "1.1|2.0|3.0"
+ },
+ "require-dev": {
+ "cache/integration-tests": "^1.0.3",
+ "doctrine/dbal": "^3.6|^4",
+ "predis/predis": "^1.1|^2.0",
+ "psr/simple-cache": "^1.0|^2.0|^3.0",
+ "symfony/clock": "^6.4|^7.0|^8.0",
+ "symfony/config": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/filesystem": "^6.4|^7.0|^8.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/messenger": "^6.4|^7.0|^8.0",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Cache\\": ""
+ },
+ "classmap": [
+ "Traits/ValueWrapper.php"
+ ],
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides extended PSR-6, PSR-16 (and tags) implementations",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "caching",
+ "psr6"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/cache/tree/v7.4.19"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-09-08T13:28:43+00:00"
+ },
+ {
+ "name": "symfony/cache-contracts",
+ "version": "v3.7.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/cache-contracts.git",
+ "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2",
+ "reference": "9789738bc19af1106dc54d6afba9a0b467516cf2",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/cache": "^3.0"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.7-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\Cache\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to caching",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/cache-contracts/tree/v3.7.1"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-06-05T06:23:12+00:00"
+ },
+ {
+ "name": "symfony/deprecation-contracts",
+ "version": "v3.7.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/deprecation-contracts.git",
+ "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d",
+ "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.7-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "function.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "A generic function and convention to trigger deprecation notices",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-06-05T06:23:12+00:00"
+ },
+ {
+ "name": "symfony/http-client",
+ "version": "v7.4.19",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/http-client.git",
+ "reference": "3a523f38dc399337ec45b2ab9628e8c37ceb23c7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/http-client/zipball/3a523f38dc399337ec45b2ab9628e8c37ceb23c7",
+ "reference": "3a523f38dc399337ec45b2ab9628e8c37ceb23c7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "psr/log": "^1|^2|^3",
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/http-client-contracts": "~3.4.4|^3.5.2",
+ "symfony/polyfill-php83": "^1.29",
+ "symfony/service-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "amphp/amp": "<2.5",
+ "amphp/socket": "<1.1",
+ "php-http/discovery": "<1.15",
+ "symfony/http-foundation": "<6.4"
+ },
+ "provide": {
+ "php-http/async-client-implementation": "*",
+ "php-http/client-implementation": "*",
+ "psr/http-client-implementation": "1.0",
+ "symfony/http-client-implementation": "3.0"
+ },
+ "require-dev": {
+ "amphp/http-client": "^4.2.1|^5.0",
+ "amphp/http-tunnel": "^1.0|^2.0",
+ "guzzlehttp/promises": "^1.4|^2.0",
+ "nyholm/psr7": "^1.0",
+ "php-http/httplug": "^1.0|^2.0",
+ "psr/http-client": "^1.0",
+ "symfony/amphp-http-client-meta": "^1.0|^2.0",
+ "symfony/cache": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/messenger": "^6.4|^7.0|^8.0",
+ "symfony/process": "^6.4|^7.0|^8.0",
+ "symfony/rate-limiter": "^6.4|^7.0|^8.0",
+ "symfony/stopwatch": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\HttpClient\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "http"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/http-client/tree/v7.4.19"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-09-08T13:28:43+00:00"
+ },
+ {
+ "name": "symfony/http-client-contracts",
+ "version": "v3.7.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/http-client-contracts.git",
+ "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/35be0019e2c2c9fba80f9dc033290a5240f7b44f",
+ "reference": "35be0019e2c2c9fba80f9dc033290a5240f7b44f",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.7-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\HttpClient\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Test/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to HTTP clients",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.3"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-04T08:41:16+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php83",
+ "version": "v1.41.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php83.git",
+ "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6",
+ "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php83\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-07-01T12:47:55+00:00"
+ },
+ {
+ "name": "symfony/polyfill-uuid",
+ "version": "v1.37.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-uuid.git",
+ "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94",
+ "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-uuid": "*"
+ },
+ "suggest": {
+ "ext-uuid": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Uuid\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Grégoire Pineau",
+ "email": "lyrixx@lyrixx.info"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for uuid functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "uuid"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-10T16:19:22+00:00"
+ },
+ {
+ "name": "symfony/service-contracts",
+ "version": "v3.7.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/service-contracts.git",
+ "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257",
+ "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/container": "^1.1|^2.0",
+ "symfony/deprecation-contracts": "^2.5|^3"
+ },
+ "conflict": {
+ "ext-psr": "<1.1|>=2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.7-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\Service\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Test/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to writing services",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/service-contracts/tree/v3.7.3"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-07-27T15:39:01+00:00"
+ },
+ {
+ "name": "symfony/uid",
+ "version": "v7.4.17",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/uid.git",
+ "reference": "69d732355a139c6f8881337d28515aa01f12b8be"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/uid/zipball/69d732355a139c6f8881337d28515aa01f12b8be",
+ "reference": "69d732355a139c6f8881337d28515aa01f12b8be",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/polyfill-uuid": "^1.15"
+ },
+ "require-dev": {
+ "symfony/console": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Uid\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Grégoire Pineau",
+ "email": "lyrixx@lyrixx.info"
+ },
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides an object-oriented API to generate and represent UIDs",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "UID",
+ "ulid",
+ "uuid"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/uid/tree/v7.4.17"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-11T07:38:58+00:00"
+ },
+ {
+ "name": "symfony/var-exporter",
+ "version": "v7.4.18",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/var-exporter.git",
+ "reference": "d6a87acbe48cbc707b9aba7f1a6c2215aab02603"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/var-exporter/zipball/d6a87acbe48cbc707b9aba7f1a6c2215aab02603",
+ "reference": "d6a87acbe48cbc707b9aba7f1a6c2215aab02603",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3"
+ },
+ "require-dev": {
+ "symfony/property-access": "^6.4|^7.0|^8.0",
+ "symfony/serializer": "^6.4|^7.0|^8.0",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\VarExporter\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Allows exporting any serializable PHP data structure to plain PHP code",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "clone",
+ "construct",
+ "export",
+ "hydrate",
+ "instantiate",
+ "lazy-loading",
+ "proxy",
+ "serialize"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/var-exporter/tree/v7.4.18"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-08-23T10:03:40+00:00"
+ },
+ {
+ "name": "webmozart/assert",
+ "version": "2.4.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/webmozarts/assert.git",
+ "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70",
+ "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-date": "*",
+ "ext-filter": "*",
+ "php": "^8.2"
+ },
+ "suggest": {
+ "ext-intl": "",
+ "ext-simplexml": "",
+ "ext-spl": ""
+ },
+ "type": "library",
+ "extra": {
+ "psalm": {
+ "pluginClass": "Webmozart\\Assert\\PsalmPlugin"
+ },
+ "branch-alias": {
+ "dev-master": "2.0-dev",
+ "dev-feature/2-0": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Webmozart\\Assert\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ },
+ {
+ "name": "Woody Gilk",
+ "email": "woody.gilk@gmail.com"
+ }
+ ],
+ "description": "Assertions to validate method input/output with nice error messages.",
+ "keywords": [
+ "assert",
+ "check",
+ "validate"
+ ],
+ "support": {
+ "issues": "https://github.com/webmozarts/assert/issues",
+ "source": "https://github.com/webmozarts/assert/tree/2.4.1"
+ },
+ "time": "2026-06-15T15:31:57+00:00"
+ }
+ ],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": {},
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {
+ "php": ">=8.2",
+ "ext-openssl": "*"
+ },
+ "platform-dev": {},
+ "platform-overrides": {
+ "php": "8.2.0"
+ },
+ "plugin-api-version": "2.9.0"
+}
diff --git a/config/config.php b/config/config.php
index 99454b9..1bc3543 100644
--- a/config/config.php
+++ b/config/config.php
@@ -102,6 +102,14 @@ $GLOBALS['config'] = [
explode(',', $envVars['ALLOWED_HOSTS'] ?? 'localhost,127.0.0.1')
)),
+ // MCP endpoint (mcp/server.php). MCP_RESOURCE_URL is this server's
+ // canonical URL: access tokens must carry it as their audience, so a
+ // beta token can't be replayed against prod. MCP_OAUTH_ISSUER is the
+ // Authelia issuer that signs those tokens.
+ 'MCP_RESOURCE_URL' => $envVars['MCP_RESOURCE_URL']
+ ?? (!empty($envVars['APP_DOMAIN']) ? 'https://' . $envVars['APP_DOMAIN'] . '/mcp' : null),
+ 'MCP_OAUTH_ISSUER' => $envVars['MCP_OAUTH_ISSUER'] ?? 'https://auth.lotusguild.org',
+
// Session settings
'SESSION_TIMEOUT' => 18000, // 5 hours in seconds
'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes
diff --git a/controllers/ApiTicketController.php b/controllers/ApiTicketController.php
new file mode 100644
index 0000000..9a47bf6
--- /dev/null
+++ b/controllers/ApiTicketController.php
@@ -0,0 +1,275 @@
+conn = $conn;
+ $this->ticketModel = new TicketModel($conn);
+ $this->commentModel = new CommentModel($conn);
+ $this->auditLog = new AuditLogModel($conn);
+ $this->workflowModel = new WorkflowModel($conn);
+ $this->userId = $userId;
+ $this->isAdmin = $isAdmin;
+ $this->currentUser = $currentUser;
+ }
+
+ public function update($id, $data)
+ {
+ // First, get the current ticket data to fill in missing fields
+ $currentTicket = $this->ticketModel->getTicketById($id);
+ if (!$currentTicket) {
+ return [
+ 'success' => false,
+ 'error' => 'Ticket not found'
+ ];
+ }
+
+ // Visibility check: return 404 for tickets the user cannot access
+ if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) {
+ return [
+ 'success' => false,
+ 'error' => 'Ticket not found',
+ 'http_status' => 404
+ ];
+ }
+
+ // Any authenticated team member can update tickets.
+ // Admin-only operations (delete, bulk actions) are enforced separately.
+
+ // Merge current data with updates, keeping existing values for missing fields
+ $updateData = [
+ 'ticket_id' => $id,
+ 'title' => $data['title'] ?? $currentTicket['title'],
+ 'description' => $data['description'] ?? $currentTicket['description'],
+ 'category' => $data['category'] ?? $currentTicket['category'],
+ 'type' => $data['type'] ?? $currentTicket['type'],
+ 'status' => $data['status'] ?? $currentTicket['status'],
+ 'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority']
+ ];
+
+ // Validate required fields
+ if (empty($updateData['title'])) {
+ return [
+ 'success' => false,
+ 'error' => 'Title cannot be empty',
+ 'http_status' => 400
+ ];
+ }
+
+ // Validate priority range
+ if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
+ return [
+ 'success' => false,
+ 'error' => 'Priority must be between 1 and 5',
+ 'http_status' => 400
+ ];
+ }
+
+ // Validate visibility BEFORE any DB write so a bad payload can't leave the
+ // ticket half-updated (core fields committed but request reported as failed).
+ $visibilityGroups = null;
+ if (isset($data['visibility'])) {
+ $visibilityGroups = $data['visibility_groups'] ?? null;
+ // Convert array to comma-separated string if needed
+ if (is_array($visibilityGroups)) {
+ $visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
+ }
+
+ // Authorization: only an admin or the ticket's creator may change
+ // visibility. Enforce only when the requested visibility actually
+ // differs so ordinary edits that re-send the same value aren't blocked.
+ $currentVisibility = $currentTicket['visibility'] ?? 'public';
+ $currentGroups = $currentTicket['visibility_groups'] ?? null;
+ $groupsProvided = array_key_exists('visibility_groups', $data);
+ $visibilityChanged = ($data['visibility'] !== $currentVisibility)
+ || ($groupsProvided && (string)$visibilityGroups !== (string)$currentGroups);
+ if ($visibilityChanged) {
+ $isCreator = $this->userId !== null
+ && (int)($currentTicket['created_by'] ?? 0) === (int)$this->userId;
+ if (!$this->isAdmin && !$isCreator) {
+ return [
+ 'success' => false,
+ 'error' => 'You do not have permission to change ticket visibility',
+ 'http_status' => 403
+ ];
+ }
+ }
+
+ // Internal visibility requires at least one group
+ if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
+ return [
+ 'success' => false,
+ 'error' => 'Internal visibility requires at least one group to be specified',
+ 'http_status' => 400
+ ];
+ }
+ }
+
+ // Validate status transition using workflow model
+ if ($currentTicket['status'] !== $updateData['status']) {
+ $allowed = $this->workflowModel->isTransitionAllowed(
+ $currentTicket['status'],
+ $updateData['status'],
+ $this->isAdmin
+ );
+
+ if (!$allowed) {
+ return [
+ 'success' => false,
+ 'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
+ ];
+ }
+
+ // Enforce requires_comment transitions server-side.
+ if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) {
+ $statusChangeComment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
+ if ($statusChangeComment === '') {
+ return [
+ 'success' => false,
+ 'error' => 'A comment is required for this status change',
+ 'requires_comment' => true,
+ 'http_status' => 400
+ ];
+ }
+ }
+ }
+
+ // A comment accompanying a status change (required or optional) is
+ // persisted in the SAME transaction as the status update below, so
+ // a failure partway through can't leave an orphaned "reason"
+ // comment attached with no matching status change — the two
+ // previously ran as separate, non-transactional HTTP calls from
+ // the client (add_comment.php then update_ticket.php).
+ $statusChangeComment = $statusChangeComment ?? trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
+
+ $result = null;
+ $this->conn->begin_transaction();
+ try {
+ if ($statusChangeComment !== '' && $currentTicket['status'] !== $updateData['status']) {
+ $commentResult = $this->commentModel->addComment($id, [
+ 'user_name' => $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? 'User',
+ 'comment_text' => $statusChangeComment,
+ 'markdown_enabled' => !empty($data['markdown_enabled']),
+ ], $this->userId);
+ if (empty($commentResult['success'])) {
+ throw new Exception($commentResult['error'] ?? 'Failed to add comment');
+ }
+ }
+
+ // Update ticket with user tracking and optional optimistic locking
+ $expectedUpdatedAt = $data['expected_updated_at'] ?? null;
+ $result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
+ if (!$result['success']) {
+ throw new Exception($result['error'] ?? 'Failed to update ticket in database');
+ }
+
+ // Handle visibility update if provided (already validated above)
+ if (isset($data['visibility'])) {
+ $visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
+ if (!$visResult) {
+ throw new Exception('Failed to update ticket visibility');
+ }
+ }
+
+ $this->conn->commit();
+ } catch (Exception $e) {
+ $this->conn->rollback();
+ $response = ['success' => false, 'error' => $e->getMessage()];
+ if (is_array($result) && !empty($result['conflict'])) {
+ $response['conflict'] = true;
+ $response['current_updated_at'] = $result['current_updated_at'] ?? null;
+ }
+ return $response;
+ }
+
+ if (isset($data['visibility']) && $this->userId) {
+ $this->auditLog->log(
+ $this->userId,
+ 'update',
+ 'ticket',
+ (string)$id,
+ [
+ 'field' => 'visibility',
+ 'from' => $currentTicket['visibility'] ?? 'public',
+ 'to' => $data['visibility'],
+ 'groups' => $visibilityGroups
+ ]
+ );
+ }
+
+ // Log ticket update to audit log — only the changed fields (delta)
+ if ($this->userId) {
+ $trackFields = ['title', 'priority', 'status', 'description', 'category', 'type'];
+ $delta = [];
+ foreach ($trackFields as $field) {
+ $oldVal = (string)($currentTicket[$field] ?? '');
+ $newVal = (string)($updateData[$field] ?? '');
+ if ($oldVal !== $newVal) {
+ $delta[$field] = ['from' => $oldVal, 'to' => $newVal];
+ }
+ }
+ if (!empty($delta)) {
+ $this->auditLog->logTicketUpdate($this->userId, $id, $delta);
+ }
+ }
+
+ // Notify on status change (global notify list + watchers)
+ if ($currentTicket['status'] !== $updateData['status']) {
+ $changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null;
+ NotificationHelper::sendStatusChangeNotification(
+ $id,
+ $currentTicket['status'],
+ $updateData['status'],
+ $updateData['title'],
+ $changedBy,
+ $currentTicket['visibility'] ?? 'public'
+ );
+ NotificationHelper::notifyWatchers(
+ $this->conn,
+ $id,
+ $updateData['title'],
+ 'status_changed',
+ ['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
+ (int)$this->userId,
+ $currentTicket['visibility'] ?? 'public'
+ );
+ }
+
+ return [
+ 'success' => true,
+ 'status' => $updateData['status'],
+ 'priority' => $updateData['priority'],
+ 'updated_at' => date('Y-m-d H:i:s'),
+ 'message' => 'Ticket updated successfully',
+ 'csrf_token' => $GLOBALS['newCsrfToken'] ?? null
+ ];
+ }
+}
diff --git a/controllers/TicketController.php b/controllers/TicketController.php
index 2825d29..fd4dc51 100644
--- a/controllers/TicketController.php
+++ b/controllers/TicketController.php
@@ -101,115 +101,27 @@ class TicketController
return;
}
- // Handle visibility groups (comes as array from checkboxes)
- $visibilityGroups = null;
- if (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) {
- $visibilityGroups = implode(',', array_map('trim', $_POST['visibility_groups']));
- }
-
- // Honor the posted status, validated against the app's canonical list
- $validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
- $status = $_POST['status'] ?? 'Open';
- if (!in_array($status, $validStatuses, true)) {
- $status = 'Open';
- }
-
- $ticketData = [
- 'title' => trim($_POST['title'] ?? ''),
+ // Validation (incl. required custom fields), creation, audit log,
+ // stats cache, custom field values, duplicate link and notification
+ // live in TicketCreationService so the MCP create_ticket tool runs
+ // the same code path.
+ require_once dirname(__DIR__) . '/services/TicketCreationService.php';
+ $result = TicketCreationService::create($this->conn, $currentUser ?? [], [
+ 'title' => $_POST['title'] ?? '',
'description' => $_POST['description'] ?? '',
'priority' => $_POST['priority'] ?? '4',
'category' => $_POST['category'] ?? 'General',
'type' => $_POST['type'] ?? 'Issue',
- 'status' => $status,
+ 'status' => $_POST['status'] ?? 'Open',
'visibility' => $_POST['visibility'] ?? 'public',
- 'visibility_groups' => $visibilityGroups,
- 'assigned_to' => !empty($_POST['assigned_to']) ? $_POST['assigned_to'] : null
- ];
-
- // Validate input (server-side; form is novalidate)
- if ($ticketData['title'] === '') {
- $error = "Title is required";
- $templates = $this->templateModel->getAllTemplates();
- $allUsers = $this->userModel->getAllUsers();
- $conn = $this->conn; // Make $conn available to view
- include dirname(__DIR__) . '/views/CreateTicketView.php';
- return;
- }
-
- if (trim($ticketData['description']) === '') {
- $error = "Description is required";
- $templates = $this->templateModel->getAllTemplates();
- $allUsers = $this->userModel->getAllUsers();
- $conn = $this->conn; // Make $conn available to view
- include dirname(__DIR__) . '/views/CreateTicketView.php';
- return;
- }
-
- // Custom fields applicable to the submitted category — validate
- // is_required server-side (the form is novalidate, and a field
- // hidden by the client-side category toggle must not silently
- // bypass a requirement that applies to the category actually
- // submitted).
- $submittedCustomFields = is_array($_POST['custom_fields'] ?? null) ? $_POST['custom_fields'] : [];
- $applicableFieldDefs = array_filter(
- $allCustomFieldDefs,
- fn($def) => $def['category'] === null || $def['category'] === $ticketData['category']
- );
- $customFieldsToSave = [];
- foreach ($applicableFieldDefs as $def) {
- $fieldId = (int)$def['field_id'];
- $raw = $submittedCustomFields[$fieldId] ?? null;
- $normalized = $def['field_type'] === 'checkbox'
- ? (!empty($raw) ? '1' : '0')
- : (is_scalar($raw) ? trim((string)$raw) : '');
-
- if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
- $error = $def['field_label'] . ' is required';
- $templates = $this->templateModel->getAllTemplates();
- $allUsers = $this->userModel->getAllUsers();
- $conn = $this->conn;
- include dirname(__DIR__) . '/views/CreateTicketView.php';
- return;
- }
-
- if ($normalized !== '') {
- $customFieldsToSave[$fieldId] = $normalized;
- }
- }
-
- // Create ticket with user tracking
- $result = $this->ticketModel->createTicket($ticketData, $userId);
+ 'visibility_groups' => (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups']))
+ ? $_POST['visibility_groups'] : null,
+ 'assigned_to' => $_POST['assigned_to'] ?? null,
+ 'custom_fields' => $_POST['custom_fields'] ?? [],
+ 'link_duplicate_of' => $_POST['link_duplicate_of'] ?? '',
+ ]);
if ($result['success']) {
- // Log ticket creation to audit log
- if (isset($GLOBALS['auditLog']) && $userId) {
- $GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
- }
-
- // Ticket counts changed — invalidate the cached dashboard stats
- require_once dirname(__DIR__) . '/models/StatsModel.php';
- (new StatsModel($this->conn))->invalidateCache();
-
- // Persist custom field values for the fields applicable to
- // this ticket's category
- if (!empty($customFieldsToSave)) {
- $this->customFieldModel->setValues($result['ticket_id'], $customFieldsToSave);
- }
-
- // Auto-link as duplicate if requested from create form
- $linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
- if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
- $depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
- VALUES (?, ?, 'duplicates', ?)";
- $depStmt = $this->conn->prepare($depSql);
- $depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId);
- $depStmt->execute();
- $depStmt->close();
- }
-
- // Send Matrix notification for new ticket
- NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
-
// Redirect to the new ticket
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/" . $result['ticket_id']);
exit;
diff --git a/helpers/AccessPolicy.php b/helpers/AccessPolicy.php
new file mode 100644
index 0000000..071000b
--- /dev/null
+++ b/helpers/AccessPolicy.php
@@ -0,0 +1,31 @@
+ preg_match('/^[a-z0-9_\-]+$/', $g)
+ );
+
+ return !empty(array_intersect($userGroups, self::REQUIRED_GROUPS));
+ }
+}
diff --git a/mcp/server.php b/mcp/server.php
new file mode 100644
index 0000000..78254c4
--- /dev/null
+++ b/mcp/server.php
@@ -0,0 +1,159 @@
+ 'MCP endpoint is not configured (MCP_RESOURCE_URL / MCP_OAUTH_ISSUER)']);
+ exit;
+}
+
+$psr17 = new Psr17Factory();
+$request = (new ServerRequestCreator($psr17, $psr17, $psr17, $psr17))->fromGlobals();
+
+// ServerRequestCreator adds Host both from the URI and from the request
+// headers, so getHeaderLine('Host') comes back as "h, h" under PHP-FPM, which
+// the DNS-rebinding check below then rejects. Collapse to the single value the
+// client actually sent.
+$clientHost = $request->getHeader('Host')[0] ?? '';
+if ($clientHost !== '') {
+ $request = $request->withHeader('Host', $clientHost);
+}
+
+// TLS terminates at the reverse proxy, so PHP sees plain http and a Host header
+// the client controls. The SDK derives the resource_metadata URL in its 401
+// challenge from the request URI, so pin scheme/host/port to the configured
+// canonical URL instead of anything the request claims. preserveHost keeps
+// the client's real Host header for the DNS-rebinding check below; without
+// it withUri() would overwrite Host and make that check a no-op.
+$canonical = parse_url($resourceUrl);
+$request = $request->withUri(
+ $request->getUri()
+ ->withScheme($canonical['scheme'])
+ ->withHost($canonical['host'])
+ ->withPort($canonical['port'] ?? null),
+ true
+);
+
+// Cache OIDC discovery + JWKS so every MCP call isn't two extra round trips to
+// Authelia. Outside the webroot on purpose.
+$cache = new Psr16Cache(new FilesystemAdapter('tinker_mcp', 3600, sys_get_temp_dir() . '/tinker_mcp_cache'));
+
+$validator = new JwtTokenValidator(
+ issuer: $issuer,
+ audience: $resourceUrl,
+ jwksProvider: new JwksProvider(new OidcDiscovery(cache: $cache), cache: $cache),
+ // Authelia puts scopes in an `scp` array, not the standard `scope` string
+ // (verified in #111 phase 1). With the default, every scope check fails.
+ scopeClaim: 'scp',
+);
+
+$resourcePath = $canonical['path'] ?? '';
+$metadata = new ProtectedResourceMetadata(
+ authorizationServers: [$issuer],
+ scopesSupported: ['tickets:read', 'tickets:write'],
+ resource: $resourceUrl,
+ resourceName: 'Tinker Tickets',
+ // RFC 9728 path-suffixed form first (used in the WWW-Authenticate
+ // challenge), plus the root form some clients probe.
+ metadataPaths: array_values(array_unique([
+ '/.well-known/oauth-protected-resource' . $resourcePath,
+ '/.well-known/oauth-protected-resource',
+ ])),
+);
+
+$conn = Database::getConnection();
+
+$server = ToolCatalog::register(
+ Server::builder()
+ ->setServerInfo('Tinker Tickets', '1.0.0')
+ // Handshake-era clients (pre-2026-07-28) still use protocol sessions.
+ ->setSession(new FileSessionStore(sys_get_temp_dir() . '/tinker_mcp_sessions')),
+ $conn
+)->build();
+
+$transport = new StreamableHttpTransport(
+ $request,
+ middleware: [
+ // CORS: SDK default (no Access-Control-Allow-Origin, so cross-origin
+ // browser calls are refused). Host allowlist: only the canonical
+ // hostname, which also refuses direct-by-IP access.
+ new CorsMiddleware(),
+ new DnsRebindingProtectionMiddleware([$canonical['host']]),
+ new ProtectedResourceMetadataMiddleware($metadata),
+ new AuthorizationMiddleware($validator, $metadata),
+ // Identity comes from the validated token's server-side request
+ // attributes, never from client-writable JSON-RPC `_meta` (so the
+ // SDK's OAuthRequestMetaMiddleware is intentionally not used).
+ new IdentityMiddleware($conn, $psr17, $psr17),
+ new ToolScopeMiddleware(
+ $psr17,
+ $canonical['scheme'] . '://' . $canonical['host'] . $metadata->getPrimaryMetadataPath()
+ ),
+ ],
+);
+
+try {
+ $response = $server->run($transport);
+} catch (\Throwable $e) {
+ error_log('mcp/server.php: ' . $e::class . ': ' . $e->getMessage());
+ $response = $psr17->createResponse(500)
+ ->withHeader('Content-Type', 'application/json')
+ ->withBody($psr17->createStream(json_encode([
+ 'jsonrpc' => '2.0',
+ 'id' => null,
+ 'error' => ['code' => -32603, 'message' => 'Internal error'],
+ ])));
+}
+
+(new SapiEmitter())->emit($response);
diff --git a/mcp/src/Auth/IdentityMiddleware.php b/mcp/src/Auth/IdentityMiddleware.php
new file mode 100644
index 0000000..c130cdb
--- /dev/null
+++ b/mcp/src/Auth/IdentityMiddleware.php
@@ -0,0 +1,78 @@
+getAttribute('oauth.claims');
+ $scopes = $request->getAttribute('oauth.scopes') ?? [];
+ if (!is_array($claims)) {
+ // Only reachable if the stack is misordered; never serve anonymously.
+ return $this->deny(500, 'Identity unavailable');
+ }
+
+ $username = is_string($claims['preferred_username'] ?? null) ? trim($claims['preferred_username']) : '';
+ if ($username === '') {
+ return $this->deny(403, 'Access token has no preferred_username claim');
+ }
+
+ $groupsClaim = $claims['groups'] ?? [];
+ $groups = implode(',', array_filter(
+ is_array($groupsClaim) ? $groupsClaim : [$groupsClaim],
+ 'is_string'
+ ));
+
+ if (!\AccessPolicy::hasAppAccess($groups)) {
+ return $this->deny(403, 'Your account is not permitted to use Tinker Tickets');
+ }
+
+ $user = (new \UserModel($this->conn))->syncUserFromAuthelia(
+ $username,
+ is_string($claims['name'] ?? null) ? $claims['name'] : '',
+ is_string($claims['email'] ?? null) ? $claims['email'] : '',
+ $groups
+ );
+
+ McpIdentity::set($user, array_values(array_filter($scopes, 'is_string')));
+
+ return $handler->handle($request);
+ }
+
+ private function deny(int $status, string $message): ResponseInterface
+ {
+ return $this->responseFactory->createResponse($status)
+ ->withHeader('Content-Type', 'application/json')
+ ->withBody($this->streamFactory->createStream(json_encode([
+ 'jsonrpc' => '2.0',
+ 'id' => null,
+ 'error' => ['code' => -32001, 'message' => $message],
+ ])));
+ }
+}
diff --git a/mcp/src/Auth/McpIdentity.php b/mcp/src/Auth/McpIdentity.php
new file mode 100644
index 0000000..fbc30ed
--- /dev/null
+++ b/mcp/src/Auth/McpIdentity.php
@@ -0,0 +1,59 @@
+ */
+ private static array $scopes = [];
+
+ /**
+ * @param array $user
+ * @param list $scopes
+ */
+ public static function set(array $user, array $scopes): void
+ {
+ self::$user = $user;
+ self::$scopes = $scopes;
+ }
+
+ /**
+ * @return array
+ */
+ public static function user(): array
+ {
+ if (self::$user === null) {
+ // Unreachable if the middleware stack is intact; fail closed.
+ throw new \LogicException('MCP identity requested before IdentityMiddleware ran');
+ }
+ return self::$user;
+ }
+
+ /** tickets:write implies tickets:read (scope hierarchy, MCP spec §Scope Challenge Handling). */
+ public static function canRead(array $scopes): bool
+ {
+ return in_array(self::SCOPE_READ, $scopes, true) || in_array(self::SCOPE_WRITE, $scopes, true);
+ }
+
+ public static function canWrite(array $scopes): bool
+ {
+ return in_array(self::SCOPE_WRITE, $scopes, true);
+ }
+}
diff --git a/mcp/src/Auth/ToolScopeMiddleware.php b/mcp/src/Auth/ToolScopeMiddleware.php
new file mode 100644
index 0000000..395a984
--- /dev/null
+++ b/mcp/src/Auth/ToolScopeMiddleware.php
@@ -0,0 +1,91 @@
+getMethod() !== 'POST') {
+ return $handler->handle($request);
+ }
+
+ $payload = json_decode((string)$request->getBody(), true);
+ $request->getBody()->rewind();
+ if (!is_array($payload)) {
+ return $handler->handle($request); // the SDK answers malformed JSON-RPC itself
+ }
+
+ $messages = array_is_list($payload) ? $payload : [$payload];
+ $needWrite = false;
+ $needRead = false;
+ foreach ($messages as $message) {
+ if (!is_array($message) || !is_string($message['method'] ?? null)) {
+ continue; // responses to server->client requests carry no method
+ }
+ $method = $message['method'];
+ if (in_array($method, self::LIFECYCLE_METHODS, true) || str_starts_with($method, 'notifications/')) {
+ continue;
+ }
+ if ($method === 'tools/call' && ToolCatalog::isWriteTool((string)($message['params']['name'] ?? ''))) {
+ $needWrite = true;
+ } else {
+ $needRead = true;
+ }
+ }
+
+ $scopes = $request->getAttribute('oauth.scopes') ?? [];
+ if ($needWrite && !McpIdentity::canWrite($scopes)) {
+ return $this->insufficient(McpIdentity::SCOPE_WRITE, 'This operation requires the tickets:write scope.');
+ }
+ if ($needRead && !McpIdentity::canRead($scopes)) {
+ return $this->insufficient(
+ $needWrite ? McpIdentity::SCOPE_WRITE : McpIdentity::SCOPE_READ,
+ 'This operation requires the tickets:read scope.'
+ );
+ }
+
+ return $handler->handle($request);
+ }
+
+ private function insufficient(string $scope, string $description): ResponseInterface
+ {
+ return $this->responseFactory->createResponse(403)->withHeader(
+ 'WWW-Authenticate',
+ sprintf(
+ 'Bearer error="insufficient_scope", scope="%s", resource_metadata="%s", error_description="%s"',
+ $scope,
+ $this->resourceMetadataUrl,
+ $description
+ )
+ );
+ }
+}
diff --git a/mcp/src/ToolCatalog.php b/mcp/src/ToolCatalog.php
new file mode 100644
index 0000000..41d57e0
--- /dev/null
+++ b/mcp/src/ToolCatalog.php
@@ -0,0 +1,42 @@
+addTool([$read, 'searchTickets'], 'search_tickets', 'Search tickets', null, $readOnly)
+ ->addTool([$read, 'getTicket'], 'get_ticket', 'Get ticket', null, $readOnly)
+ ->addTool([$write, 'createTicket'], 'create_ticket', 'Create ticket', null, $additive)
+ ->addTool([$write, 'addComment'], 'add_comment', 'Add comment', null, $additive)
+ ->addTool([$write, 'updateStatus'], 'update_status', 'Update ticket status', null, $update)
+ ->addTool([$write, 'assignTicket'], 'assign_ticket', 'Assign ticket', null, $update);
+ }
+}
diff --git a/mcp/src/Tools/TicketReadTools.php b/mcp/src/Tools/TicketReadTools.php
new file mode 100644
index 0000000..2a9f5cf
--- /dev/null
+++ b/mcp/src/Tools/TicketReadTools.php
@@ -0,0 +1,183 @@
+
+ */
+ public function searchTickets(
+ ?string $query = null,
+ ?string $status = null,
+ ?int $priority = null,
+ ?string $category = null,
+ ?string $assignee = null,
+ int $page = 1,
+ int $limit = 20,
+ ): array {
+ $user = McpIdentity::user();
+
+ $allStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
+ if ($status === null || trim($status) === '') {
+ $statusFilter = implode(',', array_filter($allStatuses, fn($s) => $s !== 'Closed'));
+ } elseif (strtolower(trim($status)) === 'all') {
+ $statusFilter = null;
+ } else {
+ $requested = array_map('trim', explode(',', $status));
+ $unknown = array_diff($requested, $allStatuses);
+ if ($unknown) {
+ throw new ToolCallException('Unknown status: ' . implode(', ', $unknown)
+ . '. Valid statuses: ' . implode(', ', $allStatuses));
+ }
+ $statusFilter = implode(',', $requested);
+ }
+
+ $filters = [];
+ if ($priority !== null) {
+ if ($priority < 1 || $priority > 5) {
+ throw new ToolCallException('priority must be between 1 and 5');
+ }
+ $filters['priority_min'] = $priority;
+ $filters['priority_max'] = $priority;
+ }
+ if ($assignee !== null && trim($assignee) !== '') {
+ $filters['assigned_to'] = $this->resolveAssigneeFilter(trim($assignee), $user);
+ }
+
+ $page = max(1, $page);
+ $limit = min(50, max(1, $limit));
+
+ $result = (new \TicketModel($this->conn))->getAllTickets(
+ $page,
+ $limit,
+ $statusFilter,
+ 'updated_at',
+ 'desc',
+ ($category !== null && trim($category) !== '') ? trim($category) : null,
+ null,
+ ($query !== null && trim($query) !== '') ? trim($query) : null,
+ $filters,
+ $user
+ );
+
+ return [
+ 'tickets' => array_map([$this, 'summarize'], $result['tickets']),
+ 'page' => $result['current_page'],
+ 'pages' => $result['pages'],
+ 'total' => $result['total'],
+ ];
+ }
+
+ /**
+ * Get one ticket's full details and its comments.
+ *
+ * @param string $ticket_id The ticket ID (digits only, e.g. "123456789").
+ * @param bool $include_comments Include the ticket's comments, newest first.
+ *
+ * @return array
+ */
+ public function getTicket(string $ticket_id, bool $include_comments = true): array
+ {
+ $user = McpIdentity::user();
+ $ticketModel = new \TicketModel($this->conn);
+
+ $ticketId = trim($ticket_id);
+ $ticket = preg_match('/^\d+$/', $ticketId) ? $ticketModel->getTicketById($ticketId) : null;
+ // Same "not found" for missing and not-visible, so a restricted
+ // ticket's existence isn't revealed.
+ if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $user)) {
+ throw new ToolCallException("Ticket {$ticketId} not found");
+ }
+
+ $details = $this->summarize($ticket) + [
+ 'description' => $ticket['description'] ?? '',
+ 'visibility_groups' => $ticket['visibility_groups'] ?? null,
+ 'closed_at' => $ticket['closed_at'] ?? null,
+ 'updated_by' => $ticket['updater_display_name'] ?? $ticket['updater_username'] ?? null,
+ ];
+
+ if ($include_comments) {
+ $comments = (new \CommentModel($this->conn))->getCommentsByTicketId($ticketId, false);
+ $details['comment_count'] = count($comments);
+ $details['comments'] = array_map(fn(array $c) => [
+ 'comment_id' => (int)$c['comment_id'],
+ 'author' => $c['display_name'] ?? $c['username'] ?? $c['user_name'] ?? null,
+ 'created_at' => $c['created_at'] ?? null,
+ 'reply_to' => isset($c['parent_comment_id']) ? (int)$c['parent_comment_id'] : null,
+ 'text' => $c['comment_text'] ?? '',
+ ], array_slice($comments, 0, self::MAX_COMMENTS));
+ if (count($comments) > self::MAX_COMMENTS) {
+ $details['comments_truncated'] = true;
+ }
+ }
+
+ return $details;
+ }
+
+ /**
+ * @param array $user
+ */
+ private function resolveAssigneeFilter(string $assignee, array $user): int|string
+ {
+ $lower = strtolower($assignee);
+ if ($lower === 'me') {
+ return (int)$user['user_id'];
+ }
+ if ($lower === 'unassigned') {
+ return 'unassigned';
+ }
+ $match = (new \UserModel($this->conn))->getUserByUsername($assignee);
+ if (!$match) {
+ throw new ToolCallException("Unknown user: {$assignee}");
+ }
+ return (int)$match['user_id'];
+ }
+
+ /**
+ * @param array $t
+ *
+ * @return array
+ */
+ private function summarize(array $t): array
+ {
+ return [
+ 'ticket_id' => (string)$t['ticket_id'],
+ 'title' => $t['title'] ?? '',
+ 'status' => $t['status'] ?? null,
+ 'priority' => isset($t['priority']) ? (int)$t['priority'] : null,
+ 'category' => $t['category'] ?? null,
+ 'type' => $t['type'] ?? null,
+ 'visibility' => $t['visibility'] ?? 'public',
+ 'assigned_to' => $t['assigned_display_name'] ?? $t['assigned_username'] ?? null,
+ 'created_by' => $t['creator_display_name'] ?? $t['creator_username'] ?? null,
+ 'created_at' => $t['created_at'] ?? null,
+ 'updated_at' => $t['updated_at'] ?? null,
+ 'url' => \UrlHelper::ticketUrl((string)$t['ticket_id']),
+ ];
+ }
+}
diff --git a/mcp/src/Tools/TicketWriteTools.php b/mcp/src/Tools/TicketWriteTools.php
new file mode 100644
index 0000000..2b28012
--- /dev/null
+++ b/mcp/src/Tools/TicketWriteTools.php
@@ -0,0 +1,216 @@
+
+ */
+ public function createTicket(
+ string $title,
+ string $description,
+ int $priority = 4,
+ string $category = 'General',
+ string $type = 'Issue',
+ string $visibility = 'public',
+ ?string $visibility_groups = null,
+ ?string $assignee = null,
+ ): array {
+ $user = McpIdentity::user();
+
+ // The web form constrains these with dropdowns; an API caller can send
+ // anything, so validate them here before handing off.
+ if ($priority < 1 || $priority > 5) {
+ throw new ToolCallException('priority must be between 1 and 5');
+ }
+ if (!in_array($visibility, self::VISIBILITIES, true)) {
+ throw new ToolCallException('visibility must be one of: ' . implode(', ', self::VISIBILITIES));
+ }
+
+ $assignedTo = null;
+ if ($assignee !== null && trim($assignee) !== '') {
+ $assignedTo = $this->resolveUserId(trim($assignee), $user);
+ }
+
+ $result = \TicketCreationService::create($this->conn, $user, [
+ 'title' => $title,
+ 'description' => $description,
+ 'priority' => (string)$priority,
+ 'category' => $category,
+ 'type' => $type,
+ 'status' => 'Open',
+ 'visibility' => $visibility,
+ 'visibility_groups' => $visibility_groups,
+ 'assigned_to' => $assignedTo,
+ ]);
+ if (!$result['success']) {
+ throw new ToolCallException($result['error']);
+ }
+
+ return [
+ 'ticket_id' => (string)$result['ticket_id'],
+ 'url' => \UrlHelper::ticketUrl((string)$result['ticket_id']),
+ ];
+ }
+
+ /**
+ * Post a comment on a ticket as you. @username mentions notify that user.
+ *
+ * @param string $ticket_id The ticket ID.
+ * @param string $text The comment text.
+ * @param bool $markdown Render the comment as markdown.
+ * @param int|null $reply_to comment_id of the comment you are replying to, if any.
+ *
+ * @return array
+ */
+ public function addComment(string $ticket_id, string $text, bool $markdown = true, ?int $reply_to = null): array
+ {
+ $user = McpIdentity::user();
+ $result = \CommentService::addComment($this->conn, $user, [
+ 'ticket_id' => $ticket_id,
+ 'comment_text' => $text,
+ 'markdown_enabled' => $markdown,
+ 'parent_comment_id' => $reply_to,
+ ]);
+ if (empty($result['success'])) {
+ throw new ToolCallException($this->errorMessage($result, $ticket_id));
+ }
+
+ return [
+ 'comment_id' => (int)$result['comment_id'],
+ 'ticket_id' => trim($ticket_id),
+ 'mentions' => $result['mentions'] ?? [],
+ ];
+ }
+
+ /**
+ * Change a ticket's status. Transitions follow the Workflow Designer rules;
+ * some transitions (e.g. closing) require a comment, which is posted as the reason.
+ *
+ * @param string $ticket_id The ticket ID.
+ * @param string $status New status, e.g. "Open", "Pending", "In Progress", "Closed".
+ * @param string|null $comment Reason for the change; required for transitions that need one.
+ *
+ * @return array
+ */
+ public function updateStatus(string $ticket_id, string $status, ?string $comment = null): array
+ {
+ $user = McpIdentity::user();
+ $validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
+ if (!in_array($status, $validStatuses, true)) {
+ throw new ToolCallException('Unknown status. Valid statuses: ' . implode(', ', $validStatuses));
+ }
+
+ $ticketId = trim($ticket_id);
+ if (!ctype_digit($ticketId)) {
+ throw new ToolCallException("Ticket {$ticketId} not found");
+ }
+
+ $controller = new \ApiTicketController($this->conn, (int)$user['user_id'], !empty($user['is_admin']), $user);
+ $data = ['status' => $status];
+ if ($comment !== null && trim($comment) !== '') {
+ $data['comment'] = $comment;
+ $data['markdown_enabled'] = true;
+ }
+ $result = $controller->update($ticketId, $data);
+ if (empty($result['success'])) {
+ if (!empty($result['requires_comment'])) {
+ throw new ToolCallException('This status change requires a comment explaining why. Call update_status again with a comment.');
+ }
+ throw new ToolCallException($this->errorMessage($result, $ticketId));
+ }
+
+ // api/update_ticket.php invalidates the dashboard stats cache after a
+ // successful update (outside the controller); do the same here.
+ (new \StatsModel($this->conn))->invalidateCache();
+
+ return ['ticket_id' => $ticketId, 'status' => $result['status']];
+ }
+
+ /**
+ * Assign a ticket to someone, or unassign it. Only admins, the ticket's
+ * creator, or its current assignee may do this.
+ *
+ * @param string $ticket_id The ticket ID.
+ * @param string $assignee Username to assign to, "me", or "unassigned".
+ *
+ * @return array
+ */
+ public function assignTicket(string $ticket_id, string $assignee): array
+ {
+ $user = McpIdentity::user();
+ $target = trim($assignee);
+ $assignedTo = strtolower($target) === 'unassigned' || $target === ''
+ ? null
+ : $this->resolveUserId($target, $user);
+
+ $result = \AssignmentService::assign($this->conn, $user, [
+ 'ticket_id' => $ticket_id,
+ 'assigned_to' => $assignedTo,
+ ]);
+ if (empty($result['success'])) {
+ throw new ToolCallException($this->errorMessage($result, $ticket_id));
+ }
+
+ return ['ticket_id' => trim($ticket_id), 'assigned_to' => $assignedTo === null ? null : $target];
+ }
+
+ /**
+ * @param array $user
+ */
+ private function resolveUserId(string $username, array $user): int
+ {
+ if (strtolower($username) === 'me') {
+ return (int)$user['user_id'];
+ }
+ $match = (new \UserModel($this->conn))->getUserByUsername($username);
+ if (!$match) {
+ throw new ToolCallException("Unknown user: {$username}");
+ }
+ return (int)$match['user_id'];
+ }
+
+ /**
+ * Map a shared-service failure to a tool error. A ticket the user can't
+ * see reads as "not found" (like get_ticket), never "access denied", so a
+ * restricted ticket's existence isn't revealed.
+ *
+ * @param array $result
+ */
+ private function errorMessage(array $result, string $ticketId): string
+ {
+ $error = (string)($result['error'] ?? 'Request failed');
+ if (in_array($error, ['Access denied', 'Ticket not found', 'Invalid ticket ID', 'Ticket ID required'], true)) {
+ return 'Ticket ' . trim($ticketId) . ' not found';
+ }
+ return $error;
+ }
+}
diff --git a/middleware/AuthMiddleware.php b/middleware/AuthMiddleware.php
index 27686fb..9d68ab8 100644
--- a/middleware/AuthMiddleware.php
+++ b/middleware/AuthMiddleware.php
@@ -263,21 +263,8 @@ class AuthMiddleware
*/
private function checkGroupAccess($groups)
{
- if (empty($groups)) {
- return false;
- }
-
- // Check for admin or employee group membership
- // Filter to safe characters only to prevent header injection attacks
- $userGroups = array_filter(
- array_map('trim', explode(',', strtolower($groups))),
- function ($g) {
- return preg_match('/^[a-z0-9_\-]+$/', $g);
- }
- );
- $requiredGroups = ['admin', 'employee'];
-
- return !empty(array_intersect($userGroups, $requiredGroups));
+ require_once dirname(__DIR__) . '/helpers/AccessPolicy.php';
+ return AccessPolicy::hasAppAccess((string)($groups ?? ''));
}
/**
diff --git a/services/AssignmentService.php b/services/AssignmentService.php
new file mode 100644
index 0000000..706fcba
--- /dev/null
+++ b/services/AssignmentService.php
@@ -0,0 +1,98 @@
+ false, 'error' => 'Ticket ID required', 'http_status' => 400];
+ }
+ $ticketId = $ticketIdRaw;
+
+ $ticketModel = new TicketModel($conn);
+ $auditLogModel = new AuditLogModel($conn);
+ $userModel = new UserModel($conn);
+
+ // Verify ticket exists and user can access it
+ $ticket = $ticketModel->getTicketById($ticketId);
+ if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
+ return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404];
+ }
+
+ // Authorization: only admins or the ticket creator/assignee can reassign
+ if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) {
+ return ['success' => false, 'error' => 'Permission denied', 'http_status' => 403];
+ }
+
+ if ($assignedTo === null || $assignedTo === '') {
+ // Unassign ticket
+ $success = $ticketModel->unassignTicket($ticketId, $userId);
+ if ($success) {
+ $auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
+ }
+ } else {
+ // Validate assigned_to is a valid user ID
+ $assignedTo = (int)$assignedTo;
+ $targetUser = $userModel->getUserById($assignedTo);
+ if (!$targetUser) {
+ return ['success' => false, 'error' => 'Invalid user ID', 'http_status' => 400];
+ }
+
+ // Assign ticket
+ $success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
+ if ($success) {
+ $auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
+
+ if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) {
+ $changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
+ $assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null;
+ $assigneeMatrix = isset($targetUser['username'])
+ ? SynapseHelper::resolveUsername($targetUser['username'])
+ : null;
+ NotificationHelper::sendAssignmentNotification(
+ $ticketId,
+ $ticket['title'] ?? "Ticket #{$ticketId}",
+ $assigneeName,
+ $assigneeMatrix,
+ $changedByDisplay,
+ $ticket['visibility'] ?? 'public'
+ );
+ }
+ }
+ }
+
+ if (!$success) {
+ return ['success' => false, 'error' => 'Failed to update ticket assignment', 'http_status' => 500];
+ }
+
+ (new StatsModel($conn))->invalidateCache();
+ return ['success' => true];
+ }
+}
diff --git a/services/CommentService.php b/services/CommentService.php
new file mode 100644
index 0000000..a837ccd
--- /dev/null
+++ b/services/CommentService.php
@@ -0,0 +1,160 @@
+ false, 'error' => 'Invalid ticket ID', 'http_status' => 400];
+ }
+
+ // Reject empty/whitespace-only comments
+ $commentTextRaw = isset($data['comment_text']) ? trim((string)$data['comment_text']) : '';
+ if ($commentTextRaw === '') {
+ return ['success' => false, 'error' => 'Comment text cannot be empty', 'http_status' => 400];
+ }
+
+ // Persist the trimmed text (not the raw client value) — matches update_comment.php
+ // and keeps stored comment_text free of leading whitespace that could shift a
+ // markdown-enabled comment's first line out of column 0 on reload.
+ $data['comment_text'] = $commentTextRaw;
+
+ // Never trust a client-supplied display name — always attribute the comment to
+ // the authenticated user.
+ $data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
+
+ // Verify user can access the ticket before allowing a comment
+ $ticketModel = new TicketModel($conn);
+ $ticket = $ticketModel->getTicketById($ticketId);
+ if (!$ticket) {
+ return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404];
+ }
+ if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
+ return ['success' => false, 'error' => 'Access denied', 'http_status' => 403];
+ }
+
+ // Initialize models
+ $commentModel = new CommentModel($conn);
+ $auditLog = new AuditLogModel($conn);
+
+ // If replying, the parent comment must belong to this same (accessible) ticket.
+ if (isset($data['parent_comment_id']) && $data['parent_comment_id'] !== null && $data['parent_comment_id'] !== '') {
+ $parentComment = $commentModel->getCommentById((int)$data['parent_comment_id']);
+ if (!$parentComment || (string)$parentComment['ticket_id'] !== (string)$ticketId) {
+ return ['success' => false, 'error' => 'Invalid parent comment', 'http_status' => 400];
+ }
+ }
+
+ // Extract @mentions from comment text
+ $mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
+ $mentionedUsers = [];
+ if (!empty($mentions)) {
+ $mentionedUsers = $commentModel->getMentionedUsers($mentions);
+ }
+
+ // Add comment with user tracking
+ $result = $commentModel->addComment($ticketId, $data, $userId);
+
+ // Log comment creation to audit log
+ if ($result['success'] && isset($result['comment_id'])) {
+ $auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
+
+ // Log mentions to audit log
+ foreach ($mentionedUsers as $mentionedUser) {
+ $auditLog->log(
+ $userId,
+ 'mention',
+ 'user',
+ (string)$mentionedUser['user_id'],
+ [
+ 'ticket_id' => $ticketId,
+ 'comment_id' => $result['comment_id'],
+ 'mentioned_username' => $mentionedUser['username']
+ ]
+ );
+ }
+
+ // Matrix notifications
+ $authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
+ $commentText = $data['comment_text'] ?? '';
+ $ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
+ $ticketVisibility = $ticket['visibility'] ?? 'public';
+
+ // @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API.
+ // Only notify mentioned users who actually have access to this ticket;
+ // otherwise a mention would DM them the ticket's title and comment text
+ // even though canUserAccessTicket() would deny them the ticket itself.
+ $accessibleMentionedUsers = array_filter(
+ $mentionedUsers,
+ fn($u) => $ticketModel->canUserAccessTicket($ticket, $u)
+ );
+ if (!empty($accessibleMentionedUsers)) {
+ $mentionedUsernames = array_column($accessibleMentionedUsers, 'username');
+ $mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
+ if (!empty($mentionedMatrixIds)) {
+ NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
+ }
+ }
+
+ // General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
+ if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
+ NotificationHelper::sendCommentNotification(
+ $ticketId,
+ $ticketTitle,
+ $commentText,
+ $authorDisplay,
+ $ticketVisibility !== 'public',
+ $ticketVisibility
+ );
+ }
+
+ // Notify watchers of the new comment
+ NotificationHelper::notifyWatchers(
+ $conn,
+ $ticketId,
+ $ticketTitle,
+ 'comment_added',
+ ['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
+ (int)$userId,
+ $ticketVisibility
+ );
+
+ // Add mentioned users to result for frontend
+ $result['mentions'] = array_map(function ($u) {
+ return $u['username'];
+ }, $mentionedUsers);
+ }
+
+ // Add user info to result for frontend avatar rendering
+ if ($result['success']) {
+ $result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
+ $result['user_id'] = $userId;
+ }
+
+ return $result;
+ }
+}
diff --git a/services/TicketCreationService.php b/services/TicketCreationService.php
new file mode 100644
index 0000000..f60138b
--- /dev/null
+++ b/services/TicketCreationService.php
@@ -0,0 +1,134 @@
+ value), link_duplicate_of
+ *
+ * @return array ['success' => true, 'ticket_id' => ...] or ['success' => false, 'error' => ...]
+ */
+ public static function create(mysqli $conn, array $currentUser, array $input): array
+ {
+ $userId = $currentUser['user_id'] ?? null;
+
+ // Handle visibility groups (a list from the web form's checkboxes, or a string)
+ $visibilityGroups = null;
+ if (isset($input['visibility_groups']) && is_array($input['visibility_groups'])) {
+ $visibilityGroups = implode(',', array_map('trim', $input['visibility_groups']));
+ } elseif (isset($input['visibility_groups']) && is_string($input['visibility_groups']) && trim($input['visibility_groups']) !== '') {
+ $visibilityGroups = trim($input['visibility_groups']);
+ }
+
+ // Honor the posted status, validated against the app's canonical list
+ $validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
+ $status = $input['status'] ?? 'Open';
+ if (!in_array($status, $validStatuses, true)) {
+ $status = 'Open';
+ }
+
+ $ticketData = [
+ 'title' => trim($input['title'] ?? ''),
+ 'description' => $input['description'] ?? '',
+ 'priority' => $input['priority'] ?? '4',
+ 'category' => $input['category'] ?? 'General',
+ 'type' => $input['type'] ?? 'Issue',
+ 'status' => $status,
+ 'visibility' => $input['visibility'] ?? 'public',
+ 'visibility_groups' => $visibilityGroups,
+ 'assigned_to' => !empty($input['assigned_to']) ? $input['assigned_to'] : null
+ ];
+
+ if ($ticketData['title'] === '') {
+ return ['success' => false, 'error' => 'Title is required'];
+ }
+
+ if (trim($ticketData['description']) === '') {
+ return ['success' => false, 'error' => 'Description is required'];
+ }
+
+ // Custom fields applicable to the submitted category — validate
+ // is_required server-side (the form is novalidate, and a field
+ // hidden by the client-side category toggle must not silently
+ // bypass a requirement that applies to the category actually
+ // submitted).
+ $customFieldModel = new CustomFieldModel($conn);
+ $allCustomFieldDefs = $customFieldModel->getAllDefinitions(null, true);
+ $submittedCustomFields = is_array($input['custom_fields'] ?? null) ? $input['custom_fields'] : [];
+ $applicableFieldDefs = array_filter(
+ $allCustomFieldDefs,
+ fn($def) => $def['category'] === null || $def['category'] === $ticketData['category']
+ );
+ $customFieldsToSave = [];
+ foreach ($applicableFieldDefs as $def) {
+ $fieldId = (int)$def['field_id'];
+ $raw = $submittedCustomFields[$fieldId] ?? null;
+ $normalized = $def['field_type'] === 'checkbox'
+ ? (!empty($raw) ? '1' : '0')
+ : (is_scalar($raw) ? trim((string)$raw) : '');
+
+ if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
+ return ['success' => false, 'error' => $def['field_label'] . ' is required'];
+ }
+
+ if ($normalized !== '') {
+ $customFieldsToSave[$fieldId] = $normalized;
+ }
+ }
+
+ // Create ticket with user tracking
+ $result = (new TicketModel($conn))->createTicket($ticketData, $userId);
+ if (!$result['success']) {
+ return ['success' => false, 'error' => $result['error']];
+ }
+
+ // Log ticket creation to audit log
+ if ($userId) {
+ (new AuditLogModel($conn))->logTicketCreate($userId, $result['ticket_id'], $ticketData);
+ }
+
+ // Ticket counts changed — invalidate the cached dashboard stats
+ (new StatsModel($conn))->invalidateCache();
+
+ // Persist custom field values for the fields applicable to this ticket's category
+ if (!empty($customFieldsToSave)) {
+ $customFieldModel->setValues($result['ticket_id'], $customFieldsToSave);
+ }
+
+ // Auto-link as duplicate if requested
+ $linkDupOfRaw = trim((string)($input['link_duplicate_of'] ?? ''));
+ if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
+ $depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
+ VALUES (?, ?, 'duplicates', ?)";
+ $depStmt = $conn->prepare($depSql);
+ $depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId);
+ $depStmt->execute();
+ $depStmt->close();
+ }
+
+ // Send Matrix notification for new ticket
+ NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
+
+ return ['success' => true, 'ticket_id' => $result['ticket_id']];
+ }
+}