Compare commits

...
Author SHA1 Message Date
jared da916bc5a6 Merge development: fix ticket page 500 (#112)
Lint / PHP (phpcs PSR-12) (push) Successful in 33s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 24s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m38s
Lint / Deploy (push) Successful in 5s
2026-09-25 15:45:07 -04:00
jaredandClaude Opus 5.5 ad201691e4 Fix ticket page 500 from unbounded audit timeline (#112)
Lint / PHP (phpcs PSR-12) (push) Successful in 21s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m22s
Lint / Deploy (push) Successful in 4s
A ticket updated by hwmonDaemon had ~52k audit rows; getTicketTimeline()
loaded all of them and exhausted PHP's 128MB memory limit, so the ticket
page returned 500. Not related to the MCP server.

- getTicketTimeline() takes a limit (newest first). The ticket page shows
  the latest 500 events with a note when older ones are omitted; the JSON
  export caps at 5000.
- create_ticket_api.php: the description is refreshed on every run, and
  that alone wrote a reason-only audit row every few minutes per open
  ticket. Audit only real title/priority changes.
- create_ticket_api.php: after creating a brand-new ticket the dedup retry
  loop fell through into a second iteration on a closed connection,
  appending a 500 error body after the success response. Exit instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-25 15:34:44 -04:00
jaredandClaude Opus 5.5 dad066cb32 Merge development into main: MCP server documentation (#111)
Lint / PHP (phpcs PSR-12) (push) Successful in 21s
Lint / JS (eslint) (push) Successful in 13s
Lint / PHP requirements (version + extensions) (push) Successful in 49s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m55s
Lint / Deploy (push) Successful in 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 19:26:42 -04:00
jaredandClaude Opus 5.5 9c633aa09b Document the MCP server: connecting Claude Code, tools, architecture (#111)
Lint / PHP (phpcs PSR-12) (push) Successful in 42s
Lint / JS (eslint) (push) Successful in 16s
Lint / PHP requirements (version + extensions) (push) Successful in 42s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m46s
Lint / Deploy (push) Successful in 2s
Adds a README section on connecting Claude Code to /mcp (the pre-registered
client id + fixed callback port Authelia requires, `claude mcp login`
incl. --no-browser for headless hosts), the six tools and their scopes,
and how the pieces fit together: Authelia as the authorization server
(and the 4.39.21/22 RFC 8707 bug to avoid), token validation incl. the
per-server audience, Composer being MCP-only, and the proxy exemptions.
Also updates the project-structure tree (mcp/, services/,
ApiTicketController, composer files, migrations 005-007) and notes that
uploads/ is only served via PHP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 19:26:40 -04:00
6 changed files with 80 additions and 11 deletions
+43 -1
View File
@@ -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/
+1 -1
View File
@@ -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 [
+8 -2
View File
@@ -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
View File
@@ -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;
} }
+10 -3
View File
@@ -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();
+3
View File
@@ -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>