Skip to content

refactor(registry)!: MeshCoreManager implements ISourceManager — one unified source registry (#3962)#4004

Merged
Yeraze merged 15 commits into
mainfrom
feature/3962-p2-meshcore-isourcemanager
Jul 8, 2026
Merged

refactor(registry)!: MeshCoreManager implements ISourceManager — one unified source registry (#3962)#4004
Yeraze merged 15 commits into
mainfrom
feature/3962-p2-meshcore-isourcemanager

Conversation

@Yeraze

@Yeraze Yeraze commented Jul 8, 2026

Copy link
Copy Markdown
Owner

Summary

Task 2.1 of the remediation plan (#3962, Phase 2) — the highest-leverage structural change of the phase: one registry, one source-manager interface, no parallel MeshCore world.

What changed

  • MeshCoreManager implements ISourceManager: new sourceType getter, configure()/parameterless start()/stop() lifecycle (config staged on the instance), widened getStatus(sourceName?), setSourceName() (kept fresh on rename), getLocalNodeInfo() → null (documented — MeshCore has no nodeNum).
  • One registry: meshcore sources now register in sourceManagerRegistry; all lifecycle (create/enable/disable/delete/config-change/connect/disconnect) and all ~30 read sites migrated. The load-bearing MeshCore quirk is preserved and now documented: manual /disconnect stops the manager but keeps it registered (routes 404 without it).
  • Type-guard idiom: isMeshCoreManager()/isMeshtasticManager() in sourceManagerTypes.ts replace as (typeof meshtasticManager)[] casts; every meshtastic-assuming enumeration loop is hardened (security/dup-key/dead-node exclude meshcore; low-battery/inactive use the unified enumeration that replaces their explicit dual-registry merge).
  • meshcoreRegistry.ts reduced to a @deprecated shim (delete after one release): get/list/remove delegate to the unified registry, disconnectAll faithfully scoped to meshcore-only; config helpers moved to meshcoreConfig.ts. Zero production callers remain.
  • ensureMeshCoreManagerStarted() helper replaces the create-or-connect recipe that had been copy-pasted at 5 call sites.

Found and fixed by the adversarial review (8 finder angles, 11 verifiers)

Confirmed regression class — by-id lookups: pre-PR, getManager(<meshcore-id>) returned undefined and several Meshtastic-only paths leaned on that as an implicit guard. Post-unification a truthy MeshCoreManager reached sendTraceroute/sendRemoveNode/isNodeInDeviceDb calls → TypeError 500s (one reachable via attacker-controlled request body). Fixed at the shared-resolver altitude with isMeshtasticManager guards restoring exact pre-PR semantics: resolveSourceManager (covers v1 actions), purge-node, dead-nodes, bulk-delete, security scan — with regression tests.

Also from review: shim disconnectAll no longer tears down every source type; connect() keeps pendingConfig in sync; meshcoreRoutes guard now caches the narrowed manager in res.locals (kills a TOCTOU + honors the type-guard rule); touched handlers converted to the fail() envelope; log/doc accuracy nits.

Behavior notes

Behavior-preserving by design, verified per-site. Deliberate calls: VN status stays meshtastic-filtered; security-class scans keep excluding meshcore; graceful-shutdown semantics untouched (follow-up filed).

Verification

  • Full suite (independent orchestrator run, post-fixes): 8266 passed, 0 failed, 0 suite failures (success: true). Typecheck clean; test-typecheck 282 (one below the 283 baseline); npm run lint:ci green.
  • New test files: interface/guard truth tables, unified-registry lifecycle + mixed-protocol enumeration + manual-disconnect invariant (47 tests), shim behavior, by-id regression tests.

Refs #3962

🤖 Generated with Claude Code

https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n

Yeraze and others added 15 commits July 7, 2026 18:11
…e guards + config module (#3962 Ph2)

- Add `implements ISourceManager` to MeshCoreManager with all required members:
  - `get sourceType(): 'meshcore'` discriminant getter
  - `configure(cfg)` stores pendingConfig so parameterless start() can connect
  - `start()` / `stop()` delegating to connect(pendingConfig) / disconnect()
  - `getStatus(sourceName?: string): SourceStatus` (widened from required arg + narrow return)
  - `getLocalNodeInfo(): null` (meshcore has no nodeNum; meshcore-specific code uses getLocalNode())
  - `setSourceName(name)` for the rename-handler path (WP3b call-site)
  - `sourceName` stored in constructor (fallback to sourceId, mirrors meshtastic quirk)
- New src/server/sourceManagerTypes.ts — isMeshCoreManager / isMeshtasticManager type guards
  (sourceType-based, no instanceof, no import cycles; canonical narrowing idiom for codebase)
- New src/server/meshcoreConfig.ts — meshcoreConfigFromSource, virtualNodeConfigFromSource,
  MeshCoreSourceConfig, DEFAULT_VIRTUAL_NODE_PORT moved out of meshcoreRegistry.ts
- New tests: sourceManagerTypes.test.ts (guard truth table over all 4 sourceTypes),
  meshcoreConfig.test.ts (ported from meshcoreRegistry.test.ts), and
  sourceManagerRegistry.meshcore.test.ts (registry lifecycle + manual-disconnect invariant)

Verification: tsc clean; test typecheck 283 errors (at baseline); lint:ci OK;
full suite 8261 pass / 0 fail / 607 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n
…gerRegistry (#3962 Ph2)

MeshCore sources now use sourceManagerRegistry (the shared registry) instead
of the parallel meshcoreManagerRegistry.  Every lifecycle path — startup
auto-connect, source create/enable/disable/delete, config-change restart,
manual connect/disconnect — goes through the create-or-connect recipe on
sourceManagerRegistry.

Key behaviors preserved:
- Manual /disconnect calls manager.disconnect() and KEEPS the manager
  registered (routes 404 without it); only removeManager removes from map
- autoConnect=false at startup skips registration entirely
- Reconnect with changed config: removeManager + fresh MeshCoreManager

The 3 scheduler constructors (TelemetryPoller, RemoteTelemetryScheduler,
RoomSyncScheduler) now receive sourceManagerRegistry and filter internally
with getAllManagers().filter(isMeshCoreManager), replacing the old registry.list().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n
…pe (#3962 Ph2)

All getAllManagers() loops that assumed meshtastic-only content now use
type guards or explicit sourceType checks:

- Security/dup-key/dead-node loops: `.filter(m => m.sourceType !== 'meshcore')`
  because MeshCore does not participate in PKI key dedup or Meshtastic
  dead-node detection.
- Low-battery / inactive-node services: unified enumeration via
  getAllManagers() only (removed the old dual-registry merged array that
  appended meshcoreManagerRegistry.list() alongside sourceManagerRegistry).
  MeshCore managers now appear here naturally because WP2 registers them in
  sourceManagerRegistry.
- Announce/timer/geofence/airtime loops in server.ts: filter(isMeshtasticManager)
  before calling meshtastic-only methods (restartAnnounceScheduler etc.).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n
- meshcoreTelemetryPoller.test.ts / meshcoreRemoteTelemetryScheduler.test.ts:
  switch registry mock from MeshCoreManagerRegistry.list() to
  SourceManagerRegistry.getAllManagers(); add sourceType: 'meshcore' to fake
  manager objects so isMeshCoreManager() filter passes
- sourceRoutes.autoConnect.test.ts / sourceRoutes.status.test.ts:
  add vi.mock stubs for meshcoreConfig.js and meshcoreManager.js (now imported
  by sourceRoutes.ts); update disconnect/status/nodeCount tests to use unified
  mockRegistry.getManager with sourceType: 'meshcore' instead of old
  mockMcRegistry.get
- lowBatteryNotificationService.test.ts / inactiveNodeNotificationService.test.ts:
  MeshCore beforeEach now puts manager in mockGetAllManagers (unified) rather
  than the removed mockMeshcoreList; both test files still mock meshcoreRegistry
  (unused by production code after WP2 but harmless to keep for isolation)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n
…Order)

Restores the typecheck:tests baseline to 283.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n
…ly call sites to sourceManagerRegistry (WP3b)

All production imports of meshcoreManagerRegistry / meshcoreRegistry have been
removed from routes and services. Every call site now uses
sourceManagerRegistry.getManager(id) narrowed by isMeshCoreManager() from
sourceManagerTypes.ts, or sourceManagerRegistry.getAllManagers().filter(isMeshCoreManager)
for list operations.

Files changed:
- routes/channelRoutes.ts: two .get() calls (PUT + DELETE channel for MeshCore)
- routes/meshcoreRoutes.ts: import, managerFor(), router guard, two .list() calls
  (saved-region cache refresh), three .get() calls inside handlers simplified to
  managerFor(req) since the router guard already guarantees the manager
- routes/messageRoutes.ts: .list().filter(isConnected) for message search
- routes/sourceRoutes.ts: null-guard on isMeshCoreManager (pre-existing TS2345)
- routes/unifiedRoutes.ts: .get() in unified stats; stale comment updated
- routes/v1/messages.ts: .list().filter(isConnected) for v1 message search
- services/automation/meshActionDeps.ts: drop dual-registry fallback in resolveManager()
- services/automation/meshNodeData.ts: getSelfPublicKey via isMeshCoreManager narrow
- services/sourceDashboardData.ts: buildSourceNodes MeshCore branch via isMeshCoreManager

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n
…erRegistry (WP3b)

Production code no longer consults meshcoreManagerRegistry, so tests that
stubbed it were exercising dead mocks:
- meshcoreRoutes.test.ts: registry mock -> sourceManagerRegistry (partial mock);
  stub manager gains sourceType:'meshcore' for the isMeshCoreManager() guard
- unifiedRoutes.test.ts: meshcore node-count test drives getManager(); mc
  manager mock gains sourceType + getStatus (anyConnected loop touches it now)
- channelRoutes.test.ts: registry mock -> sourceManagerRegistry.getManager
- meshActionDeps.test.ts: #3915 fallback regression tests rewritten for the
  single-registry resolution (#3962 Ph2)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n
…3962 Ph2)

- Reduce meshcoreRegistry.ts to a @deprecated shim delegating to
  sourceManagerRegistry + isMeshCoreManager; delete MeshCoreManagerRegistry
  class; re-export config helpers from meshcoreConfig.ts; getOrCreate throws
  a migration-guidance error. Remove annotation: one release then delete.
- Replace meshcoreRegistry.test.ts lifecycle suite with shim-behavior tests:
  delegation semantics, @deprecated surface shape, getOrCreate throws, and
  meshcoreConfigFromSource re-export. Config-helper tests stay in
  meshcoreConfig.test.ts (moved in WP1).
- Opportunistic cleanup: remove inert vi.mock('../meshcoreRegistry.js') blocks
  and stale mockMeshcoreList/mockMcRegistry variables from six sourceRoutes
  test files and two notification-service test files (production code no longer
  imports meshcoreRegistry after WP3b migration).
- CLAUDE.md: update Multi-Source table (single unified registry); add critical
  rules for isMeshCoreManager/isMeshtasticManager guards and MeshCore
  disconnect semantics; note @deprecated shim for deletion.
- REMEDIATION_EPIC.md: add Phase 2 checklist (2.1 this task, 2.2a–d
  heartbeat/announce/responder/distance-delete, 2.3 singleton retirement);
  record Phase 2 start date 2026-07-07.

Verification: typecheck clean; typecheck:tests 282 errors (≤283 baseline);
lint:ci exit 0; full suite success:true / 8258 passed / 0 failed.

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

- resolveSourceManager: check isMeshtasticManager before returning the registered
  manager; meshcore ids deliberately fall back to the singleton
- messageRoutes: purge-node resolver uses isMeshtasticManager instead of raw cast
- securityRoutes: add isMeshtasticManager guards on GET /dead-nodes,
  POST /dead-nodes/bulk-delete, POST /scanner/scan; convert plain json errors
  to fail() envelope with SOURCE_NOT_FOUND / INVALID_SOURCE_TYPE codes
- New test: resolveSourceManager.test.ts covers meshtastic, meshcore, missing id
- Existing securityRoutes.test.ts extended with INVALID_SOURCE_TYPE guard tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n
Previously delegated to sourceManagerRegistry.stopAll() which stopped ALL
managers (including meshtastic). Now filters to meshcore managers only
before removing, leaving meshtastic sources untouched.

Adds regression test verifying meshtastic manager survives disconnectAll().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n
…Name in start() logs

- connect(): sets this.pendingConfig = config alongside this.config = config so
  the staging field stays in sync when connect() is called directly (not via start())
- start(): all three log lines now include (sourceName) alongside sourceId for
  easier multi-source log triage

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n
…re-lookup

The router-level guard now stores the narrowed MeshCoreManager in
res.locals.meshcoreManager after the isMeshCoreManager check succeeds.
managerFor() reads from res.locals instead of calling getManager() a second
time, eliminating the TOCTOU window between guard check and handler use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n
- server.ts: clarify debug log to say 'registered as meshcore and connected'
- CLAUDE.md: replace 'one place sourceType===meshcore is branched on in route logic'
  with accurate description of why MeshCore disconnect semantics differ from
  meshtastic (disconnect keeps the manager registered rather than removing it)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n
…ail() envelope

- channelRoutes: PUT /:id and DELETE /:id MeshCore 503 responses now use
  fail(res, 503, 'SOURCE_NOT_CONNECTED', ...) for consistent error shape
- securityRoutes: SOURCE_NOT_FOUND and INVALID_SOURCE_TYPE already committed
  in fix(security) commit above; this commit covers the channelRoutes changes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n
…e-filter

- meshcoreConfig.ts: add ensureMeshCoreManagerStarted(source, cfg) — the
  canonical create-or-connect recipe (create if missing, reconnect if
  disconnected, skip+log if already connected)
- sourceRoutes.ts: replace 4 inline create-or-connect blocks with the helper;
  the /connect endpoint with alreadyRunning logic is kept at its call site
- server.ts: startup auto-connect loop also replaced with the helper; removes
  the now-redundant MeshCoreManager import from server.ts
- messageRoutes.ts + v1/messages.ts: collapse .filter(isMeshCoreManager)
  .filter(m => m.isConnected()) into a single type-predicate filter

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

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: MeshCoreManager implements ISourceManager — unified registry (#3962)

Todo List:

  • Gather context and understand the PR scope
  • Review core interface and type changes (sourceManagerTypes.ts, meshcoreManager.ts)
  • Review registry changes (meshcoreRegistry.ts, sourceManagerRegistry.meshcore.test.ts)
  • Review route handler changes (security, meshcore, source routes)
  • Review resolveSourceManager.ts changes and regression tests
  • Review test coverage and quality
  • Post comprehensive review feedback

Summary

This is a high-quality, well-executed structural refactor. The adversarial review process the PR describes is clearly visible in the result — regression tests exist for the specific TOCTOU and type-safety bugs that unification would have introduced. The new unified registry approach is architecturally sound. My comments below are mostly minor quality observations and a couple of things worth discussing.


Observations

1. isMeshtasticManager covers only meshtastic_tcp — MQTT sources are excluded

src/server/sourceManagerTypes.ts:34

export function isMeshtasticManager(m: ISourceManager): m is MeshtasticManager {
  return m.sourceType === 'meshtastic_tcp';
}

MQTT bridge/broker sources (mqtt_broker, mqtt_bridge) use MeshtasticManager instances too (they appear in resolveSourceManager and buildMqttManagerForSource). The guard returning false for those types means code that filters with isMeshtasticManager will silently skip active MQTT sources. The securityRoutes.ts dead-nodes filter at line 376 (m.sourceType !== 'meshcore') avoids this issue by negation, but it's inconsistent with the "use the guard predicates" rule in CLAUDE.md.

This might be intentional (MQTT sources perhaps do NOT implement the full MeshtasticManager interface), but it warrants a comment in sourceManagerTypes.ts to make the distinction explicit — especially since the CLAUDE.md isMeshtasticManager usage example on the getAllManagers().filter(isMeshtasticManager) line would silently exclude MQTT sources from enumeration results.

2. ensureMeshCoreManagerStarted silently handles non-MeshCore registered manager

src/server/meshcoreConfig.ts:79

} else if (isMeshCoreManager(existing) && !existing.isConnected()) {
    await existing.connect(cfg);
} else {
    logger.debug(`... Manager already registered as meshcore and connected — skipping auto-connect`);
}

The else branch covers three cases: (a) already registered as meshcore AND connected, (b) registered but NOT as meshcore. Case (b) would silently log "already connected" when the sourceId is registered as a different manager type — a defensive error log or early fail for the type-mismatch sub-case would aid debugging. Low risk in practice since source types are immutable after creation, but worth noting.

3. resolveSourceManager comment says "60+ handlers" but the function exists in the new PR

src/server/utils/resolveSourceManager.ts:16

"Centralizes the inline pattern that previously appeared in 60+ handlers"

The resolveSourceManager.ts function predates this PR (it's listed as MODIFIED, not ADDED). The comment is unchanged from before, so this is pre-existing, but the PR description says "~30 read sites migrated." The comment in the file doesn't match either count. Minor doc nit.

4. meshcoreRegistry.ts shim: disconnectAll calls removeManager — semantics mismatch

src/server/meshcoreRegistry.ts:51-56

disconnectAll(): Promise<void> {
    const meshcoreManagers = sourceManagerRegistry.getAllManagers().filter(isMeshCoreManager);
    return Promise.allSettled(
      meshcoreManagers.map(m => sourceManagerRegistry.removeManager(m.sourceId))
    ).then(() => undefined);
}

The original disconnectAll in the pre-PR registry called disconnect() (keeping the manager registered). This shim now calls removeManager() (which removes them). The deprecation note says "Note: this scopes to meshcore managers only — it does NOT remove meshtastic sources," but doesn't document the semantics change from disconnect→remove. Since this is a @deprecated shim with zero production callers, it's not a live bug, but callers who relied on the old behavior (if any existed) would behave differently. The deprecation javadoc could mention this divergence.

5. securityRoutes.ts — inconsistent response envelope in the /dead-nodes path

src/server/routes/securityRoutes.ts:421

res.json({ nodes: deadNodes, count: deadNodes.count, thresholdDays: DEAD_NODE_DAYS });

vs. the error paths in the same function that correctly use fail(). The success response on line 421 uses a bare res.json — no ok() envelope. This is consistent with how the route worked before (it's pre-existing), but since the PR explicitly touches this file and converts some handlers to fail(), it's a good opportunity to flag for the next pass.

6. sourceRoutes.ts — large if-else if chain on enable/disable transitions

src/server/routes/sourceRoutes.ts:531–700 (approximately)

The PUT /:id handler has grown a long if-else if chain, one branch per (sourceType × transition) combination. The MeshCore branches added in this PR integrate cleanly with the existing structure, but this chain is now 10+ branches and will need a source-type dispatch table to remain maintainable. Not a PR blocker, but a follow-up refactor candidate.

7. Test coverage for ensureMeshCoreManagerStarted

src/server/meshcoreConfig.test.ts covers meshcoreConfigFromSource and virtualNodeConfigFromSource well, but ensureMeshCoreManagerStarted is not tested in that file. The WP1 test (sourceManagerRegistry.meshcore.test.ts) covers registry lifecycle but doesn't call ensureMeshCoreManagerStarted directly. The function has 3 branches (no manager, disconnected manager, connected manager) and the second and third aren't exercised with unit tests. This is a gap worth filling, especially the "registered but non-MeshCore type" edge case from observation #2 above.


Strengths

  • Type-guard approach (isMeshCoreManager/isMeshtasticManager based on sourceType discriminant) is clean and avoids import cycles. The leaf-module placement is correct.
  • res.locals.meshcoreManager caching in the router-level guard eliminates the TOCTOU window and is exactly the right fix for the narrowing-then-lookup pattern.
  • @deprecated shim is well-documented and the getOrCreate hard-throw is the right way to make callers migrate.
  • Security scan, dead-nodes, and bulk-delete all have isMeshtasticManager guards with appropriate INVALID_SOURCE_TYPE error codes and regression tests.
  • setSourceName() on rename is a nice quality-of-life fix that preserves status accuracy without a restart.
  • Test file sourceManagerRegistry.meshcore.test.ts covers all 5 invariants including the load-bearing manual-disconnect behavior.

Minor / Nits

  • sourceManagerTypes.test.ts line 83: isMeshCoreManager(makeStub('mqtt_broker')) || isMeshtasticManager(makeStub('mqtt_broker')) — the claim is "mqtt types are not identified by either guard". True, but the test would be stronger if it also asserted that MQTT manager instances actually work in the registry (since MqttBrokerManager/MqttBridgeManager presumably implement ISourceManager but have sourceType !== 'meshtastic_tcp'). Cosmetic concern.
  • The sourceRoutes.ts MQTT link hot-swap at line 660 still uses as MeshtasticManager | undefined cast instead of isMeshtasticManager. Pre-existing, but since the file is touched this is a clean-up opportunity.

Verdict

Approve with the above observations noted. The regression fixes (type guards on security/dead-node/scan routes) are correct and tested. The architectural change (unified registry) is well-executed. The observations above are either pre-existing, documentation-only, or low-priority follow-ups that don't affect correctness of this PR.

@Yeraze
Yeraze merged commit 765485b into main Jul 8, 2026
19 checks passed
Yeraze added a commit that referenced this pull request Jul 8, 2026
…race (#4007)

PR #4002 (scheduled device-reboot action) was authored against the
pre-#4004 dual-registry mocks (meshcoreGet) and merged after #4004
removed them — both PRs green individually, main red in combination
(5 ReferenceError failures). Production code was unaffected (it
duck-types rebootDevice via the unified resolveManager). Tests now
use the unified-registry mock pattern like the rest of the file.


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

Co-authored-by: Claude Opus 4.8 (1M context) <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.

1 participant