Skip to content
Merged
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
1 change: 1 addition & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
112 changes: 112 additions & 0 deletions server/src/app.ts
Original file line number Diff line number Diff line change
@@ -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;
};
102 changes: 2 additions & 100 deletions server/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 = () => {
Expand Down
8 changes: 8 additions & 0 deletions server/src/middleware/apiNotFound.ts
Original file line number Diff line number Diff line change
@@ -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'
});
};
56 changes: 56 additions & 0 deletions tests/integration/api/notFound.spec.ts
Original file line number Diff line number Diff line change
@@ -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'),
'<!doctype html><html><body>SparseTree test client</body></html>'
);
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');
});
});
Loading