import { canModerate, canReadModeration, type UserRole } from '../../auth.js'; export type RoutePermission = | 'public' | 'machine' | 'authenticated' | 'viewer' | 'operator-read' | 'admin-read' | 'moderation-read' | 'moderation' | 'operator' | 'admin'; const within = (route: string, base: string): boolean => route === base || route.startsWith(base + '/'); const readMethod = (method: string): boolean => method === 'GET' || method === 'HEAD'; /** Classify SvelteKit's resolved route ID, never a user-supplied URL pathname. * Route IDs preserve the same policy for page-data requests and encoded URLs. * Machine endpoints enforce their own host/enrollment/telemetry credentials. */ export function routePermission(routeId: string | null, method: string): RoutePermission { method = method.toUpperCase(); const reading = readMethod(method); if (routeId === null) return reading ? 'viewer' : 'operator'; // These cannot occur in this application's resolved route IDs. Fail closed // if a future caller accidentally supplies a raw or noncanonical pathname. if ( !routeId.startsWith('/') || // eslint-disable-next-line no-control-regex -- Reject control characters in resolved route IDs. /[%\\?#\x00-\x1f\x7f]/.test(routeId) || (routeId !== '/' && routeId .split('/') .slice(1) .some((part) => !part || part === '.' || part === '..')) ) return 'admin'; if (within(routeId, '/api/agent')) return 'machine'; if (routeId === '/api/bank/[action]' && method === 'POST') return 'machine'; if (routeId === '/api/telemetry' && method === 'POST') return 'machine'; if (routeId === '/api/moderation/runtime' && method === 'POST') return 'machine'; if (routeId === '/api/leaderboard' && reading) return 'public'; if ((routeId === '/login' || routeId === '/setup') && reading) return 'public'; if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST') return 'public'; if ( routeId === '/account' || ['/api/auth/me', '/api/auth/logout', '/api/auth/password'].includes(routeId) ) return 'authenticated'; // This POST only calculates a preview; it never saves settings or runs cleanup. if (routeId === '/api/settings/retention/preview' && method === 'POST') return 'admin-read'; if (within(routeId, '/api/auth')) return 'admin'; if (within(routeId, '/moderation') || within(routeId, '/api/moderation')) return reading ? 'moderation-read' : 'moderation'; if ( within(routeId, '/api/users') || within(routeId, '/settings') || within(routeId, '/api/settings') || within(routeId, '/api/server-keys') || within(routeId, '/server-keys') || within(routeId, '/keys') ) return reading ? 'admin-read' : 'admin'; if (!reading && ['/api/hosts', '/api/game-files'].some((base) => within(routeId, base))) return 'admin'; if (routeId === '/api/provisioning/[id]/override') return 'admin'; // These endpoints mix operational actions with global policy and schedule // changes. Per-incident/job actions remain available to operators. if (!reading && ['/api/maintenance', '/api/plutonium', '/api/watchdog'].includes(routeId)) return 'admin'; // These reads initiate fleet file scans or spend external API quota. if (routeId === '/api/drift' || routeId === '/api/plugins/github/release') return 'operator'; // Summaries remain visible to viewers; raw configuration, historical file // bytes, and unfiltered diagnostics may contain runtime credentials. if ( routeId === '/configs/[id]' || routeId === '/api/configs/[id]' || routeId === '/api/configs/inspect' || routeId === '/api/campaigns/[id]/file/[fileId]' || routeId === '/api/deployments/[id]/file/[fileId]' || routeId === '/api/betas/[id]/revisions/[revision]/files/[scriptId]' || routeId === '/api/servers/[name]/logs/[file]' || routeId === '/api/servers/[name]/telemetry-requests' || routeId === '/api/servers/[name]/rcon-diagnostics' ) return reading ? 'operator-read' : 'operator'; return reading ? 'viewer' : 'operator'; } /** A browser role never substitutes for a machine credential. */ export function roleAllows(role: UserRole, permission: RoutePermission): boolean { if (!['viewer', 'super_viewer', 'operator', 'moderator', 'admin'].includes(role)) return false; if (permission === 'machine') return false; if (permission === 'public' || permission === 'authenticated' || permission === 'viewer') return true; if (permission === 'operator') return role === 'operator' || role === 'admin'; if (permission === 'moderation') return canModerate(role); if (permission === 'moderation-read') return canReadModeration(role); if (permission === 'operator-read') return role === 'super_viewer' || role === 'operator' || role === 'admin'; if (permission === 'admin-read') return role === 'super_viewer' || role === 'admin'; return permission === 'admin' && role === 'admin'; }