Skip to content

fix(api): GET /stats returns 500 whenever sourceId is omitted - #4470

Merged
Yeraze merged 2 commits into
Yeraze:mainfrom
rancur:fix/stats-cross-source-sentinel
Aug 1, 2026
Merged

fix(api): GET /stats returns 500 whenever sourceId is omitted#4470
Yeraze merged 2 commits into
Yeraze:mainfrom
rancur:fix/stats-cross-source-sentinel

Conversation

@rancur

@rancur rancur commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The bug

GET /api/stats returns HTTP 500 / STATS_FAILED for any caller that does not pass sourceId.

routes/dataExchangeRoutes.ts scopes three of its four queries with statsSourceId ?? ALL_SOURCES, but hands the fourth a bare statsSourceId:

const messageCount  = await databaseService.messages.getMessageCount(statsSourceId ?? ALL_SOURCES);
const nodeCount     = await databaseService.nodes.getNodeCount(statsSourceId ?? ALL_SOURCES);
const channelCount  = await databaseService.channels.getChannelCount(statsSourceId ?? ALL_SOURCES);
const messagesByDay = await databaseService.getMessagesByDayAsync(7, statsSourceId);   // <-- undefined

With sourceId omitted that argument is undefined, which BaseRepository.withSourceScope rejects by design:

withSourceScope: sourceId is required. Pass a concrete sourceId, or the
ALL_SOURCES sentinel for an intentional cross-source query. Omitting sourceId
used to silently return rows from every source (data leak).

The throw is caught by the route's catch and surfaced as a generic 500, so the endpoint fails with no hint of the cause. The comment immediately above the block states the intended behaviour — "intentional cross-source: stats totals span all sources when no sourceId is specified" — so this is a missed conversion, not a deliberate restriction. Only the ?sourceId=... path ever worked; the aggregate path was dead.

Why the type checker did not catch it

DatabaseService.getMessagesByDayAsync declared sourceId?: string, narrower than the SourceScope that its own messagesRepo.getMessagesByDay accepts. Passing the ALL_SOURCES symbol would not have compiled, so the correct call was not even expressible from the service. Widening the service signature to SourceScope fixes the leaky abstraction and keeps the service in step with the repository layer.

Changes

  • routes/dataExchangeRoutes.ts — pass ALL_SOURCES when no sourceId is supplied, matching the three sibling calls.
  • services/database.ts — widen getMessagesByDayAsync's sourceId to SourceScope.
  • routes/dataExchangeRoutes.test.ts — regression test for the bare /stats call, asserting all four counts receive the sentinel. The existing test only exercised /stats?sourceId=..., which is why this went unnoticed.

Verification

  • Reproduced on a live 4.13.0 deployment: GET /api/stats → 500 STATS_FAILED, GET /api/stats?sourceId=<id> → 200. After the change, the bare call returns 200 with correct cross-source totals (message/node/channel counts, and a non-empty messagesByDay).
  • The new test fails without the route change (expected "vi.fn()" to be called with arguments: [ 7, Symbol(ALL_SOURCES) ]) and passes with it.
  • npx vitest run src/server/routes/dataExchangeRoutes.test.ts → 7 passed.
  • npx tsc --noEmit → clean.

No behaviour change for callers that already pass sourceId.

/api/stats scopes three of its four queries with `statsSourceId ?? ALL_SOURCES`
but hands the fourth, getMessagesByDayAsync, a bare `statsSourceId`. When the
caller omits sourceId that is `undefined`, which `BaseRepository.withSourceScope`
rejects by design:

    withSourceScope: sourceId is required. Pass a concrete sourceId, or the
    ALL_SOURCES sentinel for an intentional cross-source query.

So the endpoint throws and returns 500 / STATS_FAILED for every caller that does
not pass sourceId, even though the route comment states the intent explicitly:
"stats totals span all sources when no sourceId is specified". Only the
source-scoped call path worked; the aggregate path was dead.

Why the type system did not catch it: DatabaseService.getMessagesByDayAsync
declared `sourceId?: string`, narrower than the `SourceScope` its own repository
accepts, so passing the ALL_SOURCES symbol would not have compiled. Widening the
service signature to SourceScope makes the correct call expressible and keeps
the service in step with the repository layer.

- route: pass ALL_SOURCES when no sourceId is supplied, matching the sibling calls
- service: widen getMessagesByDayAsync's sourceId to SourceScope
- test: cover the bare /stats call, asserting all four counts receive the
  sentinel. The existing test only exercised /stats?sourceId=..., which is why
  this went unnoticed.

Verified against a live 4.13.0 instance: GET /api/stats returned 500, and
returns 200 with correct cross-source totals after the change. `tsc --noEmit`
clean; the new test fails without the route fix and passes with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Yeraze

Yeraze commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Review

Fixes a real, reachable bug with an appropriately minimal diff. I verified the description's claims against the tree rather than taking them on trust:

  • ALL_SOURCES is already imported at dataExchangeRoutes.ts:4, so the route change compiles as-is
  • SourceScope is already imported at services/database.ts:71 — no new import needed there either
  • messagesRepo.getMessagesByDay already accepts SourceScope (messages.ts:468), so the "leaky abstraction" framing is accurate: the service signature genuinely was the narrower of the two
  • SourceScope = string | typeof ALL_SOURCES (base.ts:36), so widening is strictly additive
  • The route is the only non-test caller, so nothing else is affected

Diagnosis and fix both look right.

Two suggestions

1. Hoist the scope. The expression appears four times and the bug is precisely that one copy drifted. One binding makes that impossible:

const scope = statsSourceId ?? ALL_SOURCES;   // intentional cross-source
const messageCount  = await databaseService.messages.getMessageCount(scope);
const nodeCount     = await databaseService.nodes.getNodeCount(scope);
const channelCount  = await databaseService.channels.getChannelCount(scope);
const messagesByDay = await databaseService.getMessagesByDayAsync(7, scope);

2. Make the parameter required rather than just wider. The sibling repo methods take sourceId: SourceScope — required (messages.ts:240) — which is exactly why those three were correct by construction. Leaving getMessagesByDayAsync(days, sourceId?: SourceScope) optional keeps undefined expressible, so the identical bug can recur and will again surface as a runtime 500 rather than a compile error. Widening fixed expressibility of ALL_SOURCES; it didn't remove the hazard. Since the route is the only caller, tightening it is cheap.

Together these make the bug unrepresentable instead of fixed once.

One thing worth confirming

Because the bare path was dead, this makes cross-source aggregates reachable for the first time. requirePermission('dashboard', 'read') is invoked without sourceIdFrom, so it isn't source-scoped — a user whose dashboard grant is scoped to a single source will now receive totals spanning every source.

That intent is already established by the three sibling calls and the code comment, so this PR doesn't introduce it. But it's the change that makes it observable, and it deserves a conscious yes rather than arriving as a side effect.

Tests

Good. The regression test asserts all four counts receive the sentinel, and it failing without the route change with the exact expected diff is the right evidence.

On convention: the file uses the vi.mock('../../services/database.js') pattern rather than createRouteTestApp, which CLAUDE.md asks of changed route tests. I'd leave it here. This test asserts which argument reached the DB layer, which needs a mock; the harness runs real SQL and verifies outcomes, so it couldn't express this assertion as directly. Converting the file for a one-line fix would also be disproportionate.

Risk

Low. No behavior change for callers passing sourceId; the only newly-live path is the one that was returning 500.

Approve — suggestions 1 and 2 are worth folding in, but neither is blocking.

…boundary

Follow-ups to the fix in this PR, applied per review.

Hoist `statsSourceId ?? ALL_SOURCES` into a single `statsScope` binding. The
bug being fixed here was one of four copies of that expression drifting from
the other three; with one binding that cannot recur.

Make `sourceId` REQUIRED on getMessagesByDayAsync and on the repository's
getMessagesByDay, matching the sibling count methods (getMessageCount,
getNodeCount, getChannelCount) which take `sourceId: SourceScope` — and which
is why those three were correct by construction. Widening the type to
SourceScope made ALL_SOURCES expressible; leaving the parameter optional kept
`undefined` expressible too, so the same omission would still compile and fail
at runtime with a 500. Now it is a compile error.

The route is the only non-test caller of either method, so this narrows no
existing usage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9
@Yeraze

Yeraze commented Aug 1, 2026

Copy link
Copy Markdown
Owner

@rancur — I've pushed the two suggestions from my review directly to this branch as 0446a9f8, since maintainerCanModify is enabled. Flagging it so the commit isn't a surprise; revert or reshape it freely, it's your PR.

1. Hoisted the scope into a single statsScope binding. The bug you fixed was one of four copies of statsSourceId ?? ALL_SOURCES drifting from the other three — with one binding that can't recur.

2. Made sourceId required, on both getMessagesByDayAsync and the repository's getMessagesByDay. This is the part I think matters most: widening the type to SourceScope made ALL_SOURCES expressible, but leaving the parameter optional kept undefined expressible too — so the exact omission this PR fixes would still compile and still fail at runtime with a 500. Required makes it a compile error instead.

That also brings both in line with the sibling count methods (getMessageCount, getNodeCount, getChannelCount all take sourceId: SourceScope), which is precisely why those three were correct by construction and this one wasn't. The route is the only non-test caller of either method, so nothing existing is narrowed.

Verified on the branch: tsc --noEmit clean, lint:ci clean with no baseline growth, full suite success: true — 12,772 passed / 0 failed. (Lower total than main simply because this branch predates a few PRs merged today.) Your regression test still passes unchanged.

One thing from my review I did not act on, because it's a judgment call rather than a defect: this PR makes the cross-source aggregate path reachable for the first time, and requirePermission('dashboard', 'read') isn't source-scoped here — so a user whose dashboard grant covers one source will now see totals spanning all of them. Three of the four counts already intended that, so it isn't introduced here, but it's worth a deliberate yes from a maintainer rather than arriving as a side effect of the fix.

@Yeraze
Yeraze merged commit 0687d6c into Yeraze:main Aug 1, 2026
16 checks passed
Yeraze added a commit that referenced this pull request Aug 1, 2026
Twelve changes since rc4, across all five version files (package.json,
package-lock.json, helm Chart.yaml, desktop/package.json, tauri.conf.json).

Highlights:
- Remote admin no longer holds an HTTP request across mesh round-trips
  (#4482#4485, #4486): commands, session-passkey acquisition, and remote
  config import all return 202 + an operation id and complete in the
  background, fixing upstream 502s behind a reverse proxy. Remote
  ignore/unignore gained routing-ACK confirmation.
- Admin messages now use the node's configured LoRa hop limit instead of a
  hardcoded 3 (#4479), so nodes further than 3 hops away are administrable.
- GET /stats no longer 500s when sourceId is omitted (#4470).
- MeshCore fixes: anon auth banner, channel-sync data loss, reply/trigger
  ordering (#4491), channel view scroll/delete (#4488), hop/route/scope
  preserved in channel history (#4475).
- Navigation unified across Meshtastic/MeshCore per-source views (#4481,
  #4484).

Carries the system-test label: this is the first release build to exercise
the async remote-admin paths against real hardware — the Configuration
Import leg covers /import-config, which has not run on a device until now.


Claude-Session: https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants