diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md index 64db6f7b..88bfb297 100644 --- a/.changelog/NEXT.md +++ b/.changelog/NEXT.md @@ -50,6 +50,7 @@ ## Fixed +- **[issue-158] Unknown API routes now return JSON errors** — Requests to unrecognized `/api` paths receive a stable 404 error envelope instead of the browser app's HTML, while client-side navigation continues to use the SPA fallback. - Search results now keep their alphabetical ordering. The batch person-loader (`getPersonsBatch`) re-orders rows back to the requested order, fixing a regression where SQLite's `WHERE person_id IN (...)` returned rows in table order and silently discarded the search query's `ORDER BY display_name` (so the default, unsorted search view appeared randomly ordered). - Platform comparison now treats equivalent place spellings as matches: "Dallas, Texas, USA" vs "Dallas, Texas, United States" (and U.S.A. / United States of America / state abbreviations like TX vs Texas, UK vs United Kingdom, etc.) — no longer flagged as `different`. Place containment is now suffix-based, so "Texas" no longer falsely matches "Texarkana" - Platform comparison now treats equivalent date formats as matches (e.g., "1979-07-31" vs "31 JUL 1979") diff --git a/docs/api.md b/docs/api.md index b476efcd..d14d0779 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,6 +1,6 @@ # API Reference -Backend runs on port 6374 by default. All endpoints return JSON with `{ success: boolean, data?: T, error?: string }`. +Backend runs on port 6374 by default. Application JSON endpoints use the envelope `{ success: boolean, data?: T, error?: string }`. Explicitly documented file downloads and server-sent event (SSE) streams retain their native payloads, while `/api/health` returns its operational status object directly. Requests that do not match an `/api` endpoint return HTTP 404 with `{ success: false, error: "API route not found" }`. ## Databases (Roots) diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 00000000..33defc9d --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,112 @@ +import cors from 'cors'; +import express, { type Express } from 'express'; +import { existsSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { apiNotFound } from './middleware/apiNotFound.js'; +import { errorHandler } from './middleware/errorHandler.js'; +import { requestLogger } from './middleware/requestLogger.js'; +import { requestTimeout } from './middleware/requestTimeout.js'; +import { aiDiscoveryRouter } from './routes/ai-discovery.routes.js'; +import { ancestryHintsRouter } from './routes/ancestry-hints.routes.js'; +import { ancestryTreeRouter } from './routes/ancestry-tree.routes.js'; +import { ancestryUpdateRouter } from './routes/ancestry-update.routes.js'; +import { auditorRouter } from './routes/auditor.routes.js'; +import { augmentationRouter } from './routes/augmentation.routes.js'; +import { browserRouter } from './routes/browser.routes.js'; +import { databaseRoutes } from './routes/database.routes.js'; +import { deathsRouter } from './routes/deaths.routes.js'; +import { exportRoutes } from './routes/export.routes.js'; +import { favoritesRouter } from './routes/favorites.routes.js'; +import { gedcomRouter } from './routes/gedcom.routes.js'; +import { genealogyProviderRouter } from './routes/genealogy-provider.routes.js'; +import { indexerRoutes } from './routes/indexer.routes.js'; +import { integrityRouter } from './routes/integrity.routes.js'; +import { mapRouter } from './routes/map.routes.js'; +import { pathRoutes } from './routes/path.routes.js'; +import { personRoutes } from './routes/person.routes.js'; +import { providerRouter } from './routes/provider.routes.js'; +import { searchRoutes } from './routes/search.routes.js'; +import { syncRouter } from './routes/sync.routes.js'; +import { testRunnerRouter } from './routes/test-runner.routes.js'; +import { initAIToolkit } from './services/ai-toolkit.service.js'; +import { logger } from './lib/logger.js'; + +const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:6373'; +const corsOrigin = CORS_ORIGIN.includes(',') + ? CORS_ORIGIN.split(',').map(origin => { + const trimmed = origin.trim(); + new URL(trimmed); + return trimmed; + }) + : CORS_ORIGIN; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CLIENT_DIST = path.join(__dirname, '..', '..', 'client', 'dist'); +const CLIENT_DIST_ALT = path.join(__dirname, '..', '..', '..', 'client', 'dist'); + +const findClientDist = (): string => ( + existsSync(CLIENT_DIST) ? CLIENT_DIST : CLIENT_DIST_ALT +); + +export interface CreateAppOptions { + aiToolkit?: { + mountRoutes: (app: Express) => void; + }; + clientDist?: string; +} + +export const createApp = ({ + aiToolkit = initAIToolkit(null), + clientDist = findClientDist() +}: CreateAppOptions = {}): Express => { + const app = express(); + + app.use(cors({ origin: corsOrigin })); + app.use(express.json()); + app.use(requestTimeout); + app.use(requestLogger); + + aiToolkit.mountRoutes(app); + + app.use('/api/databases', databaseRoutes); + app.use('/api/persons', personRoutes); + app.use('/api/search', searchRoutes); + app.use('/api/path', pathRoutes); + app.use('/api/indexer', indexerRoutes); + app.use('/api/export', exportRoutes); + app.use('/api/browser', browserRouter); + app.use('/api/augment', augmentationRouter); + app.use('/api/genealogy-providers', genealogyProviderRouter); + app.use('/api/scrape-providers', providerRouter); + app.use('/api/gedcom', gedcomRouter); + app.use('/api/sync', syncRouter); + app.use('/api/favorites', favoritesRouter); + app.use('/api/ancestry-tree', ancestryTreeRouter); + app.use('/api/ai-discovery', aiDiscoveryRouter); + app.use('/api/test-runner', testRunnerRouter); + app.use('/api/integrity', integrityRouter); + app.use('/api/ancestry-hints', ancestryHintsRouter); + app.use('/api/ancestry-update', ancestryUpdateRouter); + app.use('/api/map', mapRouter); + app.use('/api/audit', auditorRouter); + app.use('/api/deaths', deathsRouter); + + app.get('/api/health', (_req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); + }); + + app.use('/api', apiNotFound); + + if (existsSync(clientDist)) { + app.use(express.static(clientDist)); + app.get('/{*splat}', (_req, res) => { + res.sendFile(path.join(clientDist, 'index.html')); + }); + logger.ok('server', 'Serving built UI from client/dist'); + } + + app.use(errorHandler); + + return app; +}; diff --git a/server/src/index.ts b/server/src/index.ts index 6ae92efb..64311c18 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,112 +1,14 @@ -import express from 'express'; -import cors from 'cors'; -import { existsSync } from 'fs'; import { createServer } from 'http'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { initAIToolkit } from './services/ai-toolkit.service.js'; -import { databaseRoutes } from './routes/database.routes.js'; -import { personRoutes } from './routes/person.routes.js'; -import { searchRoutes } from './routes/search.routes.js'; -import { pathRoutes } from './routes/path.routes.js'; -import { indexerRoutes } from './routes/indexer.routes.js'; -import { exportRoutes } from './routes/export.routes.js'; -import { browserRouter } from './routes/browser.routes.js'; import { browserService } from './services/browser.service.js'; -import { augmentationRouter } from './routes/augmentation.routes.js'; -import { genealogyProviderRouter } from './routes/genealogy-provider.routes.js'; -import { providerRouter } from './routes/provider.routes.js'; -import { gedcomRouter } from './routes/gedcom.routes.js'; -import { syncRouter } from './routes/sync.routes.js'; -import { favoritesRouter } from './routes/favorites.routes.js'; -import { ancestryTreeRouter } from './routes/ancestry-tree.routes.js'; -import { aiDiscoveryRouter } from './routes/ai-discovery.routes.js'; -import { testRunnerRouter } from './routes/test-runner.routes.js'; -import { integrityRouter } from './routes/integrity.routes.js'; -import { ancestryHintsRouter } from './routes/ancestry-hints.routes.js'; -import { ancestryUpdateRouter } from './routes/ancestry-update.routes.js'; -import { mapRouter } from './routes/map.routes.js'; -import { auditorRouter } from './routes/auditor.routes.js'; -import { deathsRouter } from './routes/deaths.routes.js'; import { runMigrations } from './db/migrations/index.js'; -import { errorHandler } from './middleware/errorHandler.js'; -import { requestLogger } from './middleware/requestLogger.js'; -import { requestTimeout } from './middleware/requestTimeout.js'; import { logger } from './lib/logger.js'; +import { createApp } from './app.js'; -const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:6373'; -const corsOrigin = CORS_ORIGIN.includes(',') - ? CORS_ORIGIN.split(',').map(o => { - const trimmed = o.trim(); - new URL(trimmed); // throws on invalid origin - return trimmed; - }) - : CORS_ORIGIN; - -const app = express(); +const app = createApp(); const httpServer = createServer(app); const PORT = parseInt(process.env.PORT || '6374', 10); -// Middleware -app.use(cors({ origin: corsOrigin })); -app.use(express.json()); -app.use(requestTimeout); -app.use(requestLogger); - -// Initialize AI Toolkit with routes for providers, runs, and prompts -const aiToolkit = initAIToolkit(null); -aiToolkit.mountRoutes(app); - -// Routes -app.use('/api/databases', databaseRoutes); -app.use('/api/persons', personRoutes); -app.use('/api/search', searchRoutes); -app.use('/api/path', pathRoutes); -app.use('/api/indexer', indexerRoutes); -app.use('/api/export', exportRoutes); -app.use('/api/browser', browserRouter); -app.use('/api/augment', augmentationRouter); -app.use('/api/genealogy-providers', genealogyProviderRouter); -app.use('/api/scrape-providers', providerRouter); -app.use('/api/gedcom', gedcomRouter); -app.use('/api/sync', syncRouter); -app.use('/api/favorites', favoritesRouter); -app.use('/api/ancestry-tree', ancestryTreeRouter); -app.use('/api/ai-discovery', aiDiscoveryRouter); -app.use('/api/test-runner', testRunnerRouter); -app.use('/api/integrity', integrityRouter); -app.use('/api/ancestry-hints', ancestryHintsRouter); -app.use('/api/ancestry-update', ancestryUpdateRouter); -app.use('/api/map', mapRouter); -app.use('/api/audit', auditorRouter); -app.use('/api/deaths', deathsRouter); - -// Health check -app.get('/api/health', (_req, res) => { - res.json({ status: 'ok', timestamp: new Date().toISOString() }); -}); - -// Serve built client UI in production -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -// From src/index.ts: up to server/, then to project root, then client/dist -const CLIENT_DIST = path.join(__dirname, '..', '..', 'client', 'dist'); -// From dist/index.js (compiled): up to server/, then to project root, then client/dist -const CLIENT_DIST_ALT = path.join(__dirname, '..', '..', '..', 'client', 'dist'); -const clientDist = existsSync(CLIENT_DIST) ? CLIENT_DIST : CLIENT_DIST_ALT; - -if (existsSync(clientDist)) { - app.use(express.static(clientDist)); - app.get('/{*splat}', (_req, res) => { - res.sendFile(path.join(clientDist, 'index.html')); - }); - logger.ok('server', 'Serving built UI from client/dist'); -} - -// Error handling -app.use(errorHandler); - const HOST = process.env.HOST || 'localhost'; const shutdown = () => { diff --git a/server/src/middleware/apiNotFound.ts b/server/src/middleware/apiNotFound.ts new file mode 100644 index 00000000..323ea30f --- /dev/null +++ b/server/src/middleware/apiNotFound.ts @@ -0,0 +1,8 @@ +import type { RequestHandler } from 'express'; + +export const apiNotFound: RequestHandler = (_req, res) => { + res.status(404).json({ + success: false, + error: 'API route not found' + }); +}; diff --git a/tests/integration/api/notFound.spec.ts b/tests/integration/api/notFound.spec.ts new file mode 100644 index 00000000..70d68a09 --- /dev/null +++ b/tests/integration/api/notFound.spec.ts @@ -0,0 +1,56 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import path from 'path'; +import type { Express } from 'express'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createApp } from '../../../server/src/app.js'; + +describe('terminal API boundary', () => { + let app: Express; + let clientDist: string; + + beforeAll(() => { + clientDist = mkdtempSync(path.join(tmpdir(), 'sparsetree-client-')); + writeFileSync( + path.join(clientDist, 'index.html'), + 'SparseTree test client' + ); + app = createApp({ + aiToolkit: { mountRoutes: () => undefined }, + clientDist + }); + }); + + afterAll(() => { + rmSync(clientDist, { recursive: true, force: true }); + }); + + it.each([ + ['get', '/api/persons/missing/typo/extra'], + ['post', '/api/missing'] + ] as const)('returns the JSON error envelope for unknown %s routes', async (method, url) => { + const response = await request(app)[method](url).expect(404); + + expect(response.type).toBe('application/json'); + expect(response.body).toEqual({ + success: false, + error: 'API route not found' + }); + }); + + it('keeps registered API routes ahead of the terminal boundary', async () => { + const response = await request(app).get('/api/health').expect(200); + + expect(response.body.status).toBe('ok'); + }); + + it('keeps non-API navigation behind the SPA fallback', async () => { + const response = await request(app) + .get('/people/interesting-ancestor') + .expect(200); + + expect(response.type).toBe('text/html'); + expect(response.text).toContain('SparseTree test client'); + }); +});