From 82abb2fbd2cb76bc8e5d04ba1512f0c220f39b1f Mon Sep 17 00:00:00 2001 From: Rinalds Uzkalns Date: Sat, 1 Aug 2026 21:52:50 +0200 Subject: [PATCH 1/4] feat(api): add hosted fleet control endpoints --- .env.example | 6 + packages/api/routes/hostedFleetRoutes.ts | 97 +++++++++++++ packages/api/routes/index.ts | 1 + packages/api/server.ts | 12 +- packages/api/test/hostedFleetRoutes.test.ts | 146 ++++++++++++++++++++ 5 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 packages/api/routes/hostedFleetRoutes.ts create mode 100644 packages/api/test/hostedFleetRoutes.test.ts diff --git a/.env.example b/.env.example index 533e4d789..cc4d59797 100644 --- a/.env.example +++ b/.env.example @@ -203,6 +203,12 @@ 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. 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. +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..ee9930d1a --- /dev/null +++ b/packages/api/routes/hostedFleetRoutes.ts @@ -0,0 +1,97 @@ +import { timingSafeEqual } from 'node:crypto'; +import type { 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; + operationalStatus?: (req: Request, res: Response) => void | Promise; + queueStatus?: (req: Request, res: Response) => void | Promise; +} + +function safeEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); +} + +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, + operationalStatus, + queueStatus, +}: HostedFleetRoutesDeps = {}) { + function isAuthorized(req: Request): boolean { + const supplied = req.get('x-propr-fleet-secret') ?? ''; + return Boolean(fleetSecret && fleetSecret.length >= 32 && safeEqual(supplied, fleetSecret)); + } + + async function getBootstrapStatus(req: Request, res: Response): Promise { + if (!isAuthorized(req)) { + res.status(401).json({ error: 'Fleet authentication required' }); + return; + } + if (!initialAdminGithubUserId || !/^\d+$/.test(initialAdminGithubUserId)) { + res.status(409).json({ error: 'Hosted initial administrator is not configured' }); + return; + } + + const durableAdmin = await database('instance_members') + .select('github_user_id') + .where({ github_user_id: initialAdminGithubUserId, role: 'admin' }) + .first(); + const normalizedLogin = initialAdminGithubLogin?.trim().toLowerCase() ?? ''; + const environmentBootstrapActive = normalizedLogin.length > 0 + && getBootstrapAdminUsernames().some(username => username.toLowerCase() === normalizedLogin); + const whitelist = (githubUserWhitelist ?? '') + .split(',') + .map(username => username.trim().toLowerCase()) + .filter(Boolean); + + res.setHeader('Cache-Control', 'no-store'); + res.json({ + initialAdminGithubUserId, + durableAdminVerified: Boolean(durableAdmin), + environmentBootstrapActive, + whitelistOnlyInitialOwner: normalizedLogin.length > 0 + && whitelist.length === 1 + && whitelist[0] === normalizedLogin, + }); + } + + async function getOperationalStatus(req: Request, res: Response): Promise { + if (!isAuthorized(req)) { + res.status(401).json({ error: 'Fleet authentication required' }); + return; + } + if (!operationalStatus) { + res.status(503).json({ error: 'Operational status is unavailable' }); + return; + } + res.setHeader('Cache-Control', 'no-store'); + await operationalStatus(req, res); + } + + async function getQueueStatus(req: Request, res: Response): Promise { + if (!isAuthorized(req)) { + res.status(401).json({ error: 'Fleet authentication required' }); + return; + } + if (!queueStatus) { + res.status(503).json({ error: 'Queue status is unavailable' }); + return; + } + res.setHeader('Cache-Control', 'no-store'); + await queueStatus(req, res); + } + + return { getBootstrapStatus, getOperationalStatus, getQueueStatus }; +} diff --git a/packages/api/routes/index.ts b/packages/api/routes/index.ts index e285e82c7..b379e1031 100644 --- a/packages/api/routes/index.ts +++ b/packages/api/routes/index.ts @@ -24,3 +24,4 @@ export { createRepoTodoRoutes } from './repoTodoRoutes.js'; export { createUserRepoPreferencesRoutes } from './userRepoPreferencesRoutes.js'; export { createAgentRuntimeRoutes } from './agentRuntimeRoutes.js'; export { createAdminRoutes } from './adminRoutes.js'; +export { createHostedFleetRoutes } from './hostedFleetRoutes.js'; diff --git a/packages/api/server.ts b/packages/api/server.ts index 0463b6c64..df080ba23 100644 --- a/packages/api/server.ts +++ b/packages/api/server.ts @@ -28,6 +28,7 @@ import { createUserRepoPreferencesRoutes, createAgentRuntimeRoutes, createAdminRoutes, + createHostedFleetRoutes, createInstanceCatalogRoutes, attachmentUpload } from './routes/index.js'; @@ -215,6 +216,11 @@ async function initRedis(): Promise { function setupRoutes(): void { const statusRoutes = createStatusRoutes({ redisClient }); + const queueRoutes = createQueueRoutes({ redisClient, taskQueue }); + const hostedFleetRoutes = createHostedFleetRoutes({ + operationalStatus: statusRoutes.getStatus, + queueStatus: queueRoutes.getQueueStats + }); // 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 +229,17 @@ 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. + app.get('/api/internal/hosted/bootstrap', hostedFleetRoutes.getBootstrapStatus); + app.get('/api/internal/hosted/status', hostedFleetRoutes.getOperationalStatus); + app.get('/api/internal/hosted/queue', hostedFleetRoutes.getQueueStatus); 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..96d40d770 --- /dev/null +++ b/packages/api/test/hostedFleetRoutes.test.ts @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict'; +import { after, afterEach, beforeEach, describe, test } from 'node:test'; +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 { createHostedFleetRoutes } from '../routes/hostedFleetRoutes.js'; + +const fleetSecret = 'fleet-control-secret-with-at-least-32-bytes'; +let database: Knex; +let previousAdminUsers: string | undefined; + +beforeEach(async () => { + previousAdminUsers = process.env.PROPR_ADMIN_USERS; + process.env.PROPR_ADMIN_USERS = 'owner'; + database = knex({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true + }); + await createInstanceMemberTables(database); +}); + +afterEach(async () => { + await database.destroy(); + if (previousAdminUsers === undefined) delete process.env.PROPR_ADMIN_USERS; + else process.env.PROPR_ADMIN_USERS = previousAdminUsers; +}); + +after(async () => { + const { closeConnection, shutdownQueue } = await import('@propr/core'); + await closeConnection(); + await shutdownQueue(); +}); + +function request(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?: Record; headers: Record } = { + status: 200, + headers: {} + }; + const response = { + status(code: number) { record.status = code; return response; }, + json(body: Record) { 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() { + return createHostedFleetRoutes({ + database, + fleetSecret, + initialAdminGithubUserId: '100', + initialAdminGithubLogin: 'owner', + githubUserWhitelist: 'owner', + operationalStatus: (_req, res) => { + res.json({ + githubAuthMode: 'relay', + githubAuth: 'connected', + githubEventIntake: 'routing_websocket', + githubEventIntakeStatus: 'connected' + }); + }, + queueStatus: (_req, res) => { res.json({ waiting: 2, active: 1 }); } + }); +} + +describe('hosted fleet bootstrap status', () => { + test('rejects missing and incorrect service credentials', async () => { + for (const supplied of [undefined, 'wrong-secret']) { + const { response, record } = recorder(); + await routes().getBootstrapStatus(request(supplied), response); + assert.equal(record.status, 401); + assert.deepEqual(record.body, { error: 'Fleet authentication required' }); + } + }); + + test('reports a pending durable claim without exposing the login', async () => { + const { response, record } = recorder(); + await routes().getBootstrapStatus(request(fleetSecret), response); + + assert.equal(record.status, 200); + assert.deepEqual(record.body, { + initialAdminGithubUserId: '100', + durableAdminVerified: false, + environmentBootstrapActive: true, + whitelistOnlyInitialOwner: true + }); + assert.equal(record.headers['cache-control'], 'no-store'); + assert.equal(JSON.stringify(record.body).includes('owner'), false); + }); + + test('verifies the durable administrator by immutable GitHub user ID', async () => { + await database('instance_members').insert({ + github_user_id: '100', + github_username: 'renamed-owner', + role: 'admin', + source: 'local' + }); + process.env.PROPR_ADMIN_USERS = ''; + const { response, record } = recorder(); + await routes().getBootstrapStatus(request(fleetSecret), response); + + assert.equal(record.body?.durableAdminVerified, true); + assert.equal(record.body?.environmentBootstrapActive, 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(request(fleetSecret), response); + assert.equal(record.body?.durableAdminVerified, false); + }); + + test('protects the current operational status with the fleet credential', async () => { + const unauthorized = recorder(); + await routes().getOperationalStatus(request(), unauthorized.response); + assert.equal(unauthorized.record.status, 401); + + const authorized = recorder(); + await routes().getOperationalStatus(request(fleetSecret), authorized.response); + assert.deepEqual(authorized.record.body, { + githubAuthMode: 'relay', + githubAuth: 'connected', + githubEventIntake: 'routing_websocket', + githubEventIntakeStatus: 'connected' + }); + assert.equal(authorized.record.headers['cache-control'], 'no-store'); + + const queue = recorder(); + await routes().getQueueStatus(request(fleetSecret), queue.response); + assert.deepEqual(queue.record.body, { waiting: 2, active: 1 }); + }); +}); From 8db5de09a8e548b40cc31c4ab5dc3cb9827b3053 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 04:51:30 +0000 Subject: [PATCH 2/4] feat(ai): Implemented the PR follow-up without committing. Implemented the PR follow-up without committing. - Hosted fleet routes are mounted only when `PROPR_FLEET_CONTROL_SECRET` is at least 32 characters. - Reused the same enablement check for authentication. - Updated `.env.example` documentation and added regression coverage. Validation passed: - API typecheck - API lint - Hosted fleet route tests: 6/6 - `git diff --check` PR: #1714 Comment by: @integry (ID: 5155392232) Model: gpt-5.6-sol --- .env.example | 8 +++++--- packages/api/routes/hostedFleetRoutes.ts | 8 +++++++- packages/api/routes/index.ts | 2 +- packages/api/server.ts | 21 ++++++++++++--------- packages/api/test/hostedFleetRoutes.test.ts | 17 ++++++++++++++++- 5 files changed, 41 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index cc4d59797..1dc912cdb 100644 --- a/.env.example +++ b/.env.example @@ -203,9 +203,11 @@ 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. 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. +# 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. PROPR_FLEET_CONTROL_SECRET= PROPR_HOSTED_INITIAL_ADMIN_GITHUB_USER_ID= PROPR_HOSTED_INITIAL_ADMIN_GITHUB_LOGIN= diff --git a/packages/api/routes/hostedFleetRoutes.ts b/packages/api/routes/hostedFleetRoutes.ts index ee9930d1a..de3f61240 100644 --- a/packages/api/routes/hostedFleetRoutes.ts +++ b/packages/api/routes/hostedFleetRoutes.ts @@ -14,6 +14,12 @@ interface HostedFleetRoutesDeps { queueStatus?: (req: Request, res: Response) => void | Promise; } +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 leftBuffer = Buffer.from(left); const rightBuffer = Buffer.from(right); @@ -31,7 +37,7 @@ export function createHostedFleetRoutes({ }: HostedFleetRoutesDeps = {}) { function isAuthorized(req: Request): boolean { const supplied = req.get('x-propr-fleet-secret') ?? ''; - return Boolean(fleetSecret && fleetSecret.length >= 32 && safeEqual(supplied, fleetSecret)); + return isHostedFleetControlEnabled(fleetSecret) && safeEqual(supplied, fleetSecret); } async function getBootstrapStatus(req: Request, res: Response): Promise { diff --git a/packages/api/routes/index.ts b/packages/api/routes/index.ts index b379e1031..ad5f61fb2 100644 --- a/packages/api/routes/index.ts +++ b/packages/api/routes/index.ts @@ -24,4 +24,4 @@ export { createRepoTodoRoutes } from './repoTodoRoutes.js'; export { createUserRepoPreferencesRoutes } from './userRepoPreferencesRoutes.js'; export { createAgentRuntimeRoutes } from './agentRuntimeRoutes.js'; export { createAdminRoutes } from './adminRoutes.js'; -export { createHostedFleetRoutes } from './hostedFleetRoutes.js'; +export { createHostedFleetRoutes, isHostedFleetControlEnabled } from './hostedFleetRoutes.js'; diff --git a/packages/api/server.ts b/packages/api/server.ts index df080ba23..954e6458b 100644 --- a/packages/api/server.ts +++ b/packages/api/server.ts @@ -29,6 +29,7 @@ import { createAgentRuntimeRoutes, createAdminRoutes, createHostedFleetRoutes, + isHostedFleetControlEnabled, createInstanceCatalogRoutes, attachmentUpload } from './routes/index.js'; @@ -217,10 +218,6 @@ async function initRedis(): Promise { function setupRoutes(): void { const statusRoutes = createStatusRoutes({ redisClient }); const queueRoutes = createQueueRoutes({ redisClient, taskQueue }); - const hostedFleetRoutes = createHostedFleetRoutes({ - operationalStatus: statusRoutes.getStatus, - queueStatus: queueRoutes.getQueueStats - }); // 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 @@ -229,11 +226,17 @@ 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. - app.get('/api/internal/hosted/bootstrap', hostedFleetRoutes.getBootstrapStatus); - app.get('/api/internal/hosted/status', hostedFleetRoutes.getOperationalStatus); - app.get('/api/internal/hosted/queue', hostedFleetRoutes.getQueueStatus); + if (isHostedFleetControlEnabled()) { + const hostedFleetRoutes = createHostedFleetRoutes({ + operationalStatus: statusRoutes.getStatus, + queueStatus: queueRoutes.getQueueStats + }); + // Machine-to-machine bootstrap verification has its own narrow service + // credential and deliberately does not depend on a customer's OAuth session. + app.get('/api/internal/hosted/bootstrap', hostedFleetRoutes.getBootstrapStatus); + app.get('/api/internal/hosted/status', hostedFleetRoutes.getOperationalStatus); + app.get('/api/internal/hosted/queue', hostedFleetRoutes.getQueueStatus); + } app.use('/api', ensureAuthenticated, resolveAuthorization); const taskRoutes = createTaskRoutes({ db, taskQueue }); const taskHistoryRoutes = createTaskHistoryRoutes({ redisClient, taskQueue, db }); diff --git a/packages/api/test/hostedFleetRoutes.test.ts b/packages/api/test/hostedFleetRoutes.test.ts index 96d40d770..9df1917ea 100644 --- a/packages/api/test/hostedFleetRoutes.test.ts +++ b/packages/api/test/hostedFleetRoutes.test.ts @@ -3,7 +3,7 @@ import { after, afterEach, beforeEach, describe, test } from 'node:test'; 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 { createHostedFleetRoutes } from '../routes/hostedFleetRoutes.js'; +import { createHostedFleetRoutes, isHostedFleetControlEnabled } from '../routes/hostedFleetRoutes.js'; const fleetSecret = 'fleet-control-secret-with-at-least-32-bytes'; let database: Knex; @@ -73,6 +73,21 @@ function routes() { } describe('hosted fleet bootstrap status', () => { + test('requires a valid fleet control secret before routes are enabled', () => { + const previousFleetSecret = process.env.PROPR_FLEET_CONTROL_SECRET; + try { + delete process.env.PROPR_FLEET_CONTROL_SECRET; + assert.equal(isHostedFleetControlEnabled(), false); + process.env.PROPR_FLEET_CONTROL_SECRET = 'x'.repeat(31); + assert.equal(isHostedFleetControlEnabled(), false); + process.env.PROPR_FLEET_CONTROL_SECRET = 'x'.repeat(32); + assert.equal(isHostedFleetControlEnabled(), true); + } finally { + if (previousFleetSecret === undefined) delete process.env.PROPR_FLEET_CONTROL_SECRET; + else process.env.PROPR_FLEET_CONTROL_SECRET = previousFleetSecret; + } + }); + test('rejects missing and incorrect service credentials', async () => { for (const supplied of [undefined, 'wrong-secret']) { const { response, record } = recorder(); From 4b4a0885064409dbb230cb4281927124c25b336a Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:15:28 +0000 Subject: [PATCH 3/4] feat(ai): Implemented all review follow-ups for PR #1714. Implemented all review follow-ups for PR #1714. - Fleet status/queue responses are explicitly validated and allowlisted. - Added `bootstrapOnlyInitialOwner`, username deduplication, injected bootstrap configuration, and GitHub ID canonicalization. - Fleet-secret comparison now uses fixed-length SHA-256 digests. - Added gated route-registration wiring before OAuth middleware. - Expanded unit and Express integration coverage. - Documented `openssl rand -hex 32` secret generation. Key files: [hostedFleetRoutes.ts](/tmp/git-processor/worktrees/integry/propr/pr-1714-followup-2026-08-02T05-01-52/packages/api/routes/hostedFleetRoutes.ts:44), [server.ts](/tmp/git-processor/worktrees/integry/propr/pr-1714-followup-2026-08-02T05-01-52/packages/api/server.ts:217), [hostedFleetRoutes.test.ts](/tmp/git-processor/worktrees/integry/propr/pr-1714-followup-2026-08-02T05-01-52/packages/api/test/hostedFleetRoutes.test.ts:1). Verification passed: - Repository-wide TypeScript check - API lint - 12 Fleet route tests - 32 combined Fleet/status tests - `git diff --check` No commit created. PR: #1714 Comment by: @propr-ultrafix (ID: 0) Model: gpt-5.6-sol --- .env.example | 1 + packages/api/routes/hostedFleetRoutes.ts | 184 +++++++++++++-- packages/api/server.ts | 22 +- packages/api/test/hostedFleetRoutes.test.ts | 243 ++++++++++++++++---- 4 files changed, 370 insertions(+), 80 deletions(-) diff --git a/.env.example b/.env.example index 1dc912cdb..e6391e114 100644 --- a/.env.example +++ b/.env.example @@ -208,6 +208,7 @@ PROPR_ADMIN_USERS= # 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= diff --git a/packages/api/routes/hostedFleetRoutes.ts b/packages/api/routes/hostedFleetRoutes.ts index de3f61240..01724fdfe 100644 --- a/packages/api/routes/hostedFleetRoutes.ts +++ b/packages/api/routes/hostedFleetRoutes.ts @@ -1,5 +1,5 @@ -import { timingSafeEqual } from 'node:crypto'; -import type { Request, Response } from 'express'; +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'; @@ -10,10 +10,31 @@ interface HostedFleetRoutesDeps { initialAdminGithubUserId?: string; initialAdminGithubLogin?: string; githubUserWhitelist?: string; + bootstrapAdminUsernames?: readonly string[]; operationalStatus?: (req: Request, res: Response) => void | Promise; queueStatus?: (req: Request, res: Response) => void | Promise; } +type DelegatedHandler = NonNullable; + +interface DelegatedJsonResult { + statusCode: number; + body: unknown; + jsonSent: boolean; +} + +interface FleetOperationalStatus { + githubAuthMode: string; + githubAuth: string; + githubEventIntake: string; + githubEventIntakeStatus: string; +} + +interface FleetQueueStatus { + waiting: number; + active: number; +} + export function isHostedFleetControlEnabled( fleetSecret: string | undefined = process.env.PROPR_FLEET_CONTROL_SECRET ): fleetSecret is string { @@ -21,9 +42,82 @@ export function isHostedFleetControlEnabled( } function safeEqual(left: string, right: string): boolean { - const leftBuffer = Buffer.from(left); - const rightBuffer = Buffer.from(right); - return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); + 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 || !/^\d+$/.test(trimmed)) return undefined; + return trimmed.replace(/^0+(?=\d)/, ''); +} + +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' + || typeof githubAuth !== 'string' + || typeof githubEventIntake !== 'string' + || typeof githubEventIntakeStatus !== 'string' + ) { + 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 }; +} + +async function captureDelegatedJson(handler: DelegatedHandler, req: Request): Promise { + const result: DelegatedJsonResult = { statusCode: 200, body: undefined, jsonSent: false }; + const response = { + status(code: number) { + result.statusCode = code; + return response; + }, + json(body: unknown) { + result.body = body; + result.jsonSent = true; + return response; + }, + setHeader() { + return response; + }, + set() { + return response; + }, + header() { + return response; + }, + get headersSent() { + return result.jsonSent; + }, + } as unknown as Response; + await handler(req, response); + return result; } export function createHostedFleetRoutes({ @@ -32,48 +126,53 @@ export function createHostedFleetRoutes({ 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 (!initialAdminGithubUserId || !/^\d+$/.test(initialAdminGithubUserId)) { + if (!canonicalInitialAdminGithubUserId) { res.status(409).json({ error: 'Hosted initial administrator is not configured' }); return; } const durableAdmin = await database('instance_members') .select('github_user_id') - .where({ github_user_id: initialAdminGithubUserId, role: 'admin' }) + .where({ github_user_id: canonicalInitialAdminGithubUserId, role: 'admin' }) .first(); - const normalizedLogin = initialAdminGithubLogin?.trim().toLowerCase() ?? ''; const environmentBootstrapActive = normalizedLogin.length > 0 - && getBootstrapAdminUsernames().some(username => username.toLowerCase() === normalizedLogin); - const whitelist = (githubUserWhitelist ?? '') - .split(',') - .map(username => username.trim().toLowerCase()) - .filter(Boolean); + && normalizedBootstrapAdmins.has(normalizedLogin); - res.setHeader('Cache-Control', 'no-store'); res.json({ - initialAdminGithubUserId, + initialAdminGithubUserId: canonicalInitialAdminGithubUserId, durableAdminVerified: Boolean(durableAdmin), environmentBootstrapActive, + bootstrapOnlyInitialOwner: normalizedLogin.length > 0 + && normalizedBootstrapAdmins.size === 1 + && normalizedBootstrapAdmins.has(normalizedLogin), whitelistOnlyInitialOwner: normalizedLogin.length > 0 - && whitelist.length === 1 - && whitelist[0] === normalizedLogin, + && 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; @@ -82,11 +181,26 @@ export function createHostedFleetRoutes({ res.status(503).json({ error: 'Operational status is unavailable' }); return; } - res.setHeader('Cache-Control', 'no-store'); - await operationalStatus(req, res); + try { + const delegated = await captureDelegatedJson(operationalStatus, req); + if (delegated.statusCode < 200 || delegated.statusCode >= 300) { + res.status(delegated.statusCode).json({ error: 'Operational status is unavailable' }); + return; + } + const status = delegated.jsonSent ? parseOperationalStatus(delegated.body) : undefined; + if (!status) { + res.status(503).json({ error: 'Operational status is unavailable' }); + return; + } + res.status(delegated.statusCode).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; @@ -95,9 +209,37 @@ export function createHostedFleetRoutes({ res.status(503).json({ error: 'Queue status is unavailable' }); return; } - res.setHeader('Cache-Control', 'no-store'); - await queueStatus(req, res); + try { + const delegated = await captureDelegatedJson(queueStatus, req); + if (delegated.statusCode < 200 || delegated.statusCode >= 300) { + res.status(delegated.statusCode).json({ error: 'Queue status is unavailable' }); + return; + } + const status = delegated.jsonSent ? parseQueueStatus(delegated.body) : undefined; + if (!status) { + res.status(503).json({ error: 'Queue status is unavailable' }); + return; + } + res.status(delegated.statusCode).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/server.ts b/packages/api/server.ts index 954e6458b..61476d04c 100644 --- a/packages/api/server.ts +++ b/packages/api/server.ts @@ -28,11 +28,10 @@ import { createUserRepoPreferencesRoutes, createAgentRuntimeRoutes, createAdminRoutes, - createHostedFleetRoutes, - isHostedFleetControlEnabled, createInstanceCatalogRoutes, attachmentUpload } from './routes/index.js'; +import { registerHostedFleetRoutes } from './routes/hostedFleetRoutes.js'; import { agentLoginSessionManager } from './services/agentLoginSessionManager.js'; import { checkAndExecuteDelayedReindex } from './routes/indexingQueueHelpers.js'; import { @@ -226,17 +225,14 @@ function setupRoutes(): void { // compatibility dates). All other /api routes registered after this line are // authenticated. app.get('/api/compatibility', statusRoutes.getCompatibility); - if (isHostedFleetControlEnabled()) { - const hostedFleetRoutes = createHostedFleetRoutes({ - operationalStatus: statusRoutes.getStatus, - queueStatus: queueRoutes.getQueueStats - }); - // Machine-to-machine bootstrap verification has its own narrow service - // credential and deliberately does not depend on a customer's OAuth session. - app.get('/api/internal/hosted/bootstrap', hostedFleetRoutes.getBootstrapStatus); - app.get('/api/internal/hosted/status', hostedFleetRoutes.getOperationalStatus); - app.get('/api/internal/hosted/queue', hostedFleetRoutes.getQueueStatus); - } + // 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.getStatus, + queueStatus: queueRoutes.getQueueStats + }); app.use('/api', ensureAuthenticated, resolveAuthorization); const taskRoutes = createTaskRoutes({ db, taskQueue }); const taskHistoryRoutes = createTaskHistoryRoutes({ redisClient, taskQueue, db }); diff --git a/packages/api/test/hostedFleetRoutes.test.ts b/packages/api/test/hostedFleetRoutes.test.ts index 9df1917ea..b33e865bf 100644 --- a/packages/api/test/hostedFleetRoutes.test.ts +++ b/packages/api/test/hostedFleetRoutes.test.ts @@ -1,17 +1,23 @@ 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 { createHostedFleetRoutes, isHostedFleetControlEnabled } from '../routes/hostedFleetRoutes.js'; +import { ensureAuthenticated } from '../auth.js'; +import { resolveAuthorization } from '../authorization.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; -let previousAdminUsers: string | undefined; beforeEach(async () => { - previousAdminUsers = process.env.PROPR_ADMIN_USERS; - process.env.PROPR_ADMIN_USERS = 'owner'; database = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, @@ -22,8 +28,6 @@ beforeEach(async () => { afterEach(async () => { await database.destroy(); - if (previousAdminUsers === undefined) delete process.env.PROPR_ADMIN_USERS; - else process.env.PROPR_ADMIN_USERS = previousAdminUsers; }); after(async () => { @@ -32,7 +36,7 @@ after(async () => { await shutdownQueue(); }); -function request(secret?: string): Request { +function fleetRequest(secret?: string): Request { return { get(name: string) { return name.toLowerCase() === 'x-propr-fleet-secret' ? secret : undefined; @@ -41,90 +45,169 @@ function request(secret?: string): Request { } function recorder() { - const record: { status: number; body?: Record; headers: Record } = { + const record: { status: number; body?: unknown; headers: Record } = { status: 200, headers: {} }; const response = { status(code: number) { record.status = code; return response; }, - json(body: Record) { record.body = body; 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() { +function routes(overrides: HostedFleetRoutesDeps = {}) { return createHostedFleetRoutes({ database, fleetSecret, initialAdminGithubUserId: '100', initialAdminGithubLogin: 'owner', githubUserWhitelist: 'owner', + bootstrapAdminUsernames: ['owner'], operationalStatus: (_req, res) => { res.json({ githubAuthMode: 'relay', githubAuth: 'connected', githubEventIntake: 'routing_websocket', - githubEventIntakeStatus: 'connected' + githubEventIntakeStatus: 'connected', + redis: 'connected', + routing: { routingUrl: 'wss://internal.example.test' }, }); }, - queueStatus: (_req, res) => { res.json({ waiting: 2, active: 1 }); } + queueStatus: (_req, res) => { + res.json({ 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(); + 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: (_req, res) => { + res.json({ + githubAuthMode: 'relay', + githubAuth: 'connected', + githubEventIntake: 'routing_websocket', + githubEventIntakeStatus: 'connected', + }); + }, + queueStatus: (_req, res) => { res.json({ waiting: 2, active: 1 }); }, + }); + app.use('/api', ensureAuthenticated, resolveAuthorization); + return { app, registered }; +} + describe('hosted fleet bootstrap status', () => { - test('requires a valid fleet control secret before routes are enabled', () => { - const previousFleetSecret = process.env.PROPR_FLEET_CONTROL_SECRET; - try { - delete process.env.PROPR_FLEET_CONTROL_SECRET; - assert.equal(isHostedFleetControlEnabled(), false); - process.env.PROPR_FLEET_CONTROL_SECRET = 'x'.repeat(31); - assert.equal(isHostedFleetControlEnabled(), false); - process.env.PROPR_FLEET_CONTROL_SECRET = 'x'.repeat(32); - assert.equal(isHostedFleetControlEnabled(), true); - } finally { - if (previousFleetSecret === undefined) delete process.env.PROPR_FLEET_CONTROL_SECRET; - else process.env.PROPR_FLEET_CONTROL_SECRET = previousFleetSecret; - } + 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 and incorrect service credentials', async () => { - for (const supplied of [undefined, 'wrong-secret']) { + 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(request(supplied), response); + 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 or invalid initial administrator IDs', async () => { + for (const initialAdminGithubUserId of ['', 'not-a-github-id']) { + 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(request(fleetSecret), response); + 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('verifies the durable administrator by immutable GitHub user ID', async () => { + 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' }); - process.env.PROPR_ADMIN_USERS = ''; const { response, record } = recorder(); - await routes().getBootstrapStatus(request(fleetSecret), response); + 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('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); - assert.equal(record.body?.durableAdminVerified, true); - assert.equal(record.body?.environmentBootstrapActive, false); + 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 () => { @@ -135,27 +218,95 @@ describe('hosted fleet bootstrap status', () => { source: 'local' }); const { response, record } = recorder(); - await routes().getBootstrapStatus(request(fleetSecret), response); - assert.equal(record.body?.durableAdminVerified, false); + await routes().getBootstrapStatus(fleetRequest(fleetSecret), response); + assert.equal((record.body as Record).durableAdminVerified, false); }); +}); - test('protects the current operational status with the fleet credential', async () => { - const unauthorized = recorder(); - await routes().getOperationalStatus(request(), unauthorized.response); - assert.equal(unauthorized.record.status, 401); - - const authorized = recorder(); - await routes().getOperationalStatus(request(fleetSecret), authorized.response); - assert.deepEqual(authorized.record.body, { +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(authorized.record.headers['cache-control'], 'no-store'); + assert.equal(operational.record.headers['cache-control'], 'no-store'); const queue = recorder(); - await routes().getQueueStatus(request(fleetSecret), queue.response); + 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 delegated handlers 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('sanitizes delegated error responses and catches thrown failures', async () => { + const delegatedFailure = recorder(); + await routes({ + operationalStatus: (_req, res) => { + res.status(500).json({ error: 'sensitive backend detail', credential: 'do-not-expose' }); + }, + }).getOperationalStatus(fleetRequest(fleetSecret), delegatedFailure.response); + assert.equal(delegatedFailure.record.status, 500); + assert.deepEqual(delegatedFailure.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); + } }); }); From e2f58c0fb773e461491a59430a70fe80861a2efa Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:33:31 +0000 Subject: [PATCH 4/4] feat(ai): Implemented all requested review fixes without committing: Implemented all requested review fixes without committing: - Added sanitized bootstrap DB-error handling, positive bounded GitHub IDs, strict operational enums, and stable `503` health failures in [hostedFleetRoutes.ts](/tmp/git-processor/worktrees/integry/propr/pr-1714-followup-2026-08-02T05-21-25/packages/api/routes/hostedFleetRoutes.ts:30). - Replaced fabricated Express responses with shared pure collectors in [statusRoutes.ts](/tmp/git-processor/worktrees/integry/propr/pr-1714-followup-2026-08-02T05-21-25/packages/api/routes/statusRoutes.ts:68) and [queueRoutes.ts](/tmp/git-processor/worktrees/integry/propr/pr-1714-followup-2026-08-02T05-21-25/packages/api/routes/queueRoutes.ts:13). - Updated server/barrel wiring and added real-collector, malformed-output, database-failure, and ID-boundary tests. Verification passed: - 36 focused Fleet/status tests - 155 repository unit tests - Root and API TypeScript checks - Targeted ESLint - `git diff --check` The standalone demo-mode suite still has two unrelated pre-existing expectation mismatches; no demo-mode code was changed. PR: #1714 Comment by: @propr-ultrafix (ID: 0) Model: gpt-5.6-sol --- packages/api/routes/hostedFleetRoutes.ts | 92 ++++----- packages/api/routes/index.ts | 6 +- packages/api/routes/queueRoutes.ts | 22 ++- packages/api/routes/statusRoutes.ts | 195 ++++++++++---------- packages/api/server.ts | 8 +- packages/api/test/hostedFleetRoutes.test.ts | 176 ++++++++++++++---- 6 files changed, 296 insertions(+), 203 deletions(-) diff --git a/packages/api/routes/hostedFleetRoutes.ts b/packages/api/routes/hostedFleetRoutes.ts index 01724fdfe..da6b74fe9 100644 --- a/packages/api/routes/hostedFleetRoutes.ts +++ b/packages/api/routes/hostedFleetRoutes.ts @@ -11,16 +11,8 @@ interface HostedFleetRoutesDeps { initialAdminGithubLogin?: string; githubUserWhitelist?: string; bootstrapAdminUsernames?: readonly string[]; - operationalStatus?: (req: Request, res: Response) => void | Promise; - queueStatus?: (req: Request, res: Response) => void | Promise; -} - -type DelegatedHandler = NonNullable; - -interface DelegatedJsonResult { - statusCode: number; - body: unknown; - jsonSent: boolean; + operationalStatus?: () => unknown | Promise; + queueStatus?: () => unknown | Promise; } interface FleetOperationalStatus { @@ -35,6 +27,12 @@ interface FleetQueueStatus { 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 { @@ -49,8 +47,9 @@ function safeEqual(left: string, right: string): boolean { function canonicalizeGithubUserId(value: string | undefined): string | undefined { const trimmed = value?.trim(); - if (!trimmed || !/^\d+$/.test(trimmed)) return undefined; - return trimmed.replace(/^0+(?=\d)/, ''); + 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 { @@ -66,9 +65,13 @@ function parseOperationalStatus(value: unknown): FleetOperationalStatus | undefi 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; } @@ -91,35 +94,6 @@ function parseQueueStatus(value: unknown): FleetQueueStatus | undefined { return { waiting, active }; } -async function captureDelegatedJson(handler: DelegatedHandler, req: Request): Promise { - const result: DelegatedJsonResult = { statusCode: 200, body: undefined, jsonSent: false }; - const response = { - status(code: number) { - result.statusCode = code; - return response; - }, - json(body: unknown) { - result.body = body; - result.jsonSent = true; - return response; - }, - setHeader() { - return response; - }, - set() { - return response; - }, - header() { - return response; - }, - get headersSent() { - return result.jsonSent; - }, - } as unknown as Response; - await handler(req, response); - return result; -} - export function createHostedFleetRoutes({ database = db, fleetSecret = process.env.PROPR_FLEET_CONTROL_SECRET, @@ -151,16 +125,24 @@ export function createHostedFleetRoutes({ return; } - const durableAdmin = await database('instance_members') - .select('github_user_id') - .where({ github_user_id: canonicalInitialAdminGithubUserId, role: 'admin' }) - .first(); + 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: Boolean(durableAdmin), + durableAdminVerified, environmentBootstrapActive, bootstrapOnlyInitialOwner: normalizedLogin.length > 0 && normalizedBootstrapAdmins.size === 1 @@ -182,17 +164,12 @@ export function createHostedFleetRoutes({ return; } try { - const delegated = await captureDelegatedJson(operationalStatus, req); - if (delegated.statusCode < 200 || delegated.statusCode >= 300) { - res.status(delegated.statusCode).json({ error: 'Operational status is unavailable' }); - return; - } - const status = delegated.jsonSent ? parseOperationalStatus(delegated.body) : undefined; + const status = parseOperationalStatus(await operationalStatus()); if (!status) { res.status(503).json({ error: 'Operational status is unavailable' }); return; } - res.status(delegated.statusCode).json(status); + res.json(status); } catch (error) { console.error('Failed to collect hosted Fleet operational status:', error); res.status(503).json({ error: 'Operational status is unavailable' }); @@ -210,17 +187,12 @@ export function createHostedFleetRoutes({ return; } try { - const delegated = await captureDelegatedJson(queueStatus, req); - if (delegated.statusCode < 200 || delegated.statusCode >= 300) { - res.status(delegated.statusCode).json({ error: 'Queue status is unavailable' }); - return; - } - const status = delegated.jsonSent ? parseQueueStatus(delegated.body) : undefined; + const status = parseQueueStatus(await queueStatus()); if (!status) { res.status(503).json({ error: 'Queue status is unavailable' }); return; } - res.status(delegated.statusCode).json(status); + res.json(status); } catch (error) { console.error('Failed to collect hosted Fleet queue status:', error); res.status(503).json({ error: 'Queue status is unavailable' }); diff --git a/packages/api/routes/index.ts b/packages/api/routes/index.ts index ad5f61fb2..5371db4d4 100644 --- a/packages/api/routes/index.ts +++ b/packages/api/routes/index.ts @@ -24,4 +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 } from './hostedFleetRoutes.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 61476d04c..d7fe377a6 100644 --- a/packages/api/server.ts +++ b/packages/api/server.ts @@ -29,9 +29,9 @@ import { createAgentRuntimeRoutes, createAdminRoutes, createInstanceCatalogRoutes, - attachmentUpload + attachmentUpload, + registerHostedFleetRoutes } from './routes/index.js'; -import { registerHostedFleetRoutes } from './routes/hostedFleetRoutes.js'; import { agentLoginSessionManager } from './services/agentLoginSessionManager.js'; import { checkAndExecuteDelayedReindex } from './routes/indexingQueueHelpers.js'; import { @@ -230,8 +230,8 @@ function setupRoutes(): void { // Registration remains before the OAuth boundary and is a no-op unless Fleet // control was explicitly enabled at startup. registerHostedFleetRoutes(app, { - operationalStatus: statusRoutes.getStatus, - queueStatus: queueRoutes.getQueueStats + operationalStatus: statusRoutes.collectStatus, + queueStatus: queueRoutes.collectQueueStats }); app.use('/api', ensureAuthenticated, resolveAuthorization); const taskRoutes = createTaskRoutes({ db, taskQueue }); diff --git a/packages/api/test/hostedFleetRoutes.test.ts b/packages/api/test/hostedFleetRoutes.test.ts index b33e865bf..62f35aefe 100644 --- a/packages/api/test/hostedFleetRoutes.test.ts +++ b/packages/api/test/hostedFleetRoutes.test.ts @@ -7,6 +7,8 @@ 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, @@ -65,19 +67,15 @@ function routes(overrides: HostedFleetRoutesDeps = {}) { initialAdminGithubLogin: 'owner', githubUserWhitelist: 'owner', bootstrapAdminUsernames: ['owner'], - operationalStatus: (_req, res) => { - res.json({ - githubAuthMode: 'relay', - githubAuth: 'connected', - githubEventIntake: 'routing_websocket', - githubEventIntakeStatus: 'connected', - redis: 'connected', - routing: { routingUrl: 'wss://internal.example.test' }, - }); - }, - queueStatus: (_req, res) => { - res.json({ waiting: 2, active: 1, completed: 20, failed: 3, delayed: 4, total: 30 }); - }, + 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, }); } @@ -101,6 +99,37 @@ async function fetchFromApp( 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(); @@ -112,15 +141,8 @@ function wiredApp(secret: string) { initialAdminGithubLogin: 'owner', githubUserWhitelist: 'owner', bootstrapAdminUsernames: ['owner'], - operationalStatus: (_req, res) => { - res.json({ - githubAuthMode: 'relay', - githubAuth: 'connected', - githubEventIntake: 'routing_websocket', - githubEventIntakeStatus: 'connected', - }); - }, - queueStatus: (_req, res) => { res.json({ waiting: 2, active: 1 }); }, + operationalStatus: statusRoutes.collectStatus, + queueStatus: queueRoutes.collectQueueStats, }); app.use('/api', ensureAuthenticated, resolveAuthorization); return { app, registered }; @@ -148,8 +170,16 @@ describe('hosted fleet bootstrap status', () => { assert.deepEqual(queue.record.body, { error: 'Fleet authentication required' }); }); - test('rejects missing or invalid initial administrator IDs', async () => { - for (const initialAdminGithubUserId of ['', 'not-a-github-id']) { + 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); @@ -173,6 +203,28 @@ describe('hosted fleet bootstrap status', () => { 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', @@ -191,6 +243,15 @@ describe('hosted fleet bootstrap status', () => { 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({ @@ -241,7 +302,7 @@ describe('hosted fleet health status', () => { assert.equal(queue.record.headers['cache-control'], 'no-store'); }); - test('returns 503 when delegated handlers are unavailable', async () => { + test('returns 503 when status collectors are unavailable', async () => { const operational = recorder(); await routes({ operationalStatus: undefined }).getOperationalStatus( fleetRequest(fleetSecret), @@ -256,15 +317,53 @@ describe('hosted fleet health status', () => { assert.deepEqual(queue.record.body, { error: 'Queue status is unavailable' }); }); - test('sanitizes delegated error responses and catches thrown failures', async () => { - const delegatedFailure = recorder(); + 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: (_req, res) => { - res.status(500).json({ error: 'sensitive backend detail', credential: 'do-not-expose' }); - }, - }).getOperationalStatus(fleetRequest(fleetSecret), delegatedFailure.response); - assert.equal(delegatedFailure.record.status, 500); - assert.deepEqual(delegatedFailure.record.body, { error: 'Operational status is unavailable' }); + 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; @@ -307,6 +406,17 @@ describe('hosted fleet Express wiring', () => { }); 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 }); + } } }); });