Skip to content

Commit af86c5c

Browse files
Yerazeclaude
andcommitted
feat(server): add ok/fail response-envelope helpers and convert 2 exemplar routes (#3962 task 1.5)
Introduces src/server/utils/apiResponse.ts with ok<T>(res, data?) and fail(res, status, code, message, extra?) matching the ApiService wire contract. Converts ignoredNodeRoutes (errors → fail(); bare-array success left unchanged) and dataExchangeRoutes (import success → ok(res); three bare errors → fail() with STATS_FAILED/EXPORT_FAILED/IMPORT_FAILED codes). Deletes the now-unused local ApiErrorResponse interface. Test expectations updated to assert the full {success, error, code} envelope. CLAUDE.md documents the helper and the "does-not-unwrap-data" gotcha. REMEDIATION_EPIC.md marks 1.3 and 1.4 done with phase-log entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n
1 parent 5997b41 commit af86c5c

8 files changed

Lines changed: 155 additions & 43 deletions

File tree

CLAUDE.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,22 @@
3434
- **Never push directly to main. Always use a branch.**
3535
- After bulk find-and-replace or sed, verify modified functions have correct `async`/`await` signatures. Route handlers and callbacks need `async` if `await` was added inside.
3636

37+
### Response envelope
38+
API handlers use a shared envelope helper — `src/server/utils/apiResponse.ts`:
39+
- Success: `ok(res, data)``{ success: true, data }` (omit `data` for `{ success: true }`).
40+
- Error: `fail(res, status, code, message, extra?)``{ success: false, error, code, ...extra }`.
41+
42+
**New or modified handlers must use these.** `code` is a SCREAMING_SNAKE machine
43+
code; reuse an existing one where it fits.
44+
45+
**Gotcha:** the frontend `ApiService.request()` returns the raw JSON body and
46+
does **not** unwrap `data`. So `ok(res, x)` is only correct for handlers that
47+
already return `{ success: true, data }` — converting a bare-payload handler
48+
(`res.json(array)`) breaks its consumer. `fail()` is always safe: `ApiService`
49+
reads only `error`/`code`/`retryAfterSeconds` and ignores `success` on errors.
50+
Existing bare-`{error}` handlers convert opportunistically as they're touched
51+
(Phases 2/4); this is not a mass conversion.
52+
3753
## Multi-Source Architecture (4.x)
3854

3955
MeshMonitor 4.x supports **N concurrent Meshtastic node connections** ("sources"). Pre-4.0 code that referenced a singleton `meshtasticManager` is now a `@deprecated` JSDoc-tagged compatibility shim at the bottom of `src/server/meshtasticManager.ts` — IDEs will strikethrough usages but `tsc` does not error.

docs/internal/dev-notes/REMEDIATION_EPIC.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ Each unchecked phase = one worktree → architect spec → implementation → re
2222
- [x] **0.5**`@typescript-eslint/no-floating-promises` as `error` for non-test code; violations fixed or explicitly `void`/`.catch()`ed.
2323
- [x] **1.1**`withSourceScope` fails closed: omitting `sourceId` throws; explicit `ALL_SOURCES` sentinel for the documented global-by-design consumers (channel decryption, estimated positions, automations); full call-site audit.
2424
- [x] **1.2** — Type-check the tests: `tsconfig.tests.json` (full strict — see phase log deviation), wired into CI non-blocking first; flip to blocking once clean.
25-
- [ ] **1.3** — Integration-grade route-test harness: in-memory SQLite + real Express app with real `requirePermission`/session/auth wiring; 3–5 representative route test files converted (source-scoped permission coverage); no mass conversion.
26-
- [ ] **1.4** — Lint ratchets: `no-explicit-any``error` with checked-in baseline; forbid raw `fetch(` in `src/components/**`/`src/pages/**` (baselined); `react-hooks/exhaustive-deps` + `prefer-const``error`.
25+
- [x] **1.3** — Integration-grade route-test harness: in-memory SQLite + real Express app with real `requirePermission`/session/auth wiring; 3–5 representative route test files converted (source-scoped permission coverage); no mass conversion.
26+
- [x] **1.4** — Lint ratchets: `no-explicit-any``error` with checked-in baseline; forbid raw `fetch(` in `src/components/**`/`src/pages/**` (baselined); `react-hooks/exhaustive-deps` + `prefer-const``error`.
2727
- [ ] **1.5** — Response-envelope convention: `ok(res, data)` / `fail(res, status, code, msg)` helper for the `{ success, error, code }` envelope; documented in CLAUDE.md; new/modified handlers must use it.
2828
- [ ] **1.6** — Schema-drift tripwire: CI test diffing `createTables()` schema vs full migration replay (001→latest), normalized `sqlite_master`, fail on divergence.
2929

@@ -46,3 +46,5 @@ Record per-phase: PR link, deviations from plan, follow-ups.
4646
- **1.1** — PR #3976 merged (ALL_SOURCES unique-symbol sentinel; runtime throw + Tier-2 required params; 3 real leaks fixed: server.ts getNodeCount refresh, deleteNode→deleteNeighborInfoInvolvingNode, meshtasticManager pending-DM fetch). Review-loop correction: implementer's repo-body `?? ALL_SOURCES` normalization (silent fail-open) reverted; explicit per-call-site decisions instead. CI: Quick Tests outgrew its 15-min cap (#3385 redux) → raised to 25 min in this PR.
4747
**Follow-up findings (1.1 architect §10, epic backlog):** hand-rolled fail-open `if (sourceId)` filters bypass the helper — all of `meshcore.ts`, `notifications.ts` subscriptions, parts of `channels.ts`, `nodes.ts:getAllNodesSqlite`; legacy `*Sync`/`*Sqlite` twins partially covered (Phase 3.4 deletes them); facade `getNode(nodeNum)` single-source-assumption chain (revisit with Phase 2).
4848
- **1.2** — PR TBD (tsconfig.tests.json + `typecheck:tests` script + non-blocking CI steps in ci.yml/pr-tests.yml). Deviations: (a) kept FULL strict incl. noImplicitAny — plan's "noImplicitAny off" injects ~50 false-positive errors into prod src via evolving-any inference; (b) top-level `tests/` dir NOT included — its files pull @types/node into the program and poison frontend inference (~60 spurious errors). Baseline: 283 errors, all in src test files, 0 prod. Flip-to-blocking when count reaches 0 (burndown is a follow-up; top ~8 files hold ~150 errors).
49+
- **1.3** — PR #3986 merged (createRouteTestApp() real-middleware harness + 4 conversions). Ride-alongs: real prod bug fixed (v1/messages GET 500'd without sourceId under fail-closed — missing ?? ALL_SOURCES) + harness anonymous-user race fix. 3 CodeQL alerts on the test fixture left open for human triage (recommend dismiss as used-in-tests).
50+
- **1.4** — PR #3989 merged (count-based lint ratchet, eslint-baseline.json 410 files/2,515 violations; no-explicit-any/exhaustive-deps/prefer-const → error; raw-fetch ban in components/pages; CI lint now BLOCKING — closes the 0.5 deviation). Census correction: 727 pre-existing errors (468 config false-positives fixed at config level, not baselined).

src/server/routes/dataExchangeRoutes.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ describe('GET /stats', () => {
5353
mockDb.messages.getMessageCount.mockRejectedValue(new Error('boom'));
5454
const res = await request(app).get('/stats');
5555
expect(res.status).toBe(500);
56-
expect(res.body.error).toBe('Failed to fetch stats');
56+
expect(res.body).toMatchObject({ success: false, error: 'Failed to fetch stats', code: 'STATS_FAILED' });
5757
});
5858
});
5959

@@ -69,7 +69,7 @@ describe('POST /export', () => {
6969
mockDb.exportDataAsync.mockRejectedValue(new Error('fail'));
7070
const res = await request(app).post('/export').send({});
7171
expect(res.status).toBe(500);
72-
expect(res.body.error).toBe('Failed to export data');
72+
expect(res.body).toMatchObject({ success: false, error: 'Failed to export data', code: 'EXPORT_FAILED' });
7373
});
7474
});
7575

@@ -87,6 +87,6 @@ describe('POST /import', () => {
8787
mockDb.importDataAsync.mockRejectedValue(new Error('fail'));
8888
const res = await request(app).post('/import').send({});
8989
expect(res.status).toBe(500);
90-
expect(res.body.error).toBe('Failed to import data');
90+
expect(res.body).toMatchObject({ success: false, error: 'Failed to import data', code: 'IMPORT_FAILED' });
9191
});
9292
});

src/server/routes/dataExchangeRoutes.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { requirePermission, requireAdmin } from '../auth/authMiddleware.js';
33
import databaseService from '../../services/database.js';
44
import { ALL_SOURCES } from '../../db/repositories/index.js';
55
import { logger } from '../../utils/logger.js';
6+
import { ok, fail } from '../utils/apiResponse.js';
67

78
const router: Router = Router();
89

@@ -23,7 +24,7 @@ router.get('/stats', requirePermission('dashboard', 'read'), async (req: Request
2324
});
2425
} catch (error) {
2526
logger.error('Error fetching stats:', error);
26-
res.status(500).json({ error: 'Failed to fetch stats' });
27+
fail(res, 500, 'STATS_FAILED', 'Failed to fetch stats');
2728
}
2829
});
2930

@@ -33,18 +34,18 @@ router.post('/export', requireAdmin(), async (_req: Request, res: Response) => {
3334
res.json(data);
3435
} catch (error) {
3536
logger.error('Error exporting data:', error);
36-
res.status(500).json({ error: 'Failed to export data' });
37+
fail(res, 500, 'EXPORT_FAILED', 'Failed to export data');
3738
}
3839
});
3940

4041
router.post('/import', requireAdmin(), async (req: Request, res: Response) => {
4142
try {
4243
const data = req.body;
4344
await databaseService.importDataAsync(data);
44-
res.json({ success: true });
45+
ok(res);
4546
} catch (error) {
4647
logger.error('Error importing data:', error);
47-
res.status(500).json({ error: 'Failed to import data' });
48+
fail(res, 500, 'IMPORT_FAILED', 'Failed to import data');
4849
}
4950
});
5051

src/server/routes/ignoredNodeRoutes.test.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ describe('GET /', () => {
4848
const res = await request(app).get('/');
4949

5050
expect(res.status).toBe(400);
51-
expect(res.body.code).toBe('MISSING_SOURCE_ID');
51+
expect(res.body).toMatchObject({ success: false, code: 'MISSING_SOURCE_ID', error: 'No permitted source' });
52+
expect(typeof res.body.details).toBe('string');
5253
});
5354

5455
it('returns 500 on database error', async () => {
@@ -57,6 +58,7 @@ describe('GET /', () => {
5758
const res = await request(app).get('/');
5859

5960
expect(res.status).toBe(500);
61+
expect(res.body).toMatchObject({ success: false, code: 'INTERNAL_ERROR' });
6062
});
6163
});
6264

@@ -78,7 +80,8 @@ describe('DELETE /:nodeId', () => {
7880
const res = await request(app).delete('/!ZZZZ');
7981

8082
expect(res.status).toBe(400);
81-
expect(res.body.code).toBe('INVALID_NODE_ID');
83+
expect(res.body).toMatchObject({ success: false, code: 'INVALID_NODE_ID', error: 'Invalid nodeId format' });
84+
expect(typeof res.body.details).toBe('string');
8285
});
8386

8487
it('returns 400 when source cannot be resolved', async () => {
@@ -87,7 +90,8 @@ describe('DELETE /:nodeId', () => {
8790
const res = await request(app).delete('/!aabbccdd');
8891

8992
expect(res.status).toBe(400);
90-
expect(res.body.code).toBe('MISSING_SOURCE_ID');
93+
expect(res.body).toMatchObject({ success: false, code: 'MISSING_SOURCE_ID', error: 'No permitted source' });
94+
expect(typeof res.body.details).toBe('string');
9195
});
9296

9397
it('succeeds even if setNodeIgnoredAsync throws (node not in nodes table)', async () => {
@@ -106,5 +110,6 @@ describe('DELETE /:nodeId', () => {
106110
const res = await request(app).delete('/!aabbccdd');
107111

108112
expect(res.status).toBe(500);
113+
expect(res.body).toMatchObject({ success: false, code: 'INTERNAL_ERROR' });
109114
});
110115
});

src/server/routes/ignoredNodeRoutes.ts

Lines changed: 11 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,37 +3,26 @@ import { requirePermission } from '../auth/authMiddleware.js';
33
import databaseService from '../../services/database.js';
44
import { logger } from '../../utils/logger.js';
55
import { resolveRequestSourceId } from '../utils/sourceResolver.js';
6-
7-
interface ApiErrorResponse {
8-
error: string;
9-
code: string;
10-
details?: string;
11-
}
6+
import { fail } from '../utils/apiResponse.js';
127

138
const router = Router();
149

1510
router.get('/', requirePermission('nodes', 'read'), async (req: Request, res: Response) => {
1611
try {
1712
const listSourceId = await resolveRequestSourceId(req, 'nodes', 'read');
1813
if (!listSourceId) {
19-
const errorResponse: ApiErrorResponse = {
20-
error: 'No permitted source',
21-
code: 'MISSING_SOURCE_ID',
14+
fail(res, 400, 'MISSING_SOURCE_ID', 'No permitted source', {
2215
details: 'Provide ?sourceId=, or ensure your account has nodes:read on at least one enabled source',
23-
};
24-
res.status(400).json(errorResponse);
16+
});
2517
return;
2618
}
2719
const ignoredNodes = await databaseService.ignoredNodes.getIgnoredNodesAsync(listSourceId);
2820
res.json(ignoredNodes);
2921
} catch (error) {
3022
logger.error('Error fetching ignored nodes:', error);
31-
const errorResponse: ApiErrorResponse = {
32-
error: 'Failed to fetch ignored nodes',
33-
code: 'INTERNAL_ERROR',
23+
fail(res, 500, 'INTERNAL_ERROR', 'Failed to fetch ignored nodes', {
3424
details: error instanceof Error ? error.message : 'Unknown error occurred',
35-
};
36-
res.status(500).json(errorResponse);
25+
});
3726
}
3827
});
3928

@@ -44,23 +33,17 @@ router.delete('/:nodeId', requirePermission('nodes', 'write'), async (req: Reque
4433
const nodeNumStr = nodeId.replace('!', '');
4534

4635
if (!/^[0-9a-fA-F]{8}$/.test(nodeNumStr)) {
47-
const errorResponse: ApiErrorResponse = {
48-
error: 'Invalid nodeId format',
49-
code: 'INVALID_NODE_ID',
36+
fail(res, 400, 'INVALID_NODE_ID', 'Invalid nodeId format', {
5037
details: 'nodeId must be in format !XXXXXXXX (8 hex characters)',
51-
};
52-
res.status(400).json(errorResponse);
38+
});
5339
return;
5440
}
5541

5642
const deleteSourceId = await resolveRequestSourceId(req, 'nodes', 'write');
5743
if (!deleteSourceId) {
58-
const errorResponse: ApiErrorResponse = {
59-
error: 'No permitted source',
60-
code: 'MISSING_SOURCE_ID',
44+
fail(res, 400, 'MISSING_SOURCE_ID', 'No permitted source', {
6145
details: 'Provide ?sourceId=, or ensure your account has nodes:write on at least one enabled source',
62-
};
63-
res.status(400).json(errorResponse);
46+
});
6447
return;
6548
}
6649

@@ -76,12 +59,9 @@ router.delete('/:nodeId', requirePermission('nodes', 'write'), async (req: Reque
7659
res.json({ success: true, nodeNum, sourceId: deleteSourceId });
7760
} catch (error) {
7861
logger.error('Error removing ignored node:', error);
79-
const errorResponse: ApiErrorResponse = {
80-
error: 'Failed to remove ignored node',
81-
code: 'INTERNAL_ERROR',
62+
fail(res, 500, 'INTERNAL_ERROR', 'Failed to remove ignored node', {
8263
details: error instanceof Error ? error.message : 'Unknown error occurred',
83-
};
84-
res.status(500).json(errorResponse);
64+
});
8565
}
8666
});
8767

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { ok, fail } from './apiResponse.js';
3+
import type { Response } from 'express';
4+
5+
function makeMockRes(): Response {
6+
const res = {
7+
json: vi.fn(),
8+
status: vi.fn(),
9+
} as unknown as Response;
10+
// status() must return the same res for chaining
11+
(res.status as ReturnType<typeof vi.fn>).mockReturnValue(res);
12+
(res.json as ReturnType<typeof vi.fn>).mockReturnValue(res);
13+
return res;
14+
}
15+
16+
describe('ok()', () => {
17+
it('wraps a payload under data', () => {
18+
const res = makeMockRes();
19+
const result = ok(res, { a: 1 });
20+
expect(res.json).toHaveBeenCalledWith({ success: true, data: { a: 1 } });
21+
expect(result).toBe(res);
22+
});
23+
24+
it('emits { success: true } with no data key when called without a second argument', () => {
25+
const res = makeMockRes();
26+
ok(res);
27+
const body = (res.json as ReturnType<typeof vi.fn>).mock.calls[0][0] as Record<string, unknown>;
28+
expect(body).toEqual({ success: true });
29+
expect('data' in body).toBe(false);
30+
});
31+
32+
it('includes data: null when null is passed (null is a real payload, not absence)', () => {
33+
const res = makeMockRes();
34+
ok(res, null);
35+
expect(res.json).toHaveBeenCalledWith({ success: true, data: null });
36+
});
37+
38+
it('returns the res object for chaining', () => {
39+
const res = makeMockRes();
40+
expect(ok(res, 42)).toBe(res);
41+
});
42+
});
43+
44+
describe('fail()', () => {
45+
it('sets the status and emits the error envelope', () => {
46+
const res = makeMockRes();
47+
const result = fail(res, 400, 'BAD', 'nope');
48+
expect(res.status).toHaveBeenCalledWith(400);
49+
expect(res.json).toHaveBeenCalledWith({ success: false, error: 'nope', code: 'BAD' });
50+
expect(result).toBe(res);
51+
});
52+
53+
it('spreads extra fields into the top-level body', () => {
54+
const res = makeMockRes();
55+
fail(res, 500, 'X', 'boom', { details: 'd', retryAfterSeconds: 3 });
56+
expect(res.json).toHaveBeenCalledWith({
57+
success: false,
58+
error: 'boom',
59+
code: 'X',
60+
details: 'd',
61+
retryAfterSeconds: 3,
62+
});
63+
});
64+
65+
it('returns the res object for chaining', () => {
66+
const res = makeMockRes();
67+
expect(fail(res, 500, 'E', 'err')).toBe(res);
68+
});
69+
});

src/server/utils/apiResponse.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { Response } from 'express';
2+
3+
/**
4+
* Standard MeshMonitor success envelope: `{ success: true, data? }`.
5+
*
6+
* IMPORTANT: the frontend `ApiService.request()` returns the raw JSON body and
7+
* does NOT unwrap `data`. Only use `ok()` on a handler that already emits
8+
* `{ success: true, data }` (or `{ success: true }`), or update the consumer in
9+
* the same change. Do NOT convert handlers that return a bare payload
10+
* (`res.json(array)`, `res.json({ deletedCount })`) — that changes the wire shape.
11+
*
12+
* @param res Express response.
13+
* @param data Optional payload placed under `data`. Omit for `{ success: true }`.
14+
*/
15+
export function ok<T>(res: Response, data?: T): Response {
16+
return res.json(data === undefined ? { success: true } : { success: true, data });
17+
}
18+
19+
/**
20+
* Standard MeshMonitor error envelope: `{ success: false, error, code, ...extra }`.
21+
* Matches what `ApiService` parses: `error` (message), `code` (machine code),
22+
* plus optional fields it forwards such as `retryAfterSeconds`.
23+
*
24+
* @param res Express response.
25+
* @param status HTTP status (4xx/5xx).
26+
* @param code SCREAMING_SNAKE machine code (e.g. 'MISSING_SOURCE_ID').
27+
* @param message Human-readable message (becomes `ApiError.message` on the client).
28+
* @param extra Optional additional top-level fields (e.g. `{ details }`,
29+
* `{ retryAfterSeconds }`). Kept minimal.
30+
*/
31+
export function fail(
32+
res: Response,
33+
status: number,
34+
code: string,
35+
message: string,
36+
extra?: Record<string, unknown>,
37+
): Response {
38+
return res.status(status).json({ success: false, error: message, code, ...extra });
39+
}

0 commit comments

Comments
 (0)