diff --git a/.env.example b/.env.example index 533e4d789..e6391e114 100644 --- a/.env.example +++ b/.env.example @@ -203,6 +203,15 @@ GITHUB_USER_BLACKLIST= # remove or carefully maintain this list. Keep an entry only if you want a # username-based break-glass administrator. PROPR_ADMIN_USERS= +# Hosted fleet activation gate. The internal hosted routes are registered only +# when the fleet secret is at least 32 characters. Leave all three unset outside +# operator-managed hosted instances. The secret is distinct from session, relay, +# and tunnel credentials and authorizes only the hosted bootstrap and health +# endpoints. +# Generate with: openssl rand -hex 32 +PROPR_FLEET_CONTROL_SECRET= +PROPR_HOSTED_INITIAL_ADMIN_GITHUB_USER_ID= +PROPR_HOSTED_INITIAL_ADMIN_GITHUB_LOGIN= PR_FOLLOWUP_TRIGGER_KEYWORDS=!propr # With a whitelist set, polling resolves who applied the trigger label from the # issue timeline (page 1 + the most recent N pages). Raise this if long-lived diff --git a/packages/api/routes/hostedFleetRoutes.ts b/packages/api/routes/hostedFleetRoutes.ts new file mode 100644 index 000000000..da6b74fe9 --- /dev/null +++ b/packages/api/routes/hostedFleetRoutes.ts @@ -0,0 +1,217 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; +import type { Application, Request, Response } from 'express'; +import type { Knex } from 'knex'; +import { db } from '@propr/core'; +import { getBootstrapAdminUsernames } from '../authorization.js'; + +interface HostedFleetRoutesDeps { + database?: Knex; + fleetSecret?: string; + initialAdminGithubUserId?: string; + initialAdminGithubLogin?: string; + githubUserWhitelist?: string; + bootstrapAdminUsernames?: readonly string[]; + operationalStatus?: () => unknown | Promise; + queueStatus?: () => unknown | Promise; +} + +interface FleetOperationalStatus { + githubAuthMode: string; + githubAuth: string; + githubEventIntake: string; + githubEventIntakeStatus: string; +} + +interface FleetQueueStatus { + waiting: number; + active: number; +} + +const MAX_GITHUB_USER_ID_DIGITS = 20; +const GITHUB_AUTH_MODES = new Set(['app', 'relay', 'demo', 'none', 'unknown']); +const GITHUB_AUTH_STATUSES = new Set(['connected', 'disconnected']); +const GITHUB_EVENT_INTAKE_MODES = new Set(['routing_websocket', 'polling', 'direct_webhook', 'unknown']); +const GITHUB_EVENT_INTAKE_STATUSES = new Set(['connected', 'disconnected', 'active', 'unknown']); + +export function isHostedFleetControlEnabled( + fleetSecret: string | undefined = process.env.PROPR_FLEET_CONTROL_SECRET +): fleetSecret is string { + return Boolean(fleetSecret && fleetSecret.length >= 32); +} + +function safeEqual(left: string, right: string): boolean { + const leftDigest = createHash('sha256').update(left).digest(); + const rightDigest = createHash('sha256').update(right).digest(); + return timingSafeEqual(leftDigest, rightDigest); +} + +function canonicalizeGithubUserId(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed || trimmed.length > MAX_GITHUB_USER_ID_DIGITS || !/^\d+$/.test(trimmed)) return undefined; + const canonical = trimmed.replace(/^0+(?=\d)/, ''); + return canonical === '0' ? undefined : canonical; +} + +function normalizeUsernames(usernames: readonly string[]): Set { + return new Set(usernames.map(username => username.trim().toLowerCase()).filter(Boolean)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseOperationalStatus(value: unknown): FleetOperationalStatus | undefined { + if (!isRecord(value)) return undefined; + const { githubAuthMode, githubAuth, githubEventIntake, githubEventIntakeStatus } = value; + if ( + typeof githubAuthMode !== 'string' + || !GITHUB_AUTH_MODES.has(githubAuthMode) + || typeof githubAuth !== 'string' + || !GITHUB_AUTH_STATUSES.has(githubAuth) + || typeof githubEventIntake !== 'string' + || !GITHUB_EVENT_INTAKE_MODES.has(githubEventIntake) + || typeof githubEventIntakeStatus !== 'string' + || !GITHUB_EVENT_INTAKE_STATUSES.has(githubEventIntakeStatus) + ) { + return undefined; + } + return { githubAuthMode, githubAuth, githubEventIntake, githubEventIntakeStatus }; +} + +function parseQueueStatus(value: unknown): FleetQueueStatus | undefined { + if (!isRecord(value)) return undefined; + const { waiting, active } = value; + if ( + typeof waiting !== 'number' + || !Number.isSafeInteger(waiting) + || waiting < 0 + || typeof active !== 'number' + || !Number.isSafeInteger(active) + || active < 0 + ) { + return undefined; + } + return { waiting, active }; +} + +export function createHostedFleetRoutes({ + database = db, + fleetSecret = process.env.PROPR_FLEET_CONTROL_SECRET, + initialAdminGithubUserId = process.env.PROPR_HOSTED_INITIAL_ADMIN_GITHUB_USER_ID, + initialAdminGithubLogin = process.env.PROPR_HOSTED_INITIAL_ADMIN_GITHUB_LOGIN, + githubUserWhitelist = process.env.GITHUB_USER_WHITELIST, + bootstrapAdminUsernames = getBootstrapAdminUsernames(), + operationalStatus, + queueStatus, +}: HostedFleetRoutesDeps = {}) { + const canonicalInitialAdminGithubUserId = canonicalizeGithubUserId(initialAdminGithubUserId); + const normalizedLogin = initialAdminGithubLogin?.trim().toLowerCase() ?? ''; + const normalizedBootstrapAdmins = normalizeUsernames(bootstrapAdminUsernames); + const normalizedWhitelist = normalizeUsernames((githubUserWhitelist ?? '').split(',')); + + function isAuthorized(req: Request): boolean { + const supplied = req.get('x-propr-fleet-secret') ?? ''; + return isHostedFleetControlEnabled(fleetSecret) && safeEqual(supplied, fleetSecret); + } + + async function getBootstrapStatus(req: Request, res: Response): Promise { + res.setHeader('Cache-Control', 'no-store'); + if (!isAuthorized(req)) { + res.status(401).json({ error: 'Fleet authentication required' }); + return; + } + if (!canonicalInitialAdminGithubUserId) { + res.status(409).json({ error: 'Hosted initial administrator is not configured' }); + return; + } + + let durableAdminVerified: boolean; + try { + const durableAdmin = await database('instance_members') + .select('github_user_id') + .where({ github_user_id: canonicalInitialAdminGithubUserId, role: 'admin' }) + .first(); + durableAdminVerified = Boolean(durableAdmin); + } catch (error) { + console.error('Failed to collect hosted Fleet bootstrap status:', error); + res.status(503).json({ error: 'Bootstrap status is unavailable' }); + return; + } + const environmentBootstrapActive = normalizedLogin.length > 0 + && normalizedBootstrapAdmins.has(normalizedLogin); + + res.json({ + initialAdminGithubUserId: canonicalInitialAdminGithubUserId, + durableAdminVerified, + environmentBootstrapActive, + bootstrapOnlyInitialOwner: normalizedLogin.length > 0 + && normalizedBootstrapAdmins.size === 1 + && normalizedBootstrapAdmins.has(normalizedLogin), + whitelistOnlyInitialOwner: normalizedLogin.length > 0 + && normalizedWhitelist.size === 1 + && normalizedWhitelist.has(normalizedLogin), + }); + } + + async function getOperationalStatus(req: Request, res: Response): Promise { + res.setHeader('Cache-Control', 'no-store'); + if (!isAuthorized(req)) { + res.status(401).json({ error: 'Fleet authentication required' }); + return; + } + if (!operationalStatus) { + res.status(503).json({ error: 'Operational status is unavailable' }); + return; + } + try { + const status = parseOperationalStatus(await operationalStatus()); + if (!status) { + res.status(503).json({ error: 'Operational status is unavailable' }); + return; + } + res.json(status); + } catch (error) { + console.error('Failed to collect hosted Fleet operational status:', error); + res.status(503).json({ error: 'Operational status is unavailable' }); + } + } + + async function getQueueStatus(req: Request, res: Response): Promise { + res.setHeader('Cache-Control', 'no-store'); + if (!isAuthorized(req)) { + res.status(401).json({ error: 'Fleet authentication required' }); + return; + } + if (!queueStatus) { + res.status(503).json({ error: 'Queue status is unavailable' }); + return; + } + try { + const status = parseQueueStatus(await queueStatus()); + if (!status) { + res.status(503).json({ error: 'Queue status is unavailable' }); + return; + } + res.json(status); + } catch (error) { + console.error('Failed to collect hosted Fleet queue status:', error); + res.status(503).json({ error: 'Queue status is unavailable' }); + } + } + + return { getBootstrapStatus, getOperationalStatus, getQueueStatus }; +} + +export function registerHostedFleetRoutes( + app: Pick, + deps: HostedFleetRoutesDeps = {} +): boolean { + const fleetSecret = deps.fleetSecret ?? process.env.PROPR_FLEET_CONTROL_SECRET; + if (!isHostedFleetControlEnabled(fleetSecret)) return false; + + const routes = createHostedFleetRoutes({ ...deps, fleetSecret }); + app.get('/api/internal/hosted/bootstrap', routes.getBootstrapStatus); + app.get('/api/internal/hosted/status', routes.getOperationalStatus); + app.get('/api/internal/hosted/queue', routes.getQueueStatus); + return true; +} diff --git a/packages/api/routes/index.ts b/packages/api/routes/index.ts index e285e82c7..5371db4d4 100644 --- a/packages/api/routes/index.ts +++ b/packages/api/routes/index.ts @@ -24,3 +24,8 @@ export { createRepoTodoRoutes } from './repoTodoRoutes.js'; export { createUserRepoPreferencesRoutes } from './userRepoPreferencesRoutes.js'; export { createAgentRuntimeRoutes } from './agentRuntimeRoutes.js'; export { createAdminRoutes } from './adminRoutes.js'; +export { + createHostedFleetRoutes, + isHostedFleetControlEnabled, + registerHostedFleetRoutes +} from './hostedFleetRoutes.js'; diff --git a/packages/api/routes/queueRoutes.ts b/packages/api/routes/queueRoutes.ts index eda1bc6ce..62ff8fa92 100644 --- a/packages/api/routes/queueRoutes.ts +++ b/packages/api/routes/queueRoutes.ts @@ -10,16 +10,20 @@ interface QueueRoutesDeps { export function createQueueRoutes(deps: QueueRoutesDeps) { const { redisClient, taskQueue } = deps; + async function collectQueueStats(): Promise> { + const [waiting, active, completed, failed, delayed] = await Promise.all([ + taskQueue.getWaitingCount(), + taskQueue.getActiveCount(), + taskQueue.getCompletedCount(), + taskQueue.getFailedCount(), + taskQueue.getDelayedCount() + ]); + return { waiting, active, completed, failed, delayed, total: waiting + active + completed + failed + delayed }; + } + async function getQueueStats(_req: Request, res: Response): Promise { try { - const [waiting, active, completed, failed, delayed] = await Promise.all([ - taskQueue.getWaitingCount(), - taskQueue.getActiveCount(), - taskQueue.getCompletedCount(), - taskQueue.getFailedCount(), - taskQueue.getDelayedCount() - ]); - res.json({ waiting, active, completed, failed, delayed, total: waiting + active + completed + failed + delayed }); + res.json(await collectQueueStats()); } catch (error) { console.error('Error in /api/queue/stats:', error); res.status(500).json({ error: 'Internal server error' }); @@ -65,7 +69,7 @@ export function createQueueRoutes(deps: QueueRoutesDeps) { } } - return { getQueueStats, getActivity, getMetrics }; + return { collectQueueStats, getQueueStats, getActivity, getMetrics }; } function parseActivityLog(activity: string, index: number): Record { diff --git a/packages/api/routes/statusRoutes.ts b/packages/api/routes/statusRoutes.ts index 8afd7cf1b..b36c528d0 100644 --- a/packages/api/routes/statusRoutes.ts +++ b/packages/api/routes/statusRoutes.ts @@ -65,120 +65,123 @@ export function createStatusRoutes(deps: StatusRoutesDeps) { res.json(getProprCompatibilityMetadata()); } - async function getStatus(req: Request, res: Response): Promise { - try { - const compatibility = getProprCompatibilityMetadata(); - // In demo mode, return all-green status - if (isDemoMode()) { - res.json({ - ...compatibility, - api: 'healthy', - redis: 'connected', - daemon: 'running', - worker: 'running', - workerCount: 3, - githubAuth: 'connected', - githubAuthMode: 'demo', - githubEventIntake: resolveIntakeMode(), - githubEventIntakeStatus: 'connected', - claudeAuth: 'connected', - indexing: 'idle', - warnings: [], - agents: [{ - id: 'default-claude-agent', - type: 'claude', - alias: 'default', - status: 'connected' - }], - timestamp: new Date().toISOString() - }); - return; - } - - const status: Record = { + async function collectStatus(): Promise> { + const compatibility = getProprCompatibilityMetadata(); + // In demo mode, return all-green status + if (isDemoMode()) { + return { ...compatibility, api: 'healthy', - redis: 'unknown', - daemon: 'unknown', - worker: 'unknown', - githubAuth: 'unknown', - claudeAuth: 'unknown', - indexing: 'unknown', + redis: 'connected', + daemon: 'running', + worker: 'running', + workerCount: 3, + githubAuth: 'connected', + githubAuthMode: 'demo', + githubEventIntake: resolveIntakeMode(), + githubEventIntakeStatus: 'connected', + claudeAuth: 'connected', + indexing: 'idle', warnings: [], - agents: [], + agents: [{ + id: 'default-claude-agent', + type: 'claude', + alias: 'default', + status: 'connected' + }], timestamp: new Date().toISOString() }; + } - try { - await redisClient.ping(); - status.redis = 'connected'; + const status: Record = { + ...compatibility, + api: 'healthy', + redis: 'unknown', + daemon: 'unknown', + worker: 'unknown', + githubAuth: 'unknown', + claudeAuth: 'unknown', + indexing: 'unknown', + warnings: [], + agents: [], + timestamp: new Date().toISOString() + }; - const daemonHeartbeat = await redisClient.get('system:status:daemon'); - status.daemon = (daemonHeartbeat && Date.now() - parseInt(daemonHeartbeat) < 120000) ? 'running' : 'stopped'; + try { + await redisClient.ping(); + status.redis = 'connected'; - const activeWorkers = await redisClient.sCard('system:status:workers'); - status.worker = activeWorkers > 0 ? 'running' : 'stopped'; - status.workerCount = activeWorkers; - } catch { - status.redis = 'disconnected'; - } + const daemonHeartbeat = await redisClient.get('system:status:daemon'); + status.daemon = (daemonHeartbeat && Date.now() - parseInt(daemonHeartbeat) < 120000) ? 'running' : 'stopped'; - // Auth mode (how ProPR authenticates to GitHub) and event intake mode (how - // GitHub events arrive) are independent — surface both so operators can tell - // a relay-auth + routing-websocket deployment apart from an app + webhook one. - const authMode = resolveAuthMode(); - status.githubAuthMode = authMode; - // The coarse githubAuth health is derived from the resolved auth mode rather - // than GH_APP_* alone, so a valid relay-auth deployment reports 'connected' - // instead of a misleading 'disconnected'. Only 'none' (nothing configured) - // and 'unknown' (resolver error) report as disconnected. - status.githubAuth = (authMode === 'app' || authMode === 'relay' || authMode === 'demo') - ? 'connected' - : 'disconnected'; - const intakeMode = resolveIntakeMode(); - status.githubEventIntake = intakeMode; - - // Routing WebSocket runtime state, published to Redis by the daemon when the - // default routing_websocket intake path is active. Included only when present - // so non-routing deployments don't carry an empty field. - const routing = await getRoutingState(redisClient); - if (routing) { - status.routing = routing; - } + const activeWorkers = await redisClient.sCard('system:status:workers'); + status.worker = activeWorkers > 0 ? 'running' : 'stopped'; + status.workerCount = activeWorkers; + } catch { + status.redis = 'disconnected'; + } + + // Auth mode (how ProPR authenticates to GitHub) and event intake mode (how + // GitHub events arrive) are independent — surface both so operators can tell + // a relay-auth + routing-websocket deployment apart from an app + webhook one. + const authMode = resolveAuthMode(); + status.githubAuthMode = authMode; + // The coarse githubAuth health is derived from the resolved auth mode rather + // than GH_APP_* alone, so a valid relay-auth deployment reports 'connected' + // instead of a misleading 'disconnected'. Only 'none' (nothing configured) + // and 'unknown' (resolver error) report as disconnected. + status.githubAuth = (authMode === 'app' || authMode === 'relay' || authMode === 'demo') + ? 'connected' + : 'disconnected'; + const intakeMode = resolveIntakeMode(); + status.githubEventIntake = intakeMode; + + // Routing WebSocket runtime state, published to Redis by the daemon when the + // default routing_websocket intake path is active. Included only when present + // so non-routing deployments don't carry an empty field. + const routing = await getRoutingState(redisClient); + if (routing) { + status.routing = routing; + } - // The intake status is a stable, mode-aware health signal for the active - // GitHub event delivery path so operators can tell a healthy intake from a - // stalled one independent of the intake method name. - status.githubEventIntakeStatus = resolveIntakeStatus(intakeMode, routing, status.daemon); - - const agents = await getCachedAgentStatuses(); - status.agents = agents; - status.claudeAuth = agents.some(agent => agent.type === 'claude' && agent.status === 'connected') - ? 'connected' - : 'disconnected'; - status.indexing = await getIndexingStatus(getIndexingQueue); - const warnings = await getSystemWarnings(loadSummarizationRuntimeStateDep); - const agentRuntime = agentRegistry.getOperationalStatus?.(); - if (agentRuntime) { - status.agentRuntime = agentRuntime; - const image = agentRuntime.unifiedAgentImage; - if (image.status === 'unavailable') { - warnings.push({ - type: 'agent_runtime_unified_image_unavailable', - message: `Unified agent image is unavailable${image.imageTag ? ` (${image.imageTag})` : ''}: ${image.error || 'unknown error'}` - }); - } + // The intake status is a stable, mode-aware health signal for the active + // GitHub event delivery path so operators can tell a healthy intake from a + // stalled one independent of the intake method name. + status.githubEventIntakeStatus = resolveIntakeStatus(intakeMode, routing, status.daemon); + + const agents = await getCachedAgentStatuses(); + status.agents = agents; + status.claudeAuth = agents.some(agent => agent.type === 'claude' && agent.status === 'connected') + ? 'connected' + : 'disconnected'; + status.indexing = await getIndexingStatus(getIndexingQueue); + const warnings = await getSystemWarnings(loadSummarizationRuntimeStateDep); + const agentRuntime = agentRegistry.getOperationalStatus?.(); + if (agentRuntime) { + status.agentRuntime = agentRuntime; + const image = agentRuntime.unifiedAgentImage; + if (image.status === 'unavailable') { + warnings.push({ + type: 'agent_runtime_unified_image_unavailable', + message: `Unified agent image is unavailable${image.imageTag ? ` (${image.imageTag})` : ''}: ${image.error || 'unknown error'}` + }); } - status.warnings = warnings; + } + status.warnings = warnings; + + return status; + } - res.json(status); + async function getStatus(_req: Request, res: Response): Promise { + try { + res.json(await collectStatus()); } catch (error) { console.error('Error in /api/status:', error); res.status(500).json({ error: 'Internal server error' }); } } - return { getCompatibility, getStatus }; + return { collectStatus, getCompatibility, getStatus }; async function getCachedAgentStatuses(): Promise { const currentTime = now(); diff --git a/packages/api/server.ts b/packages/api/server.ts index 0463b6c64..d7fe377a6 100644 --- a/packages/api/server.ts +++ b/packages/api/server.ts @@ -29,7 +29,8 @@ import { createAgentRuntimeRoutes, createAdminRoutes, createInstanceCatalogRoutes, - attachmentUpload + attachmentUpload, + registerHostedFleetRoutes } from './routes/index.js'; import { agentLoginSessionManager } from './services/agentLoginSessionManager.js'; import { checkAndExecuteDelayedReindex } from './routes/indexingQueueHelpers.js'; @@ -215,6 +216,7 @@ async function initRedis(): Promise { function setupRoutes(): void { const statusRoutes = createStatusRoutes({ redisClient }); + const queueRoutes = createQueueRoutes({ redisClient, taskQueue }); // INTENTIONALLY UNAUTHENTICATED: /api/compatibility is registered BEFORE the // `ensureAuthenticated` guard below so the hosted UI can run its pre-auth // version-gate before the user logs in. This is the one deliberate exception to @@ -223,13 +225,20 @@ function setupRoutes(): void { // compatibility dates). All other /api routes registered after this line are // authenticated. app.get('/api/compatibility', statusRoutes.getCompatibility); + // Machine-to-machine bootstrap verification has its own narrow service + // credential and deliberately does not depend on a customer's OAuth session. + // Registration remains before the OAuth boundary and is a no-op unless Fleet + // control was explicitly enabled at startup. + registerHostedFleetRoutes(app, { + operationalStatus: statusRoutes.collectStatus, + queueStatus: queueRoutes.collectQueueStats + }); app.use('/api', ensureAuthenticated, resolveAuthorization); const taskRoutes = createTaskRoutes({ db, taskQueue }); const taskHistoryRoutes = createTaskHistoryRoutes({ redisClient, taskQueue, db }); const liveDetailsRoutes = createLiveDetailsRoutes({ redisClient, db }); const fileChangesRoutes = createFileChangesRoutes({ db }); const configRoutes = createConfigRoutes({ redisClient }); - const queueRoutes = createQueueRoutes({ redisClient, taskQueue }); const executionRoutes = createExecutionRoutes({ redisClient, db }); const dockerRoutes = createDockerRoutes({ redisClient }); const githubRoutes = createGitHubRoutes({ redisClient, taskQueue, db }); diff --git a/packages/api/test/hostedFleetRoutes.test.ts b/packages/api/test/hostedFleetRoutes.test.ts new file mode 100644 index 000000000..62f35aefe --- /dev/null +++ b/packages/api/test/hostedFleetRoutes.test.ts @@ -0,0 +1,422 @@ +import assert from 'node:assert/strict'; +import type { AddressInfo } from 'node:net'; +import { after, afterEach, beforeEach, describe, test } from 'node:test'; +import express from 'express'; +import type { Request, Response } from 'express'; +import knex, { type Knex } from 'knex'; +import { up as createInstanceMemberTables } from '../../core/src/db/migrations/20260730000000_create_instance_members.js'; +import { ensureAuthenticated } from '../auth.js'; +import { resolveAuthorization } from '../authorization.js'; +import { createQueueRoutes } from '../routes/queueRoutes.js'; +import { createStatusRoutes } from '../routes/statusRoutes.js'; +import { + createHostedFleetRoutes, + isHostedFleetControlEnabled, + registerHostedFleetRoutes, +} from '../routes/hostedFleetRoutes.js'; + +const fleetSecret = 'fleet-control-secret-with-at-least-32-bytes'; +type HostedFleetRoutesDeps = NonNullable[0]>; +let database: Knex; + +beforeEach(async () => { + database = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true + }); + await createInstanceMemberTables(database); +}); + +afterEach(async () => { + await database.destroy(); +}); + +after(async () => { + const { closeConnection, shutdownQueue } = await import('@propr/core'); + await closeConnection(); + await shutdownQueue(); +}); + +function fleetRequest(secret?: string): Request { + return { + get(name: string) { + return name.toLowerCase() === 'x-propr-fleet-secret' ? secret : undefined; + } + } as Request; +} + +function recorder() { + const record: { status: number; body?: unknown; headers: Record } = { + status: 200, + headers: {} + }; + const response = { + status(code: number) { record.status = code; return response; }, + json(body: unknown) { record.body = body; return response; }, + setHeader(name: string, value: string) { record.headers[name.toLowerCase()] = value; return response; } + } as unknown as Response; + return { response, record }; +} + +function routes(overrides: HostedFleetRoutesDeps = {}) { + return createHostedFleetRoutes({ + database, + fleetSecret, + initialAdminGithubUserId: '100', + initialAdminGithubLogin: 'owner', + githubUserWhitelist: 'owner', + bootstrapAdminUsernames: ['owner'], + operationalStatus: () => ({ + githubAuthMode: 'relay', + githubAuth: 'connected', + githubEventIntake: 'routing_websocket', + githubEventIntakeStatus: 'connected', + redis: 'connected', + routing: { routingUrl: 'wss://internal.example.test' }, + }), + queueStatus: () => ({ waiting: 2, active: 1, completed: 20, failed: 3, delayed: 4, total: 30 }), + ...overrides, + }); +} + +async function fetchFromApp( + app: express.Express, + path: string, + init?: RequestInit +): Promise { + const server = app.listen(0, '127.0.0.1'); + await new Promise(resolve => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + try { + return await fetch(`http://127.0.0.1:${port}${path}`, init); + } finally { + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + }); + } +} + +function wiredApp(secret: string) { + const app = express(); + const statusRoutes = createStatusRoutes({ + redisClient: { + ping: async () => 'PONG', + get: async (key: string) => key === 'system:status:routing' ? null : Date.now().toString(), + sCard: async () => 1, + } as never, + loadAgents: async () => [], + agentRegistry: { + ensureInitialized: async () => undefined, + getAllAgents: () => [], + getAgentById: () => undefined, + getAgentByAlias: () => undefined, + createAgentFromConfig: () => { throw new Error('not used'); }, + } as never, + getIndexingQueue: async () => ({ getJobCounts: async () => ({}) }), + loadSummarizationRuntimeState: async () => ({ + primary_quota_failures: 0, + primary_quota_failures_by_alias: {}, + cooldowns: {}, + }), + }); + const queueRoutes = createQueueRoutes({ + redisClient: {} as never, + taskQueue: { + getWaitingCount: async () => 2, + getActiveCount: async () => 1, + getCompletedCount: async () => 20, + getFailedCount: async () => 3, + getDelayedCount: async () => 4, + } as never, + }); + app.use((req, _res, next) => { + req.isAuthenticated = (() => false) as Request['isAuthenticated']; + next(); + }); + const registered = registerHostedFleetRoutes(app, { + database, + fleetSecret: secret, + initialAdminGithubUserId: '100', + initialAdminGithubLogin: 'owner', + githubUserWhitelist: 'owner', + bootstrapAdminUsernames: ['owner'], + operationalStatus: statusRoutes.collectStatus, + queueStatus: queueRoutes.collectQueueStats, + }); + app.use('/api', ensureAuthenticated, resolveAuthorization); + return { app, registered }; +} + +describe('hosted fleet bootstrap status', () => { + test('requires a sufficiently long fleet control secret before routes are enabled', () => { + assert.equal(isHostedFleetControlEnabled(''), false); + assert.equal(isHostedFleetControlEnabled('x'.repeat(31)), false); + assert.equal(isHostedFleetControlEnabled('x'.repeat(32)), true); + }); + + test('rejects missing, short, and same-length incorrect service credentials', async () => { + for (const supplied of [undefined, 'wrong-secret', 'x'.repeat(fleetSecret.length)]) { + const { response, record } = recorder(); + await routes().getBootstrapStatus(fleetRequest(supplied), response); + assert.equal(record.status, 401); + assert.deepEqual(record.body, { error: 'Fleet authentication required' }); + assert.equal(record.headers['cache-control'], 'no-store'); + } + + const queue = recorder(); + await routes().getQueueStatus(fleetRequest('wrong-secret'), queue.response); + assert.equal(queue.record.status, 401); + assert.deepEqual(queue.record.body, { error: 'Fleet authentication required' }); + }); + + test('rejects missing, non-positive, and unreasonably large initial administrator IDs', async () => { + for (const initialAdminGithubUserId of [ + '', + 'not-a-github-id', + '0', + '000', + '-1', + '1.5', + '1'.repeat(21), + ]) { + const { response, record } = recorder(); + await routes({ initialAdminGithubUserId }).getBootstrapStatus(fleetRequest(fleetSecret), response); + assert.equal(record.status, 409); + assert.deepEqual(record.body, { error: 'Hosted initial administrator is not configured' }); + } + }); + + test('reports a pending durable claim without exposing the login', async () => { + const { response, record } = recorder(); + await routes().getBootstrapStatus(fleetRequest(fleetSecret), response); + + assert.equal(record.status, 200); + assert.deepEqual(record.body, { + initialAdminGithubUserId: '100', + durableAdminVerified: false, + environmentBootstrapActive: true, + bootstrapOnlyInitialOwner: true, + whitelistOnlyInitialOwner: true + }); + assert.equal(record.headers['cache-control'], 'no-store'); + assert.equal(JSON.stringify(record.body).includes('owner'), false); + }); + + test('returns a stable 503 response when the durable administrator lookup fails', async () => { + const failingDatabase = (() => { + const query = { + select: () => query, + where: () => query, + first: async () => { throw new Error('sensitive database failure'); }, + }; + return query; + }) as unknown as Knex; + const originalConsoleError = console.error; + console.error = () => undefined; + try { + const { response, record } = recorder(); + await routes({ database: failingDatabase }).getBootstrapStatus(fleetRequest(fleetSecret), response); + assert.equal(record.status, 503); + assert.deepEqual(record.body, { error: 'Bootstrap status is unavailable' }); + assert.equal(record.headers['cache-control'], 'no-store'); + } finally { + console.error = originalConsoleError; + } + }); + + test('canonicalizes the configured GitHub ID before durable administrator lookup', async () => { + await database('instance_members').insert({ + github_user_id: '100', + github_username: 'renamed-owner', + role: 'admin', + source: 'local' + }); + const { response, record } = recorder(); + await routes({ + initialAdminGithubUserId: ' 00100 ', + bootstrapAdminUsernames: [], + }).getBootstrapStatus(fleetRequest(fleetSecret), response); + + assert.equal((record.body as Record).initialAdminGithubUserId, '100'); + assert.equal((record.body as Record).durableAdminVerified, true); + assert.equal((record.body as Record).environmentBootstrapActive, false); + }); + + test('preserves valid GitHub IDs larger than the JavaScript safe-integer range', async () => { + const initialAdminGithubUserId = '9007199254740993'; + const { response, record } = recorder(); + await routes({ initialAdminGithubUserId }).getBootstrapStatus(fleetRequest(fleetSecret), response); + + assert.equal((record.body as Record).initialAdminGithubUserId, initialAdminGithubUserId); + assert.equal((record.body as Record).durableAdminVerified, false); + }); + + test('distinguishes removable owner-only bootstrap state from additional administrators', async () => { + const duplicates = recorder(); + await routes({ + bootstrapAdminUsernames: [' owner ', 'OWNER'], + githubUserWhitelist: 'Owner, OWNER', + }).getBootstrapStatus(fleetRequest(fleetSecret), duplicates.response); + assert.equal((duplicates.record.body as Record).bootstrapOnlyInitialOwner, true); + assert.equal((duplicates.record.body as Record).whitelistOnlyInitialOwner, true); + + const additionalAdmins = recorder(); + await routes({ + bootstrapAdminUsernames: ['owner', 'break-glass-admin'], + githubUserWhitelist: 'owner, break-glass-admin', + }).getBootstrapStatus(fleetRequest(fleetSecret), additionalAdmins.response); + assert.equal((additionalAdmins.record.body as Record).environmentBootstrapActive, true); + assert.equal((additionalAdmins.record.body as Record).bootstrapOnlyInitialOwner, false); + assert.equal((additionalAdmins.record.body as Record).whitelistOnlyInitialOwner, false); + }); + + test('does not accept a different administrator as the initial claim', async () => { + await database('instance_members').insert({ + github_user_id: '200', + github_username: 'another-admin', + role: 'admin', + source: 'local' + }); + const { response, record } = recorder(); + await routes().getBootstrapStatus(fleetRequest(fleetSecret), response); + assert.equal((record.body as Record).durableAdminVerified, false); + }); +}); + +describe('hosted fleet health status', () => { + test('allowlists operational and queue response fields', async () => { + const operational = recorder(); + await routes().getOperationalStatus(fleetRequest(fleetSecret), operational.response); + assert.deepEqual(operational.record.body, { + githubAuthMode: 'relay', + githubAuth: 'connected', + githubEventIntake: 'routing_websocket', + githubEventIntakeStatus: 'connected' + }); + assert.equal(operational.record.headers['cache-control'], 'no-store'); + + const queue = recorder(); + await routes().getQueueStatus(fleetRequest(fleetSecret), queue.response); + assert.deepEqual(queue.record.body, { waiting: 2, active: 1 }); + assert.equal(queue.record.headers['cache-control'], 'no-store'); + }); + + test('returns 503 when status collectors are unavailable', async () => { + const operational = recorder(); + await routes({ operationalStatus: undefined }).getOperationalStatus( + fleetRequest(fleetSecret), + operational.response + ); + assert.equal(operational.record.status, 503); + assert.deepEqual(operational.record.body, { error: 'Operational status is unavailable' }); + + const queue = recorder(); + await routes({ queueStatus: undefined }).getQueueStatus(fleetRequest(fleetSecret), queue.response); + assert.equal(queue.record.status, 503); + assert.deepEqual(queue.record.body, { error: 'Queue status is unavailable' }); + }); + + test('rejects malformed and unbounded operational status values', async () => { + const valid = { + githubAuthMode: 'relay', + githubAuth: 'connected', + githubEventIntake: 'routing_websocket', + githubEventIntakeStatus: 'connected', + }; + const malformedValues = [ + { ...valid, githubAuthMode: 42 }, + { ...valid, githubAuth: null }, + { ...valid, githubEventIntake: ['routing_websocket'] }, + { ...valid, githubEventIntakeStatus: false }, + { ...valid, githubEventIntakeStatus: 'x'.repeat(256) }, + ]; + + for (const value of malformedValues) { + const result = recorder(); + await routes({ operationalStatus: () => value }).getOperationalStatus( + fleetRequest(fleetSecret), + result.response + ); + assert.equal(result.record.status, 503); + assert.deepEqual(result.record.body, { error: 'Operational status is unavailable' }); + } + }); + + test('rejects non-integer and negative queue counts', async () => { + for (const value of [ + { waiting: -1, active: 0 }, + { waiting: 0.5, active: 0 }, + { waiting: 0, active: -1 }, + { waiting: 0, active: 1.5 }, + ]) { + const result = recorder(); + await routes({ queueStatus: () => value }).getQueueStatus(fleetRequest(fleetSecret), result.response); + assert.equal(result.record.status, 503); + assert.deepEqual(result.record.body, { error: 'Queue status is unavailable' }); + } + }); + + test('sanitizes invalid collector results and catches thrown failures as 503 responses', async () => { + const invalidResult = recorder(); + await routes({ + operationalStatus: () => ({ error: 'sensitive backend detail', credential: 'do-not-expose' }), + }).getOperationalStatus(fleetRequest(fleetSecret), invalidResult.response); + assert.equal(invalidResult.record.status, 503); + assert.deepEqual(invalidResult.record.body, { error: 'Operational status is unavailable' }); + + const originalConsoleError = console.error; + console.error = () => undefined; + try { + const thrownFailure = recorder(); + await routes({ + queueStatus: () => { throw new Error('sensitive queue failure'); }, + }).getQueueStatus(fleetRequest(fleetSecret), thrownFailure.response); + assert.equal(thrownFailure.record.status, 503); + assert.deepEqual(thrownFailure.record.body, { error: 'Queue status is unavailable' }); + } finally { + console.error = originalConsoleError; + } + }); +}); + +describe('hosted fleet Express wiring', () => { + test('omits every hosted route when Fleet control is disabled', async () => { + const { app, registered } = wiredApp(''); + assert.equal(registered, false); + + for (const path of ['/api/internal/hosted/bootstrap', '/api/internal/hosted/status', '/api/internal/hosted/queue']) { + const response = await fetchFromApp(app, path); + assert.equal(response.status, 401, path); + assert.deepEqual(await response.json(), { error: 'Unauthorized' }, path); + } + }); + + test('registers protected hosted routes before the OAuth boundary when enabled', async () => { + const { app, registered } = wiredApp(fleetSecret); + assert.equal(registered, true); + + const unauthorized = await fetchFromApp(app, '/api/internal/hosted/status'); + assert.equal(unauthorized.status, 401); + assert.deepEqual(await unauthorized.json(), { error: 'Fleet authentication required' }); + + for (const path of ['/api/internal/hosted/bootstrap', '/api/internal/hosted/status', '/api/internal/hosted/queue']) { + const response = await fetchFromApp(app, path, { + headers: { 'x-propr-fleet-secret': fleetSecret }, + }); + assert.equal(response.status, 200, path); + assert.equal(response.headers.get('cache-control'), 'no-store', path); + const body = await response.json() as Record; + if (path.endsWith('/status')) { + assert.deepEqual(Object.keys(body).sort(), [ + 'githubAuth', + 'githubAuthMode', + 'githubEventIntake', + 'githubEventIntakeStatus', + ]); + } else if (path.endsWith('/queue')) { + assert.deepEqual(body, { waiting: 2, active: 1 }); + } + } + }); +});