Files
platform-gateway/src/routes/lms.ts
T

253 lines
11 KiB
TypeScript

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<string, Array<{ publicKey: string }>>))
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<Record<string, Array<{ publicKey: string }>>> {
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<string, Array<{ publicKey: string }>> }
return payload.keysByEmail ?? {}
}