import { Hono } from 'hono' import type { Context, Next } from 'hono' import { randomUUID } from 'node:crypto' import { registerEnvironment, deregisterEnvironment, reconnectEnvironment, } from '../../services/environment' import { apiKeyAuth, acceptCliHeaders, extractBearerToken, } from '../../auth/middleware' import { validateApiKey } from '../../auth/api-key' import { resolveToken } from '../../auth/token' import { resolveSessionToken } from '../../auth/session' import { getDb } from '../../db/sqlite' const app = new Hono() /** * Combined auth for bridge endpoint: accepts session tokens (rct_*), * in-memory issueToken tokens, or admin API keys. Also falls back to * apiKeyAuth's X-Username header support for CLI bridges. */ async function bridgeAuth(c: Context, next: Next) { const bearer = extractBearerToken(c) if (bearer) { // Admin API key if (validateApiKey(bearer)) { c.set('userId', c.req.header('X-Username') || c.req.query('username')) await next() return } // In-memory token const username = resolveToken(bearer) if (username) { c.set('userId', username) await next() return } // SQLite session token try { const db = getDb() const userId = resolveSessionToken(bearer, db) if (userId) { c.set('userId', userId) await next() return } } catch (err) { console.error('[bridge] DB error during session token resolution:', err) return c.json({ error: 'Database unavailable' }, 503) } } // Fall back to apiKeyAuth (handles X-Username + API key from body/header) return apiKeyAuth(c, next) } /** * Resolve a userId value (UUID or username) to a users.id UUID. * Returns null if no matching user exists. * * bridgeAuth's three token paths return different userId shapes: * - resolveSessionToken (rct_* SQLite token) → users.id (UUID) * - resolveToken (in-memory token) → username (string) * - validateApiKey + X-Username → X-Username header value (arbitrary string) * * Only UUIDs that exist in the users table can become env owners, so a * client-controlled X-Username value can't create an owner for a * non-existent user — resolveUserIdUuid is the safety gate. */ function resolveUserIdUuid( db: ReturnType, value: string, ): string | null { const row = db .query( 'SELECT id FROM users WHERE id = $value OR username = $value LIMIT 1', ) .get({ $value: value }) as { id: string } | null return row?.id ?? null } /** POST /v1/environments/bridge — Register an environment */ app.post('/bridge', acceptCliHeaders, bridgeAuth, async c => { const body = await c.req.json() const userId = c.get('userId') as string | undefined const username = userId const teamId = c.req.header('X-Team-Id') const headerUserId = c.req.header('X-User-Id') // X-User-Id explicitly sets env.owner_user_id (highest priority). // Otherwise, if bridgeAuth resolved a userId from the token (rct_* session // token → UUID, in-memory token → username, API key + X-Username → // username), auto-claim it as the env owner — but only if the value maps // to a real users.id (resolveUserIdUuid gates non-existent usernames). // If both X-User-Id and the resolved userId are absent (or unresolvable), // and X-Team-Id is also absent, the env is "unowned" — a claim_token is // generated and returned so the worker can print a /code/claim/ URL // for the first logged-in user to claim ownership. const db = getDb() const ownerUserId = headerUserId || (userId ? resolveUserIdUuid(db, userId) : null) || null const needsClaim = !ownerUserId && !teamId const claimToken = needsClaim ? `clm_${randomUUID().replace(/-/g, '')}` : null const claimExpiresAt = claimToken ? new Date(Date.now() + 24 * 60 * 60 * 1000) : null // Validate team if X-Team-Id is provided if (teamId && userId) { try { // userId may be a username (API key + X-Username path) or UUID // (session token path). Resolve to UUID for users/team_members lookups. const resolvedUserId = resolveUserIdUuid(db, userId) if (!resolvedUserId) { return c.json( { error: { type: 'forbidden', message: 'User not found', }, }, 403, ) } // Check team exists const team = db .query('SELECT id FROM teams WHERE id = $id AND deleted_at IS NULL') .get({ $id: teamId }) as { id: string } | null if (!team) { return c.json( { error: { type: 'bad_request', message: 'Team not found' } }, 400, ) } // Check membership (admin users are implicit members of all teams) const user = db .query('SELECT role FROM users WHERE id = $id') .get({ $id: resolvedUserId }) as { role: string } | null const isAdmin = user?.role === 'admin' if (!isAdmin) { const membership = db .query( 'SELECT 1 FROM team_members WHERE team_id = $teamId AND user_id = $userId', ) .get({ $teamId: teamId, $userId: resolvedUserId }) if (!membership) { return c.json( { error: { type: 'forbidden', message: 'User is not a member of this team', }, }, 403, ) } } } catch (err) { console.error('[bridge] DB error during team validation:', err) return c.json({ error: 'Database unavailable' }, 503) } } const result = registerEnvironment({ ...body, username, ownerUserId, teamId: teamId || null, claimToken, claimExpiresAt, }) // Link environment to team if X-Team-Id was provided if (teamId) { try { const db = getDb() db.query( 'INSERT OR IGNORE INTO team_environments (team_id, environment_id) VALUES ($teamId, $envId)', ).run({ $teamId: teamId, $envId: result.environment_id }) } catch (err) { console.error('[bridge] DB error during team-environment link:', err) return c.json({ error: 'Database unavailable' }, 503) } } // Append claim info to response so worker can print the claim URL. // Worker reads `claim_url` and prints it to terminal; first logged-in // user to visit claims the env. if (claimToken) { const host = c.req.header('host') || '' const proto = c.req.header('x-forwarded-proto') || 'http' const claimUrl = host ? `${proto}://${host}/code/claim/${claimToken}` : `/code/claim/${claimToken}` return c.json( { ...result, claim_token: claimToken, claim_url: claimUrl }, 200, ) } return c.json(result, 200) }) /** DELETE /v1/environments/bridge/:id — Deregister */ app.delete('/bridge/:id', acceptCliHeaders, apiKeyAuth, async c => { const envId = c.req.param('id')! deregisterEnvironment(envId) return c.json({ status: 'ok' }, 200) }) /** POST /v1/environments/:id/bridge/reconnect — Reconnect */ app.post('/:id/bridge/reconnect', acceptCliHeaders, apiKeyAuth, async c => { const envId = c.req.param('id')! reconnectEnvironment(envId) const { reconnectWorkForEnvironment } = await import( '../../services/work-dispatch' ) await reconnectWorkForEnvironment(envId) return c.json({ status: 'ok' }, 200) }) export default app