import { Hono } from 'hono' import { z } from 'zod' import { config } from '../config' import { agentClient } from '../services/agent-client' import { headscaleAdapter } from '../services/headscale-adapter' import { headscaleNodeDiscovery } from '../services/headscale-node-discovery' import { platformRegistry } from '../services/platform-registry' import { sessionManager } from '../services/session-manager' import type { AuthUser } from '../types' const sessionSchema = z.object({ platformId: z.string().min(1), user: z.object({ id: z.string().min(1), email: z.string().optional(), name: z.string().optional(), role: z.string().optional(), }), agentOwnerEmail: z.string().email().optional(), ttlSeconds: z.number().int().positive().max(8 * 60 * 60).default(config.defaultSessionTtlSeconds), }) const visualizationSchema = z.object({ stack: z.string().optional(), rosDomainId: z.string().optional(), profile: z.unknown().optional(), }) const accessSyncSchema = z.object({ assignments: z.array(z.object({ platformId: z.string().min(1), platformName: z.string().nullable().optional(), agentHostname: z.string().nullable().optional(), agentOwnerEmail: z.string().nullable().optional(), studentEmails: z.array(z.string().email()).default([]), })).default([]), }) export const lmsRoutes = new Hono() lmsRoutes.use('/lms/*', async (c, next) => { const token = c.req.header('x-lms-gateway-token') ?? '' if (!config.lmsGatewayToken || token !== config.lmsGatewayToken) { return c.json({ error: 'invalid_lms_gateway_token' }, 401) } return next() }) lmsRoutes.post('/lms/platform-sessions', async (c) => { const body = sessionSchema.safeParse(await c.req.json().catch(() => null)) if (!body.success) return c.json({ error: 'invalid_body', issues: body.error.flatten() }, 400) const platform = await platformRegistry.resolvePlatform( body.data.platformId, body.data.agentOwnerEmail ?? body.data.user.email, ) if (!platform) return c.json({ error: 'platform_not_found' }, 404) if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409) const active = sessionManager.getActiveSessionByPlatform(body.data.platformId) if (active) { if (active.userId !== body.data.user.id) return c.json({ error: 'platform_busy', session: active }, 409) return c.json({ session: active, connection: connectionPayload(platform) }) } try { const session = sessionManager.start({ platformId: body.data.platformId, ttlSeconds: body.data.ttlSeconds, user: body.data.user as AuthUser, }) return c.json({ session, connection: connectionPayload(platform) }, 201) } catch (error) { if (!(error instanceof Error)) throw error if (error.message === 'platform_not_found') return c.json({ error: error.message }, 404) if (error.message === 'platform_busy' || error.message === 'platform_unavailable') { return c.json({ error: error.message }, 409) } throw error } }) lmsRoutes.get('/lms/agents', async (c) => { const accountEmail = c.req.query('accountEmail') || undefined return c.json({ agents: await headscaleNodeDiscovery.listTeachingAgents(accountEmail) }) }) lmsRoutes.get('/lms/platforms/:id/status', async (c) => { const accountEmail = c.req.query('accountEmail') || undefined const platform = await platformRegistry.resolvePlatform(c.req.param('id'), accountEmail) if (!platform) return c.json({ error: 'platform_not_found' }, 404) const headscaleOnline = Boolean(platform.agentUrl && platform.state !== 'offline') const agentHealth = headscaleOnline ? await agentClient.health(platform) : { ok: false, message: 'headscale_offline' } return c.json({ platformId: c.req.param('id'), registered: true, headscaleOnline, agentHealthy: Boolean(agentHealth.ok), agentUrl: platform.agentUrl || null, sshUser: platform.sshUser ?? null, workspaceRoot: platform.workspaceRoot ?? null, lastSeenAt: platform.lastSeenAt, state: platform.state, message: agentHealth.message ?? null, }) }) lmsRoutes.post('/lms/access-sync', async (c) => { const body = accessSyncSchema.safeParse(await c.req.json().catch(() => null)) if (!body.success) return c.json({ error: 'invalid_body', issues: body.error.flatten() }, 400) const result = await headscaleAdapter.syncAssignments(body.data.assignments) if (!result.applied) return c.json({ error: 'headscale_policy_apply_failed', result }, 502) const ssh = await syncPlatformSshKeys(body.data.assignments) return c.json({ ...result, ssh }) }) lmsRoutes.post('/lms/platforms/:id/release', async (c) => { const user = await c.req.json<{ user?: AuthUser }>().catch(() => ({} as { user?: AuthUser })) const session = sessionManager.release(c.req.param('id'), user.user ?? { id: 'lms-system', role: 'ADMIN' }) const platform = await platformRegistry.resolvePlatform(c.req.param('id')) if (platform) await agentClient.visualizationStop(platform).catch(() => undefined) return c.json({ session }) }) lmsRoutes.get('/lms/platforms/:id/visualization/status', async (c) => { const platform = await platformRegistry.resolvePlatform(c.req.param('id')) if (!platform) return c.json({ error: 'platform_not_found' }, 404) if (!platform.agentUrl) return c.json({ ok: false, running: false, stack: null, message: 'platform_agent_not_connected' }, 409) return c.json(await agentClient.visualizationStatus(platform)) }) lmsRoutes.get('/lms/platforms/:id/modules/statuses', async (c) => { const accountEmail = c.req.query('accountEmail') || undefined const platform = await platformRegistry.resolvePlatform(c.req.param('id'), accountEmail) if (!platform) return c.json({ error: 'platform_not_found' }, 404) if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409) return c.json(await agentClient.moduleStatuses(platform)) }) lmsRoutes.post('/lms/platforms/:id/modules/start', async (c) => { const accountEmail = c.req.query('accountEmail') || undefined const platform = await platformRegistry.resolvePlatform(c.req.param('id'), accountEmail) if (!platform) return c.json({ error: 'platform_not_found' }, 404) if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409) const body = await c.req.json<{ folderName?: string; rosDomainId?: string }>().catch(() => ({})) return c.json(await agentClient.moduleStart(platform, body)) }) lmsRoutes.post('/lms/platforms/:id/modules/stop', async (c) => { const accountEmail = c.req.query('accountEmail') || undefined const platform = await platformRegistry.resolvePlatform(c.req.param('id'), accountEmail) if (!platform) return c.json({ error: 'platform_not_found' }, 404) if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409) const body = await c.req.json<{ containerKey?: string }>().catch(() => ({})) return c.json(await agentClient.moduleStop(platform, body)) }) lmsRoutes.post('/lms/platforms/:id/visualization/start', async (c) => { const platform = await platformRegistry.resolvePlatform(c.req.param('id')) if (!platform) return c.json({ error: 'platform_not_found' }, 404) if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409) const body = visualizationSchema.safeParse(await c.req.json().catch(() => null)) if (!body.success) return c.json({ error: 'invalid_body', issues: body.error.flatten() }, 400) return c.json(await agentClient.visualizationStart(platform, body.data)) }) lmsRoutes.post('/lms/platforms/:id/visualization/stop', async (c) => { const platform = await platformRegistry.resolvePlatform(c.req.param('id')) if (!platform) return c.json({ error: 'platform_not_found' }, 404) if (!platform.agentUrl) return c.json({ error: 'platform_agent_not_connected' }, 409) const body = visualizationSchema.safeParse(await c.req.json().catch(() => ({}))) return c.json(await agentClient.visualizationStop(platform, body.success ? body.data : {})) }) function connectionPayload(platform: { agentUrl: string; sshUser?: string | null; workspaceRoot?: string | null }) { const agentUrl = platform.agentUrl.replace(/\/+$/, '') const bridgeHttpUrl = (() => { try { const url = new URL(agentUrl) url.port = '8765' return url.toString().replace(/\/+$/, '') } catch { return '' } })() return { agentUrl, sshUser: platform.sshUser ?? null, workspaceRoot: platform.workspaceRoot ?? null, bridgeHttpUrl, bridgeWsUrl: bridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:'), } } async function syncPlatformSshKeys(assignments: Array<{ platformId: string agentOwnerEmail?: string | null studentEmails: string[] }>) { const emails = Array.from(new Set(assignments.flatMap((assignment) => assignment.studentEmails))) const publicKeysByEmail = await fetchLauncherSshKeys(emails).catch(() => ({} as Record>)) const results: Array<{ platformId: string; applied: boolean; keyCount: number; message?: string }> = [] for (const assignment of assignments) { const platform = await platformRegistry.resolvePlatform( assignment.platformId, assignment.agentOwnerEmail ?? undefined, ) const publicKeys = assignment.studentEmails.flatMap((email) => (publicKeysByEmail[email.toLowerCase()] ?? []).map((key) => key.publicKey), ) if (!platform?.agentUrl) { results.push({ platformId: assignment.platformId, applied: false, keyCount: publicKeys.length, message: 'platform_agent_not_connected' }) continue } try { await agentClient.syncAuthorizedKeys(platform, publicKeys, platform.sshUser) results.push({ platformId: assignment.platformId, applied: true, keyCount: publicKeys.length }) } catch (error) { results.push({ platformId: assignment.platformId, applied: false, keyCount: publicKeys.length, message: error instanceof Error ? error.message : String(error), }) } } return results } async function fetchLauncherSshKeys(emails: string[]): Promise>> { if (!emails.length) return {} if (!config.launcherGatewayToken) return {} const response = await fetch(`${config.launcherServerUrl}/api/provision/ssh-keys/lookup`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-lms-gateway-token': config.launcherGatewayToken, }, body: JSON.stringify({ emails }), }) if (!response.ok) throw new Error(`launcher_ssh_key_lookup_failed:${response.status}`) const payload = await response.json().catch(() => ({})) as { keysByEmail?: Record> } return payload.keysByEmail ?? {} }