Files
pig/apps/api/src/server.ts
T
karti 54edee30ed Unknown /api paths returned the SPA with HTTP 200
An authenticated GET to any unrecognised API route — a typo, a renamed
endpoint, an older client — fell through to the SPA fallback and returned
200 text/html containing the app shell.

This is close to the worst failure shape for an API consumer. `response.ok` is
true, so nothing treats it as an error; the caller then dies on `JSON.parse`
with "Unexpected token '<'" far from the actual cause. The MCP server, the CLI
and Piggy all consume this API and would all have hit it. It was masked from
casual testing because unauthenticated requests are rejected earlier by the
auth middleware, so it only appears once you hold a valid token.

Found by probing production with Scott's token: GET /api/keys (the real path is
/api/api-keys) returned 200 text/html.

The static-file middleware already carried this guard — added for the same
reason when og.png was being served as HTML — but the SPA fallback beneath it
did not. Same guard, one place missing.

Verified: unknown API paths now return 404 application/json, real API paths
still answer, client-side routes still receive the shell, and static assets
still serve with their own content types. Typecheck clean, 124 unit tests and
the e2e suite green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:05:00 -07:00

157 lines
6.5 KiB
TypeScript

/**
* The PIG server.
*
* Serves the API and, in production, the built front end from the same origin.
* Same-origin matters more than it might appear: the browser holds its auth
* session per origin, so splitting the app across two hostnames turns sign-in
* into a loop that looks like a broken deployment.
*/
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { createDatabase } from '@pig/db';
import { createApp } from './app';
import { loadConfig } from './lib/config';
import { startPrimeSync } from './services/sync';
import { reconcileInviteCode } from './services/bootstrap';
import { CapacityService } from './services/capacity';
import { effectivePrimeSyncConfig } from './routes/admin-settings';
import { NotificationOutbox, NotificationWorker } from './services/notification-outbox';
import { SlackNotifier } from './services/slack';
import { BuzzNotifier } from './services/buzz';
const config = loadConfig();
const db = createDatabase({ url: config.DATABASE_URL });
let stopSync = () => {};
async function reloadPrimeSync(): Promise<void> {
stopSync();
stopSync = startPrimeSync(await effectivePrimeSyncConfig(config, db), db);
}
const app = createApp(config, db, undefined, { onPlatformSettingsChanged: reloadPrimeSync });
// Serve the built SPA when it exists. Absent in development, where Vite serves
// it on its own port with hot reload.
const webDist = join(process.cwd(), 'apps/web/dist');
if (existsSync(webDist)) {
/*
* Serve the ENTIRE dist directory, not just /assets.
*
* Serving only /assets and letting everything else fall through to the SPA
* shell quietly breaks every top-level file: og.png, the icons, the manifest
* and robots.txt each returned `200 text/html` containing index.html.
*
* That failure is close to invisible. The app works, the browser tab shows
* an icon (from the SVG, which browsers cache aggressively), and nothing
* errors — but a link shared to iMessage, Slack or X fetches og.png, gets
* HTML, and renders a card with no image. Verified with a real request from
* an outside host rather than assumed, because that is the only way this
* class of bug shows up.
*
* serveStatic calls next() when there is no matching file, so unmatched
* paths still reach the SPA fallback below.
*/
app.use('*', async (c, next) => {
// API routes must never be answered from disk.
if (new URL(c.req.url).pathname.startsWith('/api/')) return next();
return serveStatic({ root: './apps/web/dist' })(c, next);
});
/*
* Client-side routes (/margin, /capacity, …) have no file on disk and must
* receive the shell so the router can take over.
*
* The `/api/` guard is repeated here deliberately. Without it an unknown API
* path — a typo, a renamed endpoint, an older client — falls through to this
* fallback and returns **HTTP 200 with the SPA's HTML**. That is close to the
* worst possible failure for an API consumer: `response.ok` is true, so
* nothing treats it as an error, and the caller then fails on `JSON.parse`
* with "Unexpected token '<'" a long way from the actual cause. The MCP
* server, the CLI and Piggy all consume this API and would all have hit it.
*
* Confirmed against production before fixing: an authenticated GET to
* /api/keys (the real path is /api/api-keys) returned 200 text/html.
*/
app.get('*', async (c, next) => {
if (new URL(c.req.url).pathname.startsWith('/api/')) return next();
return serveStatic({ path: './apps/web/dist/index.html' })(c, next);
});
console.log('[pig] serving front end from', webDist);
}
// Reconcile configuration into the database before accepting traffic, so a
// freshly set invite code works on the first request rather than the second.
await reconcileInviteCode(config, db).catch((error) =>
console.error('[pig] could not reconcile the invite code:', error),
);
const server = serve({ fetch: app.fetch, port: config.PIG_PORT }, (info) => {
console.log(`[pig] listening on http://localhost:${info.port}`);
console.log(`[pig] environment: ${config.NODE_ENV}`);
});
// Background work. Both are optional and the application is fully usable with
// neither running.
await reloadPrimeSync();
const capacity = new CapacityService(db);
const notificationOutbox = new NotificationOutbox(db);
const slackNotificationsEnabled = Boolean(config.SLACK_BOT_TOKEN);
const buzzNotificationsEnabled = Boolean(config.BUZZ_RELAY_URL && config.BUZZ_PRIVATE_KEY);
const stopSlackNotifications = config.SLACK_BOT_TOKEN
? new NotificationWorker(db, new SlackNotifier({ botToken: config.SLACK_BOT_TOKEN })).start()
: () => {};
const stopBuzzNotifications = config.BUZZ_RELAY_URL && config.BUZZ_PRIVATE_KEY
? new NotificationWorker(
db,
new BuzzNotifier({
relayUrl: config.BUZZ_RELAY_URL,
privateKey: config.BUZZ_PRIVATE_KEY,
authTag: config.BUZZ_AUTH_TAG,
}),
).start()
: () => {};
let checkingIdleCapacity = false;
const checkIdleCapacity = () => {
if (checkingIdleCapacity || (!slackNotificationsEnabled && !buzzNotificationsEnabled)) return;
checkingIdleCapacity = true;
void capacity
.idleCapacity({ thresholdPct: 0.25, withinDays: 30 })
.then((rows) => notificationOutbox.enqueueIdleCapacity(rows))
.catch((error) => console.error('[pig] idle-capacity notification queue failed', error))
.finally(() => {
checkingIdleCapacity = false;
});
};
const idleNotificationTimer = setInterval(checkIdleCapacity, 6 * 60 * 60 * 1_000);
idleNotificationTimer.unref();
checkIdleCapacity();
const holdSweeper = setInterval(
() => {
void capacity
.sweepExpiredHolds()
.then((n) => n > 0 && console.log(`[pig] released ${n} expired capacity hold(s)`))
.catch((error) => console.error('[pig] hold sweep failed', error));
},
5 * 60 * 1000,
);
/**
* Shut down cleanly so that in-flight requests finish and Postgres connections
* are returned rather than left for the server to reap.
*/
function shutdown(signal: string) {
console.log(`[pig] ${signal} received, shutting down`);
clearInterval(holdSweeper);
clearInterval(idleNotificationTimer);
stopSlackNotifications();
stopBuzzNotifications();
stopSync();
server.close(() => process.exit(0));
// Do not hang forever if a connection refuses to drain.
setTimeout(() => process.exit(1), 10_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));