Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da916bc5a6 | ||
|
|
ad201691e4 | ||
|
|
dad066cb32 | ||
|
|
9c633aa09b |
@@ -113,6 +113,33 @@ All Bearer-authenticated, rate-limited, and (like `create_ticket_api.php`) exemp
|
|||||||
| `/api/ticket_comment_api.php` | POST | read_write | Add a comment: `{ticket_id, comment_text, markdown_enabled?}` |
|
| `/api/ticket_comment_api.php` | POST | read_write | Add a comment: `{ticket_id, comment_text, markdown_enabled?}` |
|
||||||
| `/api/ticket_status_api.php` | POST | read_write | Change/close status (workflow-validated): `{ticket_id, status, comment?}` — `comment` is required for transitions that require one (e.g. → Closed); it is posted as the close reason in the same call |
|
| `/api/ticket_status_api.php` | POST | read_write | Change/close status (workflow-validated): `{ticket_id, status, comment?}` — `comment` is required for transitions that require one (e.g. → Closed); it is posted as the close reason in the same call |
|
||||||
|
|
||||||
|
### MCP server (Claude Code and other MCP clients)
|
||||||
|
`/mcp` is a remote [MCP](https://modelcontextprotocol.io) server (Streamable HTTP) protected by **OAuth via Authelia** — no API keys. You sign in once in the browser and every action runs **as you**: the same visibility, permissions, Workflow Designer rules, audit log and notifications as the web UI.
|
||||||
|
|
||||||
|
**Connect Claude Code** (one-time):
|
||||||
|
```bash
|
||||||
|
claude mcp add --transport http --scope user \
|
||||||
|
--client-id tinker-tickets-mcp --callback-port 47823 \
|
||||||
|
tinker https://t.lotusguild.org/mcp
|
||||||
|
claude mcp login tinker # add --no-browser on a headless machine, then paste the redirect URL back
|
||||||
|
```
|
||||||
|
`--client-id` / `--callback-port 47823` are required: Authelia doesn't support dynamic client registration, so Claude Code uses the pre-registered public client `tinker-tickets-mcp`, whose only allowed redirect is `http://localhost:47823/callback`. Access requires the same `admin`/`employee` group as the web UI. Tokens refresh automatically (refresh token lasts 30 days); `claude mcp logout tinker` signs out.
|
||||||
|
|
||||||
|
| Tool | Scope | What it does |
|
||||||
|
|------|-------|--------------|
|
||||||
|
| `search_tickets` | `tickets:read` | Search/list tickets you can see (text, status, priority, category, assignee `me`/`unassigned`/username), paginated |
|
||||||
|
| `get_ticket` | `tickets:read` | One ticket's details + comments |
|
||||||
|
| `create_ticket` | `tickets:write` | Create a ticket (title, description, priority, category, type, visibility, assignee) |
|
||||||
|
| `add_comment` | `tickets:write` | Comment (markdown, @mentions, replies) |
|
||||||
|
| `update_status` | `tickets:write` | Workflow-validated status change; `comment` required when the transition requires one |
|
||||||
|
| `assign_ticket` | `tickets:write` | Assign / unassign (admin, creator, or current assignee only) |
|
||||||
|
|
||||||
|
How it fits together (see issue #111 for the full design):
|
||||||
|
- **Authelia** is the authorization server: client `tinker-tickets-mcp`, custom scopes `tickets:read`/`tickets:write`, RS256 JWT access tokens carrying `preferred_username`/`groups`. Needs Authelia **≠ 4.39.21/4.39.22** (RFC 8707 `resource` bug); 4.39.20 or ≥ 4.39.23 are fine.
|
||||||
|
- **`mcp/server.php`** is the resource server (official `mcp/sdk`, pinned). It validates each token's signature (JWKS), issuer, **audience = this server's URL** (`MCP_RESOURCE_URL`, derived from `APP_DOMAIN`, so a beta token can't be used on prod) and expiry, then maps the user with the same rules as the web login. It serves RFC 9728 metadata at `/.well-known/oauth-protected-resource/mcp`.
|
||||||
|
- **Composer is used only by the MCP endpoint** (`vendor/` is gitignored and installed by the deploy script with `composer install --no-dev`); nothing else in the app loads it.
|
||||||
|
- **Reverse proxy:** `/mcp` and `/.well-known/oauth-protected-resource` are exempt from Authelia forward-auth (the token is the credential) and have `Remote-*` headers blanked; `vendor/`, `mcp/` and `composer.*` are never served.
|
||||||
|
|
||||||
### User Management & Authentication
|
### User Management & Authentication
|
||||||
- **SSO Integration**: Authelia authentication with LLDAP backend
|
- **SSO Integration**: Authelia authentication with LLDAP backend
|
||||||
- **Role-Based Access**: Admin and standard user roles
|
- **Role-Based Access**: Admin and standard user roles
|
||||||
@@ -355,7 +382,9 @@ tinker_tickets/
|
|||||||
│ ├── config.php # Config + .env loading
|
│ ├── config.php # Config + .env loading
|
||||||
│ └── requirements.php # PHP version/extension requirements (single source of
|
│ └── requirements.php # PHP version/extension requirements (single source of
|
||||||
│ # truth for scripts/check_requirements.php + api/health.php)
|
│ # truth for scripts/check_requirements.php + api/health.php)
|
||||||
|
├── composer.json / composer.lock # MCP endpoint dependencies only (mcp/sdk pinned); vendor/ installed at deploy
|
||||||
├── controllers/
|
├── controllers/
|
||||||
|
│ ├── ApiTicketController.php # Partial ticket updates (status/fields/visibility), shared by update_ticket.php + MCP
|
||||||
│ ├── CommentController.php # Comment create/edit/delete + notifications
|
│ ├── CommentController.php # Comment create/edit/delete + notifications
|
||||||
│ ├── DashboardController.php # Dashboard with stats + filters
|
│ ├── DashboardController.php # Dashboard with stats + filters
|
||||||
│ └── TicketController.php # Ticket CRUD + timeline + visibility
|
│ └── TicketController.php # Ticket CRUD + timeline + visibility
|
||||||
@@ -399,11 +428,24 @@ tinker_tickets/
|
|||||||
│ ├── 002_fix_collation_consistency.sql # Upgrade-only (already in baseline for fresh installs)
|
│ ├── 002_fix_collation_consistency.sql # Upgrade-only (already in baseline for fresh installs)
|
||||||
│ ├── 003_fk_on_delete_set_null.sql # Upgrade-only (already in baseline for fresh installs)
|
│ ├── 003_fk_on_delete_set_null.sql # Upgrade-only (already in baseline for fresh installs)
|
||||||
│ ├── 004_fix_ticket_watchers_type.sql # Upgrade-only (already in baseline for fresh installs)
|
│ ├── 004_fix_ticket_watchers_type.sql # Upgrade-only (already in baseline for fresh installs)
|
||||||
|
│ ├── 005_attachment_thumbnails.sql # ticket_attachments.thumbnail_filename
|
||||||
|
│ ├── 006_api_key_visibility_scope.sql # api_keys.see_all_visibility (public-only by default)
|
||||||
|
│ ├── 007_notification_retry_queue.sql # Queue for failed Matrix webhook retries
|
||||||
│ └── migrate.php # CLI migration runner (tracks applied migrations)
|
│ └── migrate.php # CLI migration runner (tracks applied migrations)
|
||||||
|
├── mcp/
|
||||||
|
│ ├── server.php # MCP endpoint (/mcp): OAuth resource server; only file that loads vendor/
|
||||||
|
│ └── src/
|
||||||
|
│ ├── Auth/ # IdentityMiddleware (token -> user), ToolScopeMiddleware, McpIdentity
|
||||||
|
│ ├── Tools/ # TicketReadTools, TicketWriteTools
|
||||||
|
│ └── ToolCatalog.php # Tool registry + which tools need tickets:write
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ ├── check_requirements.php # Verify PHP extensions/config prerequisites
|
│ ├── check_requirements.php # Verify PHP extensions/config prerequisites
|
||||||
│ └── cleanup_orphan_uploads.php # Delete orphaned upload files past grace period (cron)
|
│ └── cleanup_orphan_uploads.php # Delete orphaned upload files past grace period (cron)
|
||||||
├── uploads/ # File attachment storage
|
├── services/
|
||||||
|
│ ├── AssignmentService.php # Assign/unassign (shared by assign_ticket.php + MCP)
|
||||||
|
│ ├── CommentService.php # Add comment + mentions/notifications (shared by add_comment.php + MCP)
|
||||||
|
│ └── TicketCreationService.php # Create ticket (shared by TicketController + MCP)
|
||||||
|
├── uploads/ # File attachment storage (served only via PHP; nginx: internal)
|
||||||
│ └── avatars/ # lldap avatar disk cache
|
│ └── avatars/ # lldap avatar disk cache
|
||||||
├── views/
|
├── views/
|
||||||
│ ├── admin/
|
│ ├── admin/
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ try {
|
|||||||
|
|
||||||
// Load flat comment list (no threading nesting in export)
|
// Load flat comment list (no threading nesting in export)
|
||||||
$rawComments = $commentModel->getCommentsByTicketId($ticket['ticket_id'], false);
|
$rawComments = $commentModel->getCommentsByTicketId($ticket['ticket_id'], false);
|
||||||
$timeline = $auditLogModel->getTicketTimeline((string)$ticket['ticket_id']);
|
$timeline = $auditLogModel->getTicketTimeline((string)$ticket['ticket_id'], 5000);
|
||||||
|
|
||||||
$comments = array_map(function ($c) {
|
$comments = array_map(function ($c) {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -54,8 +54,14 @@ class TicketController
|
|||||||
$totalComments = $this->commentModel->getCommentCount((int)$id);
|
$totalComments = $this->commentModel->getCommentCount((int)$id);
|
||||||
$comments = $this->commentModel->getCommentsByTicketId($id, true, $commentPageSize, 0);
|
$comments = $this->commentModel->getCommentsByTicketId($id, true, $commentPageSize, 0);
|
||||||
|
|
||||||
// Get timeline for this ticket
|
// Get the newest timeline events for this ticket. One extra row is
|
||||||
$timeline = $this->auditLogModel->getTicketTimeline($id);
|
// fetched only to tell the view that older events were left out.
|
||||||
|
$timelineLimit = 500;
|
||||||
|
$timeline = $this->auditLogModel->getTicketTimeline($id, $timelineLimit + 1);
|
||||||
|
$timelineTruncated = count($timeline) > $timelineLimit;
|
||||||
|
if ($timelineTruncated) {
|
||||||
|
array_pop($timeline);
|
||||||
|
}
|
||||||
|
|
||||||
// Get all users for assignment dropdown
|
// Get all users for assignment dropdown
|
||||||
$allUsers = $this->userModel->getAllUsers();
|
$allUsers = $this->userModel->getAllUsers();
|
||||||
|
|||||||
+15
-4
@@ -328,10 +328,17 @@ for ($dedupAttempt = 1; $dedupAttempt <= $maxDedupAttempts; $dedupAttempt++) {
|
|||||||
$commentStmt->close();
|
$commentStmt->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
|
// Audit only real title/priority changes. The description is
|
||||||
array_diff_key($changes, ['description_refreshed' => true]),
|
// refreshed on every run, and logging that alone wrote a
|
||||||
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
|
// reason-only row every few minutes per open ticket — 50k+ rows
|
||||||
));
|
// on one ticket, which broke its page (tinker_tickets#112).
|
||||||
|
$auditedChanges = array_diff_key($changes, ['description_refreshed' => true]);
|
||||||
|
if (!empty($auditedChanges)) {
|
||||||
|
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
|
||||||
|
$auditedChanges,
|
||||||
|
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Only notify on priority escalation — title-only updates (e.g. rising
|
// Only notify on priority escalation — title-only updates (e.g. rising
|
||||||
// Power_On_Hours counter) should not generate a Matrix ping every hour.
|
// Power_On_Hours counter) should not generate a Matrix ping every hour.
|
||||||
@@ -581,4 +588,8 @@ for ($dedupAttempt = 1; $dedupAttempt <= $maxDedupAttempts; $dedupAttempt++) {
|
|||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
||||||
}
|
}
|
||||||
|
// Done — only the deadlock `continue` above may run the loop again.
|
||||||
|
// Falling through re-entered it on a closed connection, appending a
|
||||||
|
// 500 error body after the success response for every new ticket.
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -561,20 +561,27 @@ class AuditLogModel
|
|||||||
* Get formatted timeline for a specific ticket
|
* Get formatted timeline for a specific ticket
|
||||||
* Includes all ticket updates and comments
|
* Includes all ticket updates and comments
|
||||||
*
|
*
|
||||||
|
* Returns only the newest $limit events: a ticket touched by an automated
|
||||||
|
* reporter can accumulate tens of thousands of rows, and loading them all
|
||||||
|
* exhausted PHP memory on the ticket page (tinker_tickets#112).
|
||||||
|
*
|
||||||
* @param string $ticketId Ticket ID
|
* @param string $ticketId Ticket ID
|
||||||
|
* @param int $limit Maximum number of events (newest first)
|
||||||
* @return array Timeline events
|
* @return array Timeline events
|
||||||
*/
|
*/
|
||||||
public function getTicketTimeline($ticketId)
|
public function getTicketTimeline($ticketId, $limit = self::DEFAULT_LIMIT)
|
||||||
{
|
{
|
||||||
|
$limit = $this->validateLimit((int)$limit, self::EXPORT_LIMIT);
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT al.*, u.username, u.display_name
|
"SELECT al.*, u.username, u.display_name
|
||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
LEFT JOIN users u ON al.user_id = u.user_id
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
WHERE (al.entity_type = 'ticket' AND al.entity_id = ?)
|
WHERE (al.entity_type = 'ticket' AND al.entity_id = ?)
|
||||||
OR (al.entity_type = 'comment' AND JSON_UNQUOTE(JSON_EXTRACT(al.details, '$.ticket_id')) = ?)
|
OR (al.entity_type = 'comment' AND JSON_UNQUOTE(JSON_EXTRACT(al.details, '$.ticket_id')) = ?)
|
||||||
ORDER BY al.created_at DESC"
|
ORDER BY al.created_at DESC, al.audit_id DESC
|
||||||
|
LIMIT ?"
|
||||||
);
|
);
|
||||||
$stmt->bind_param("ss", $ticketId, $ticketId);
|
$stmt->bind_param("ssi", $ticketId, $ticketId, $limit);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
|||||||
@@ -819,6 +819,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
</div>
|
</div>
|
||||||
<?php endforeach ?>
|
<?php endforeach ?>
|
||||||
</div>
|
</div>
|
||||||
|
<?php if (!empty($timelineTruncated)) : ?>
|
||||||
|
<div class="lt-empty lt-text-xs">Showing the <?= count($timeline) ?> most recent events; older activity is in the audit log.</div>
|
||||||
|
<?php endif ?>
|
||||||
<?php endif ?>
|
<?php endif ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user