Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
217 changes: 217 additions & 0 deletions packages/api/routes/hostedFleetRoutes.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
queueStatus?: () => unknown | Promise<unknown>;
}

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<string> {
return new Set(usernames.map(username => username.trim().toLowerCase()).filter(Boolean));
}

function isRecord(value: unknown): value is Record<string, unknown> {
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<void> {
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<void> {
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<void> {
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<Application, 'get'>,
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;
}
5 changes: 5 additions & 0 deletions packages/api/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
22 changes: 13 additions & 9 deletions packages/api/routes/queueRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,20 @@ interface QueueRoutesDeps {
export function createQueueRoutes(deps: QueueRoutesDeps) {
const { redisClient, taskQueue } = deps;

async function collectQueueStats(): Promise<Record<string, number>> {
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<void> {
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' });
Expand Down Expand Up @@ -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<string, unknown> {
Expand Down
Loading