feat(meshcore): region/scope support — phase 1 (#3667)#3669
Conversation
MeshCore "regions"/"scopes" are named flood-forwarding tags. Repeaters in
some meshes (notably Germany) run `region denyf *`, which drops un-scoped
traffic — so MeshMonitor's messages were not forwarded there. This adds the
ability to scope outgoing traffic.
The device holds a SINGLE global flood scope (companion CMD_SET_FLOOD_SCOPE).
There is no per-channel scope command, so per-channel scope is MeshMonitor-owned
state applied by asserting the device scope immediately before each send. The
set-scope→send pair is serialised per source because the scope is global+stateful.
- Migration 098: channels.scope (SQLite/PG/MySQL), registry + count test
- Schema/repo: scope column, scope-preserving upsert (sync must not clobber it),
updateChannelScope, DbChannel.scope
- meshcoreDefaultScope per-source setting
- set_flood_scope bridge command: sha256("#region")[:16] -> setFloodScope/clear
- Manager: channel-scope ?? default-scope ?? unscoped resolution; per-source
send mutex; device-scope cache reset on connect; setChannel(scope);
get/setDefaultScope
- Routes + UI: per-channel Region/Scope field, default-scope settings section,
/config/default-scope endpoints, hook actions
- Tests: manager scope resolution/override/cache/serialization + setChannel
persistence; repo sync-preserves-scope regression
- Design doc: docs/internal/dev-notes/MESHCORE_REGION_SCOPE_PLAN.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4
|
Claude finished @Yeraze's task —— View job PR Review: feat(meshcore): region/scope support — phase 1 (#3667)
|
…3667) Address Claude review feedback on PR #3669: - Add a test proving that when set_flood_scope fails the message is NOT sent unscoped, and the next send re-asserts the scope (load-bearing for the `region denyf *` case). - Comment that DM scoping is intentional (firmware applies default scope to flood-routed DMs/logins/requests when path is unknown). - Comment the bare-'#'/whitespace → clear-scope semantics in the channel route. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4
|
Thanks for the thorough review! Addressed in d8a5e7d:
Declined as intentional / Phase 2 (noted in the review as correct):
Adverts/telemetry/auto-ack scoping is Phase 2 per the plan doc. |
|
Claude finished @Yeraze's task —— View job PR Review: feat(meshcore): region/scope support — phase 1 (#3667)Tasks:
Review of d8a5e7d (follow-up commit)This commit addresses the gaps called out in the initial review. The changes are targeted and correct. Failure-path test (
|
…e-3667 # Conflicts: # src/db/migrations.test.ts # src/db/migrations.ts
|
Claude finished @Yeraze's task —— View job PR Review: feat(meshcore): region/scope support — phase 1 (#3667)Tasks:
Code ReviewThis is a well-structured PR implementing MeshCore region/scope support. Two previous Claude review rounds already approved the code; this is a fresh independent pass. Architecture & Design ✅The core design constraint is well-handled: the companion protocol exposes only a single global flood scope, so per-channel scope is MeshMonitor-owned state applied via a scope-assert→send pair that is serialized per source using a chained Promise mutex: // meshcoreManager.ts:1977-1979
const task = this.sendScopeLock.then(() => this.performScopedSend(text, toPublicKey, channelIdx));
this.sendScopeLock = task.then(() => undefined, () => undefined);
return task;The chain continues on both success and failure ( Potential Issues1. The native backend returns // meshcoreManager.ts:2007-2009
const resp = await this.sendBridgeCommand('set_flood_scope', { region });
if (!resp.success) {
throw new Error(resp.error || 'set_flood_scope failed');
}The bridge command dispatcher returns 2. Validation asymmetry between GET/POST routes for default-scope
3. Empty-scope validation gap in const raw = req.body?.scope;
if (raw !== '' && typeof raw !== 'string') {
return res.status(400).json({ success: false, error: 'scope must be a string' });
}If 4. private async resolveScopeForSend(channelIdx?: number): Promise<string | null> {
if (channelIdx !== undefined) {
const channel = await databaseService.channels.getChannelById(channelIdx, this.sourceId);
...
}
const def = await databaseService.settings.getSettingForSource(this.sourceId, 'meshcoreDefaultScope') ?? '';
...
}Every message send does up to 2 DB reads (channel + default scope), even for the common case where nothing has changed. The private async applyFloodScope(region: string | null): Promise<void> {
if (this.activeFloodScope === region) return; // ← DB already read by nowFor high-frequency sends (e.g., auto-reply loops) this adds latency. A minor optimization would be to cache the resolved scope alongside Test Coverage ✅The 9-test suite covers all key behavioral properties:
One observation on the // meshcoreManager.scope.test.ts:37-40
if (cmd === 'set_flood_scope' && floodScopeFailsLeft > 0) {
floodScopeFailsLeft -= 1; // correct decrement-before-return
return { id: '1', success: false, error: 'transport closed' };
}The repository regression test at Database Layer ✅Scope-preserving upsert ( scope: data.scope !== undefined ? (data.scope || null) : (existingChannel.scope ?? null),Sentinel: Migration idempotency: All three backends are idempotent. SQLite catches Schema consistency: MySQL uses
Routes & Security ✅
UI ✅
Minor Nits
SummarySolid, well-tested implementation with correct architecture for the design constraint (single global device scope → serialized scope-assert→send pairs). The scope-preserving upsert invariant is the right design and is properly tested. The only actionable item is verifying the bridge dispatcher's Approved. |

Summary
Implements Phase 1 of MeshCore region/scope support (#3667). MeshCore "regions"/"scopes" are named flood-forwarding tags; repeaters in meshes like Germany run
region denyf *, dropping un-scoped traffic — so MeshMonitor messages were never forwarded there. This lets users scope outgoing traffic per channel and set a per-source default scope.Key design constraint
The companion protocol exposes only a single, global, stateful flood scope (
CMD_SET_FLOOD_SCOPE). There is no per-channel scope command —SetChannelcarries only name+secret. So per-channel scope is MeshMonitor-owned state, applied by asserting the device scope immediately before each send. The set-scope→send pair is serialised per source so concurrent sends with different scopes can't interleave.Full design rationale:
docs/internal/dev-notes/MESHCORE_REGION_SCOPE_PLAN.md.What's included
channels.scope(SQLite/PG/MySQL, idempotent) + registry + count testscopecolumn, scope-preserving upsert (device re-sync must not clobber it),updateChannelScope,DbChannel.scopemeshcoreDefaultScopeper-source settingset_flood_scopebridge command — derivessha256("#region")[:16]→setFloodScope/clearFloodScopechannel-scope ?? default-scope ?? unscopedresolution, per-source send mutex, device-scope cache reset on connect,setChannel(…, scope),get/setDefaultScope/config/default-scopeGET/POST, hook actionsNot in this PR (follow-ups)
Testing
tsctypecheck cleaneslint .shows pre-existing repo-wide noise (not CI-faithful); new files lint cleanCloses #3667 (phase 1)
🤖 Generated with Claude Code