Files
tinker_tickets/config/config.php
T
jaredandClaude Opus 5.5 5631731a28
Lint / PHP (phpcs PSR-12) (push) Successful in 51s
Lint / JS (eslint) (push) Successful in 10s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m17s
Lint / Deploy (push) Successful in 7s
Add OAuth-protected MCP endpoint scaffolding (#111, phase 2)
First step of the remote MCP server: mcp/server.php serves /mcp over
Streamable HTTP via the official MCP PHP SDK (mcp/sdk, pinned to exactly
0.8.1 since it breaks BC in nearly every minor release), with Authelia as
the OAuth authorization server. No tools yet: this phase only stands up
authentication, RFC 9728 Protected Resource Metadata, and routing.

- Composer is introduced for the MCP endpoint ONLY: nothing else loads
  vendor/autoload.php, so a failed composer install at deploy time can
  only take /mcp down. vendor/ is gitignored and excluded from phpcs;
  composer.lock is resolved for PHP 8.2 so it installs on 8.2 and 8.4.
- Tokens are validated against Authelia's JWKS (cached) for signature,
  issuer, audience == MCP_RESOURCE_URL (a beta token is rejected by prod
  and vice versa), and expiry. scopeClaim is 'scp' because Authelia puts
  scopes in an array claim of that name, not the standard 'scope'.
- The request URI's scheme/host are pinned to MCP_RESOURCE_URL before the
  SDK sees it: TLS ends at the proxy, so PHP sees http and a
  client-controlled Host, and the SDK builds the 401 challenge's
  resource_metadata URL from that. preserveHost keeps the real Host
  header for the DNS-rebinding check, which only allows the canonical
  hostname (so direct-by-IP access is refused too).
- Identity will come only from the token; this entrypoint never reads
  Remote-* headers or $_SESSION, since /mcp is exempt from forward-auth
  at the proxy and those headers are client-controlled there.

Verified locally with PHP's built-in server: unauthenticated POST gets
401 + WWW-Authenticate with the https resource_metadata URL and scopes;
metadata served at both /.well-known/oauth-protected-resource/mcp and the
root form; malformed token -> 401 invalid_token; foreign Host and
direct-IP Host -> 403; a forged Remote-User header without a token is
still 401.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 18:40:11 -04:00

194 lines
9.1 KiB
PHP

<?php
// Load environment variables
$envFile = __DIR__ . '/../.env';
if (!file_exists($envFile)) {
die('Configuration error: .env file not found. Copy .env.example to .env and configure your database settings.');
}
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
if (!is_array($envVars)) {
die('Configuration error: .env file could not be parsed. Check for unquoted special characters (e.g. #, ;, =, or quotes) in values and wrap affected values in double quotes.');
}
// Strip quotes from values if present (parse_ini_file may include them)
if ($envVars) {
foreach ($envVars as $key => $value) {
if (is_string($value)) {
if (
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
) {
$envVars[$key] = substr($value, 1, -1);
}
}
}
}
// Global configuration
$GLOBALS['config'] = [
// Application identity
'APP_NAME' => $envVars['APP_NAME'] ?? 'TINKER TICKETS',
'APP_SUBTITLE' => $envVars['APP_SUBTITLE'] ?? 'LotusGuild Infrastructure',
'APP_VERSION' => $envVars['APP_VERSION'] ?? '1.2',
// Asset cache-busting version — auto-computed from key asset mtimes so
// browsers always pick up changes on deploy. Override via ASSET_VERSION in .env.
'ASSET_VERSION' => (function () use ($envVars) {
if (!empty($envVars['ASSET_VERSION'])) {
return $envVars['ASSET_VERSION'];
}
$files = [
__DIR__ . '/../assets/css/base.css',
__DIR__ . '/../assets/css/dashboard.css',
__DIR__ . '/../assets/css/ticket.css',
__DIR__ . '/../assets/js/base.js',
__DIR__ . '/../assets/js/dashboard.js',
__DIR__ . '/../assets/js/ticket.js',
];
$mtime = 0;
foreach ($files as $f) {
if (file_exists($f)) {
$mtime = max($mtime, filemtime($f));
}
}
return $mtime ?: '20260329';
})(),
// Canonical ticket statuses — single source of truth used by views and JS
'TICKET_STATUSES' => ['Open', 'Pending', 'In Progress', 'Closed'],
// Database settings
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
'DB_USER' => $envVars['DB_USER'] ?? 'root',
'DB_PASS' => $envVars['DB_PASS'] ?? '',
'DB_NAME' => $envVars['DB_NAME'] ?? 'tinkertickets',
// Trusted reverse proxies. Authelia forward-auth (Remote-* headers) is only
// honored when REMOTE_ADDR is in this allowlist, so the spoofable identity
// headers can't be set by anything that reaches PHP directly. Comma-separated
// IPs in .env (e.g. TRUSTED_PROXIES=10.10.10.27). Empty = enforcement OFF
// (backward compatible — relies solely on network topology).
'TRUSTED_PROXIES' => array_values(array_filter(array_map(
'trim',
explode(',', (string)($envVars['TRUSTED_PROXIES'] ?? ''))
), fn($ip) => $ip !== '')),
// URL settings
'BASE_URL' => '', // Empty since we're serving from document root
'ASSETS_URL' => '/assets', // Assets URL
'API_URL' => '/api', // API URL
// Matrix webhook (hookshot generic webhook URL)
'MATRIX_WEBHOOK_URL' => $envVars['MATRIX_WEBHOOK_URL'] ?? null,
// Comma-separated Matrix user IDs to @mention on new tickets / status changes (e.g. @jared:matrix.lotusguild.org)
'MATRIX_NOTIFY_USERS' => $envVars['MATRIX_NOTIFY_USERS'] ?? '',
// Matrix homeserver domain (e.g. matrix.lotusguild.org) — used to construct Matrix user IDs
'MATRIX_DOMAIN' => $envVars['MATRIX_DOMAIN'] ?? null,
// Internal Synapse client-API base URL (e.g. http://10.10.10.29:8008) — used to verify user existence via Admin API
'SYNAPSE_ADMIN_URL' => $envVars['SYNAPSE_ADMIN_URL'] ?? null,
// Synapse admin access token (generate with: register_new_matrix_user or admin API)
'SYNAPSE_ADMIN_TOKEN' => $envVars['SYNAPSE_ADMIN_TOKEN'] ?? null,
// Set to '1' or 'true' to send a notification when any comment is posted
'MATRIX_NOTIFY_COMMENTS' => filter_var($envVars['MATRIX_NOTIFY_COMMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN),
// Set to '1' or 'true' to send a notification when a ticket is assigned
'MATRIX_NOTIFY_ASSIGNMENTS' => filter_var($envVars['MATRIX_NOTIFY_ASSIGNMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN),
// Domain settings for external integrations (webhooks, links, etc.)
// Set APP_DOMAIN in .env to override
'APP_DOMAIN' => $envVars['APP_DOMAIN'] ?? null,
// Allowed hosts for HTTP_HOST validation (comma-separated in .env)
'ALLOWED_HOSTS' => array_filter(array_map(
'trim',
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
// How often an already-logged-in session re-validates Remote-User/
// Remote-Groups against current Authelia/LLDAP state (AuthMiddleware).
// Without this, a revoked admin keeps full access for up to SESSION_TIMEOUT.
'PRIVILEGE_RESYNC_INTERVAL' => 300, // 5 minutes
// CSRF settings
'CSRF_LIFETIME' => 3600, // 1 hour in seconds
// Pagination settings
'PAGINATION_DEFAULT' => 15, // Default items per page
'PAGINATION_MAX' => 100, // Maximum items per page
// File upload settings
'MAX_UPLOAD_SIZE' => 10485760, // 10MB in bytes
'MAX_ATTACHMENTS_PER_TICKET' => 50,
'MAX_TOTAL_ATTACHMENT_SIZE_PER_TICKET' => 104857600, // 100MB in bytes
'ALLOWED_FILE_TYPES' => [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'application/pdf',
'text/plain',
'text/csv',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/zip',
'application/x-7z-compressed',
'application/x-tar',
'application/gzip'
],
'UPLOAD_DIR' => __DIR__ . '/../uploads',
// Rate limiting (requests per minute; read by RateLimitMiddleware)
'RATE_LIMIT_DEFAULT' => (int)($envVars['RATE_LIMIT_DEFAULT'] ?? 100), // Session-based, general endpoints
'RATE_LIMIT_API' => (int)($envVars['RATE_LIMIT_API'] ?? 60), // Session-based, API endpoints
// Audit log settings
'AUDIT_LOG_RETENTION_DAYS' => 90,
// Timezone settings
// Default: America/New_York (EST/EDT)
// Common options: America/Chicago (CST), America/Denver (MST), America/Los_Angeles (PST), UTC
'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York',
'TIMEZONE_OFFSET' => null, // Will be calculated below
// LDAP / lldap settings (for user avatar lookups). Connects over LDAPS
// (see api/user_avatar.php) — lldap's default LDAPS port is 6360, not
// its plaintext port 3890. The bind password must never go over the
// wire unencrypted, so this is not configurable back to a plaintext
// ldap:// connection.
//
// LDAP_HOST must be a hostname matching the LDAPS cert's *.lotusguild.org
// CN/SAN, not a bare IP — PHP's ldap extension verifies the cert's
// hostname by default and a mismatch fails the connection. Pi-hole has a
// split-horizon override so ldap.lotusguild.org resolves internally to
// the real LDAP server IP (its public DNS record points elsewhere).
'LDAP_HOST' => $envVars['LDAP_HOST'] ?? 'ldap.lotusguild.org',
'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 6360),
'LDAP_BIND_DN' => $envVars['LDAP_BIND_DN'] ?? 'uid=tinker-tickets,ou=people,dc=example,dc=com',
'LDAP_BIND_PW' => $envVars['LDAP_BIND_PW'] ?? '',
'LDAP_BASE_DN' => $envVars['LDAP_BASE_DN'] ?? 'dc=example,dc=com',
'LDAP_USER_BASE' => $envVars['LDAP_USER_BASE'] ?? 'ou=people,dc=example,dc=com',
'LDAP_ENABLED' => filter_var($envVars['LDAP_ENABLED'] ?? 'true', FILTER_VALIDATE_BOOLEAN),
'AVATAR_CACHE_DIR' => __DIR__ . '/../uploads/avatars',
'AVATAR_CACHE_TTL' => (int)($envVars['AVATAR_CACHE_TTL'] ?? 3600), // seconds
];
// Set PHP default timezone
date_default_timezone_set($GLOBALS['config']['TIMEZONE']);
// Calculate UTC offset for JavaScript (in minutes, negative for west of UTC)
$now = new DateTime('now', new DateTimeZone($GLOBALS['config']['TIMEZONE']));
$GLOBALS['config']['TIMEZONE_OFFSET'] = $now->getOffset() / 60; // Convert seconds to minutes
$GLOBALS['config']['TIMEZONE_ABBREV'] = $now->format('T'); // e.g., "EST", "EDT"