Files
makelore/electron/api/server.ts

96 lines
4.0 KiB
TypeScript

import { randomBytes } from 'node:crypto';
import { createServer, type Server } from 'node:http';
import { getPort } from '../utils/config';
import { logger } from '../utils/logger';
import type { HostApiContext } from './context';
import { sendJson, setCorsHeaders, requireJsonContentType } from './route-utils';
import { rotateRendererCapability } from './renderer-capability';
import { hostApiRouteHandlers } from './route-handlers';
/**
* Per-session secret token used to authenticate Host API requests.
* Generated once at server start and shared with the renderer via IPC.
* This prevents cross-origin attackers from reading sensitive data even
* if they can reach 127.0.0.1:13210 (the CORS wildcard alone is not
* sufficient because browsers attach the Origin header but not a secret).
*/
let hostApiToken: string = '';
/** Retrieve the current Host API auth token (for use by IPC proxy). */
export function getHostApiToken(): string {
return hostApiToken;
}
export function startHostApiServer(ctx: HostApiContext, port = getPort('NIANCODE_HOST_API')): Server {
// Generate a cryptographically random token for this session.
hostApiToken = randomBytes(32).toString('hex');
rotateRendererCapability();
const server = createServer(async (req, res) => {
try {
const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`);
// ── CORS headers ─────────────────────────────────────────
// Set origin-aware CORS headers early so every response
// (including error responses) carries them consistently.
const origin = req.headers.origin;
setCorsHeaders(res, origin, ctx.opencodeManager.getStatus().url);
// CORS preflight — respond before auth so browsers can negotiate.
if (req.method === 'OPTIONS') {
res.statusCode = 204;
res.end();
return;
}
// ── Auth gate ──────────────────────────────────────────────
// Every non-preflight request must carry a valid Bearer token.
// Accept via Authorization header (preferred) or ?token= query
// parameter (for EventSource which cannot set custom headers).
const authHeader = req.headers.authorization || '';
const bearerToken = authHeader.startsWith('Bearer ')
? authHeader.slice(7)
: (requestUrl.searchParams.get('token') || '');
if (bearerToken !== hostApiToken) {
sendJson(res, 401, { success: false, error: 'Unauthorized' });
return;
}
// ── Content-Type gate (anti-CSRF) ──────────────────────────
// Mutation requests must use application/json to force a CORS
// preflight, preventing "simple request" CSRF attacks.
if (!requireJsonContentType(req)) {
sendJson(res, 415, { success: false, error: 'Content-Type must be application/json' });
return;
}
for (const handler of hostApiRouteHandlers) {
if (await handler(req, res, requestUrl, ctx)) {
return;
}
}
sendJson(res, 404, { success: false, error: `No route for ${req.method} ${requestUrl.pathname}` });
} catch (error) {
logger.error('Host API request failed:', error);
sendJson(res, 500, { success: false, error: String(error) });
}
});
server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EACCES' || error.code === 'EADDRINUSE') {
logger.error(
`Host API server failed to bind port ${port}: ${error.message}. ` +
'On Windows this is often caused by Hyper-V reserving the port range. ' +
`Set NIANCODE_PORT_NIANCODE_HOST_API env var to override the default port.`,
);
} else {
logger.error('Host API server error:', error);
}
});
server.listen(port, '127.0.0.1', () => {
logger.info(`Host API server listening on http://127.0.0.1:${port}`);
});
return server;
}