Skip to content

feat(meshcore): region/scope support — phase 1 (#3667)#3669

Merged
Yeraze merged 3 commits into
mainfrom
feature/meshcore-scope-3667
Jun 23, 2026
Merged

feat(meshcore): region/scope support — phase 1 (#3667)#3669
Yeraze merged 3 commits into
mainfrom
feature/meshcore-scope-3667

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 23, 2026

Copy link
Copy Markdown
Owner

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 commandSetChannel carries 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

  • Migration 098 channels.scope (SQLite/PG/MySQL, idempotent) + registry + count test
  • Schema/repo: scope column, scope-preserving upsert (device re-sync must not clobber it), updateChannelScope, DbChannel.scope
  • meshcoreDefaultScope per-source setting
  • set_flood_scope bridge command — derives sha256("#region")[:16]setFloodScope/clearFloodScope
  • 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 GET/POST, hook actions
  • Tests: manager scope resolution/override/cache/serialization ordering/setChannel persistence; repo sync-preserves-scope regression

Not in this PR (follow-ups)

  • Phase 2: scope all originated traffic (adverts, telemetry/stats, auto-ack/announce)
  • Phase 3: region discovery from nearby repeaters (companion-protocol mechanism still TBD)

Testing

  • tsc typecheck clean
  • ✅ Full Vitest suite green (7776 tests)
  • Local eslint . shows pre-existing repo-wide noise (not CI-faithful); new files lint clean

Closes #3667 (phase 1)

🤖 Generated with Claude Code

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
@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(meshcore): region/scope support — phase 1 (#3667)

Tasks:

  • Read PR context and understand the changes
  • Review core manager implementation (meshcoreManager.ts)
  • Review test coverage (meshcoreManager.scope.test.ts)
  • Review database layer (schema, repository, migration)
  • Review routes and UI components
  • Review settings constants and hooks
  • Compile and post final review

Code Review

Overall this is an excellently-structured PR. The design rationale is sound and clearly documented. Here's detailed feedback:

Architecture & Design ✅

The mutex-chain pattern for serializing scope+send is elegant:

// meshcoreManager.ts:1977-1979
const task = this.sendScopeLock.then(() => this.performScopedSend(text, toPublicKey, channelIdx));
this.sendScopeLock = task.then(() => undefined, () => undefined);
return task;

Using a chained Promise rather than a hard lock is idiomatic and avoids deadlocks — the chain continues on both success and failure, which is correct. The test at line 118-127 in meshcoreManager.scope.test.ts proves the serialization ordering with concurrent Promise.all() sends.

Potential Issues

1. applyFloodScope error recovery is incomplete (meshcoreManager.ts:2004-2016)

When set_flood_scope fails, activeFloodScope is correctly reset to undefined so the next send re-asserts. However, performScopedSend catches the error at line 2061 and returns false silently. This means the caller (sendMessage) returns false but the serialization lock is still released, allowing the next queued send to proceed immediately — potentially with a stale/unknown device scope. This is actually the correct safe behavior (the next send will re-assert via the undefined cache), but it's worth noting that an application layer above sendMessage receives no indication of why the send failed.

2. No validation of scope format in the channelRoutes.ts PUT handler for empty-after-strip case

At channelRoutes.ts:374-378:

const stripped = scope.trim().replace(/^#/, '');
if (stripped !== '' && !/^[A-Za-z0-9-]{1,63}$/.test(stripped)) {
  return res.status(400).json({ error: 'Scope must be 1-63 chars: letters, digits, hyphen' });
}
normalizedScope = stripped || null;

If the user sends scope: "#" (just a hash sign), stripped is '', the regex check is skipped, and normalizedScope becomes null. This is actually the correct semantics (clears scope), but the behavior is somewhat surprising for the caller. A comment or documentation here would help.

3. DM scoping uses the default scope but sends the scope to the device (meshcoreManager.ts:2020-2023)

const isChannelSend = !toPublicKey && channelIdx !== undefined;
const region = await this.resolveScopeForSend(isChannelSend ? channelIdx : undefined);
await this.applyFloodScope(region);

For DMs (toPublicKey is set), the flood scope is still asserted on the device. While the PR notes this is intentional (Phase 2 will scope adverts/telemetry), it's worth confirming with MeshCore firmware behavior: does CMD_SET_FLOOD_SCOPE affect DM routing, or only flood messages? If DMs are not flood-routed, asserting the scope before a DM is a no-op overhead, but it has the side-effect of changing the global device scope — which means a DM send after a channel send could silently change the device's active scope to the default scope. This is only visible if the device state is observed externally (e.g., via the repeater's filtering), but it's worth a comment.

4. Scope cache invalidated too broadly in setChannel (meshcoreManager.ts:1388)

this.activeFloodScope = undefined;

This forces a re-assert on the next send for any channel, not just the one whose scope was updated. This is conservative (safe) but slightly inefficient — if channel 1's scope is updated and the next send is on channel 2 with a different cached scope, the cache miss triggers an unnecessary device round-trip. Given RF is much slower than the round-trip cost, this is fine for Phase 1.

Test Coverage ✅

Test coverage is excellent for this feature. The tests cover:

  • Channel scope assertion (muenchenset_flood_scope before send_message)
  • Fallback chain: channel scope → default scope → null
  • Cache hit (same scope: no re-assert)
  • Cache miss (different scope between sends: re-assert)
  • Serialization ordering with Promise.all concurrent sends — this is the most critical test and it's correct
  • setChannel persists scope and invalidates cache
  • setDefaultScope normalizes # prefix and clears on empty
  • Repository: upsert preserves scope across device re-sync (the load-bearing regression test)

One gap: there's no test for the error path where set_flood_scope fails. The behavior is that activeFloodScope is reset and the send returns false. A test asserting that a subsequent send retries the scope assertion would complete the coverage.

Database Layer ✅

The upsert scope-preservation logic is correct and clearly commented:

// channels.ts:154
scope: data.scope !== undefined ? (data.scope || null) : (existingChannel.scope ?? null),

undefined = preserve existing, empty string/null = clear. The syncChannelsFromDevice omits scope in the upsert input (correctly), so device re-syncs never touch user-configured scopes. The regression test at channels.test.ts:170-186 confirms this.

Migration idempotency: All three backends are idempotent — SQLite catches duplicate column, PostgreSQL uses ADD COLUMN IF NOT EXISTS, MySQL checks information_schema. ✅

Input Validation ✅

  • Route: ^[A-Za-z0-9-]{1,63}$ — consistent between meshcoreRoutes.ts:624 and channelRoutes.ts:375
  • Manager setDefaultScope strips leading # consistently
  • MySQL scope VARCHAR(64) vs validation 1-63 chars — 1 char difference is fine (null terminator not an issue in MySQL, but it's oddly mismatched; 64 chars in DB, 63 in validation)

Security ✅

  • Scope field passes through transformChannel (not sensitive, always included in response)
  • Both write routes require requireAuth() + requirePermission('configuration', 'write')
  • Input validation is consistent and rejects non-alphanumeric characters
  • SHA-256 key derivation for the transport key uses createHash('sha256').update(name, 'utf8') — correct

Minor Nits

  1. MeshCoreSettingsView.tsx:38-41: getDefaultScope is called in a useEffect that also loads getDiscoverable. If the component is unmounted mid-flight, there's no cancellation guard. This is pre-existing in the hook (discoverable had the same issue) but the scope fetch adds another unguarded async call.

  2. channelRoutes.ts:475-476: normalizedScope can be string | null | undefined (when scope was not in the request body). This is passed as scope to mcManager.setChannel, where undefined is the sentinel for "don't update scope". The types line up correctly, but a type annotation here would make the intent explicit.

  3. MySQL scope VARCHAR(64) vs scope TEXT in PostgreSQL/SQLite: MySQL uses 64 chars (matching the migration), while PG/SQLite use unbounded TEXT. The validation limits to 63 chars so this is safe, but the inconsistency is minor. The migration itself uses VARCHAR(64) for MySQL.

Summary

This is high-quality, well-tested code with a sound architectural design. The key invariant — scope is MeshMonitor-owned, never device-reported — is consistently enforced throughout the DB, manager, and test layers. The serialization mechanism is correct. The regression test ensuring device re-sync doesn't clobber user-set scopes is exactly the right load-bearing test for this feature.

The noted issues are all minor or expected follow-up items for Phase 2. Approved with confidence.

…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
@Yeraze

Yeraze commented Jun 23, 2026

Copy link
Copy Markdown
Owner Author

Thanks for the thorough review! Addressed in d8a5e7d:

  • Test gap (set_flood_scope failure path) — added a test proving a failed scope assertion does not fall back to an unscoped send (returns false) and that the next send re-asserts the scope. This is the load-bearing behavior for region denyf * meshes.
  • "Update Claude PR Assistant workflow" #3 (DM scoping) — added a comment confirming this is intentional: MeshCore firmware applies the default scope to flood-routed DMs/logins/requests when the path is unknown, so DMs must carry the scope in a denyf * mesh; when a direct path is known the scope is simply inert. The cache + re-assert keeps a later channel send correct.
  • feat: traceroute highlighting and UI improvements #2 ("#"/whitespace → clear) — added a comment documenting the clear-scope semantics in the channel route.

Declined as intentional / Phase 2 (noted in the review as correct):

  • "Claude PR Assistant workflow" #1 (error recovery) — returning false with the cache invalidated is the safe behavior; surfacing a richer failure reason to callers is a future enhancement.
  • feat: traceroute request tracking and auth documentation #4 (broad cache invalidation in setChannel) — conservative and correct; RF cost dwarfs the occasional extra round-trip.
  • Nit: type annotationnormalizedScope is already declared string | null | undefined.
  • MySQL VARCHAR(64) vs 63-char validation — intentional headroom.
  • useEffect cancel guard — matches the existing getDiscoverable pattern; left consistent.

Adverts/telemetry/auto-ack scoping is Phase 2 per the plan doc.

@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(meshcore): region/scope support — phase 1 (#3667)

Tasks:

  • Read PR context and understand the changes
  • Review updated test file (meshcoreManager.scope.test.ts)
  • Review manager changes (meshcoreManager.ts)
  • Review channel route comment additions (channelRoutes.ts)
  • Compile and post final review

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 (meshcoreManager.scope.test.ts:125-141) ✅

The new test is the load-bearing one for the Germany region denyf * use case:

it('does NOT send when the scope assertion fails, and re-asserts on the next send', async () => {
  const { manager, bridgeCalls } = makeManager({ channelScopes: { 1: 'muenchen' }, failFloodScopeTimes: 1 });
  const first = await manager.sendMessage('one', undefined, 1);
  expect(first).toBe(false);
  expect(cmdSeq(bridgeCalls)).toEqual(['set_flood_scope']); // no send_message
  const second = await manager.sendMessage('two', undefined, 1);
  expect(second).toBe(true);
  expect(cmdSeq(bridgeCalls)).toEqual(['set_flood_scope', 'set_flood_scope', 'send_message']);
});

Three properties are verified in sequence: (1) a failed set_flood_scope aborts the send, (2) send_message is not issued, and (3) the next send re-asserts — proving the cache was invalidated. This closes the previously noted coverage gap cleanly.

One minor observation: failFloodScopeTimes in makeManager decrements before the return, so if a test specifies failFloodScopeTimes: 2 the second call would also fail. The current tests only use 1, so this is fine — just worth noting for future test authors extending this fixture.

DM scoping comment (meshcoreManager.ts:2022-2028) ✅

// DMs are scoped too, by design: MeshCore firmware applies the default
// scope to DMs/logins/requests that flood (path unknown), so a DM in a
// `region denyf *` mesh must carry the default scope or it is dropped.
// When the DM has a known direct path the scope is simply inert. Setting
// it changes the device's single global scope, but the activeFloodScope
// cache + re-assert keeps a later channel send correct regardless.

This exactly answers the question raised. The comment correctly captures the two cases (no-path flood vs. known-path direct) and explains why the cache-invalidation property is sufficient. Good.

Channel route "#" comment (channelRoutes.ts:374-376) ✅

const stripped = scope.trim().replace(/^#/, '');
// An all-whitespace or bare '#' value strips to '' and clears the scope
// (same as sending null/'') — the regex check only applies to a real name.
if (stripped !== '' && !/^[A-Za-z0-9-]{1,63}$/.test(stripped)) {

The comment makes the "#" → clear-scope semantics explicit. No surprises for future readers.

set_flood_scope native backend (meshcoreNativeBackend.ts:1038-1053) ✅

The SHA-256 transport key derivation is correct:

const name = region.startsWith('#') ? region : `#${region}`;
const transportKey = createHash('sha256').update(name, 'utf8').digest().subarray(0, 16);
await c.setFloodScope(Uint8Array.from(transportKey));

The # prefix is always applied before hashing (since the protocol stores the region as #name), and clearFloodScope is called for null/empty. One thing to double-check: createHash('sha256').update(name, 'utf8') — the 'utf8' encoding argument is valid for region names that are ASCII-only (per the [A-Za-z0-9-] validation), so this is correct in practice.

Previously approved items (unchanged, still correct)

  • Mutex chain (sendScopeLock chained Promise) — unchanged, still the right pattern
  • Upsert scope-preservation (channels.ts:154) — unchanged, still correct
  • Migration idempotency (098) — SQLite/PG/MySQL all idempotent
  • Test coverage: All 9 tests (including the new failure-path test) cover the behavioral contract thoroughly

Minor remaining observations (non-blocking)

  1. meshcoreNativeBackend.ts: createHash('sha256').update(name, 'utf8') — the encoding parameter to update() is deprecated in newer Node; Buffer.from(name, 'utf8') as the first argument is the modern form. Not a bug at any Node version in the support matrix (20/22/24/25), but worth noting.

  2. MeshCoreSettingsView.tsx:39-40: Still two unguarded void-ed async calls in the useEffect (no cleanup on unmount). As noted in the initial review, this matches the pre-existing getDiscoverable pattern — leaving it consistent is reasonable for Phase 1.

  3. setDefaultScope return on empty (meshcoreManager.ts): The test at line 182 (expect(await manager.setDefaultScope(' ')).toBe('')) confirms clearing returns ''. The handleSaveScope in MeshCoreSettingsView.tsx:48 checks result === null to detect failure. Since setDefaultScope appears to return the normalized string (not null on success), the null check as a failure sentinel is correct — but it means a bug in setDefaultScope returning null would silently show a toast error instead of throwing. Not a current bug, just a note on the error contract.

Summary

The follow-up commit correctly addresses all three items flagged in the initial review:

  • ✅ Missing failure-path test — added and verifies the exact invariant that matters for region denyf * meshes
  • ✅ DM scoping intent — clearly documented in-code with the firmware behavior rationale
  • "#" → clear semantics — commented for future maintainers

The remaining observations are all nits or follow-up items. This PR is approved as-is.

View job

…e-3667

# Conflicts:
#	src/db/migrations.test.ts
#	src/db/migrations.ts
@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(meshcore): region/scope support — phase 1 (#3667)

Tasks:

  • Read PR context and understand the changes
  • Review core manager implementation (meshcoreManager.ts)
  • Review test coverage (meshcoreManager.scope.test.ts)
  • Review database layer (schema, repository, migration)
  • Review routes and UI components
  • Review settings constants and hooks
  • Compile and post final review

Code Review

This 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 (.then(() => undefined, () => undefined)), which is critical — a failure must not deadlock all subsequent sends.

Potential Issues

1. set_flood_scope return shape mismatch (meshcoreNativeBackend.ts:1046-1052)

The native backend returns { ok: true, scope: null } / { ok: true, scope: region }, but applyFloodScope in the manager checks resp.success (not resp.ok):

// 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 { id, success, data } where data is what the case handler returns — so resp.data would be { ok: true, scope: null }, and resp.success is set by the dispatcher wrapper, not by the handler's return value. This works correctly as long as the bridge dispatcher always sets success: true when the handler returns without throwing. Verify this invariant holds — if the dispatcher wraps handler return values into { success: true, data: <return> }, the code is correct; if the handler's ok field is intended as the success signal, there's a latent bug.

2. Validation asymmetry between GET/POST routes for default-scope

GET /config/default-scope (meshcoreRoutes.ts:589-601) requires configuration:read permission, but POST /config/default-scope (meshcoreRoutes.ts:612-633) has an additional meshcoreDeviceLimiter rate-limiter that the GET does not. This is correct behavior (writes are rate-limited; reads are not), but worth noting it's intentional.

3. Empty-scope validation gap in meshcoreRoutes.ts:619-621

const raw = req.body?.scope;
if (raw !== '' && typeof raw !== 'string') {
  return res.status(400).json({ success: false, error: 'scope must be a string' });
}

If raw is undefined (scope omitted from body), the condition raw !== '' is true and typeof raw !== 'string' is also true — so this returns a 400. This means the POST body must include a scope key, even to set an empty (clear) scope. This is intentional but undocumented in the route JSDoc. The UI always sends scope, so there's no practical bug, but a missing-body or { } body will get a confusing 400.

4. resolveScopeForSend hits the DB on every send (meshcoreManager.ts:1988-1996)

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 activeFloodScope cache avoids repeating the device round-trip, but the DB queries always run first. The cache check at applyFloodScope:2005 fires after the DB reads:

private async applyFloodScope(region: string | null): Promise<void> {
  if (this.activeFloodScope === region) return;  // ← DB already read by now

For high-frequency sends (e.g., auto-reply loops) this adds latency. A minor optimization would be to cache the resolved scope alongside activeFloodScope. Not a blocking issue for Phase 1 but worth noting for Phase 2.

Test Coverage ✅

The 9-test suite covers all key behavioral properties:

  • Channel scope assertion, fallback chain, cache hit/miss, DM scoping
  • Failure path: aborts send, invalidates cache, re-asserts on next attempt
  • Concurrent serialization ordering — the most critical test

One observation on the makeManager fixture: failFloodScopeTimes decrements before returning, so failFloodScopeTimes: 2 would fail the first two set_flood_scope calls. The tests only use 1 but this should be documented for future test authors:

// 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 channels.test.ts:170-187 (sync must not clobber user-set scope) is the load-bearing test for the upsert design — it's exactly right.

Database Layer ✅

Scope-preserving upsert (channels.ts:154):

scope: data.scope !== undefined ? (data.scope || null) : (existingChannel.scope ?? null),

Sentinel: undefined = preserve, falsy string/null = clear. Correctly ensures syncChannelsFromDevice() (which omits scope) can never wipe a user-configured region.

Migration idempotency: All three backends are idempotent. SQLite catches duplicate column, PostgreSQL uses ADD COLUMN IF NOT EXISTS, MySQL checks information_schema. ✅

Schema consistency: MySQL uses VARCHAR(64) while SQLite/PostgreSQL use unbounded TEXT. Validation caps at 63 chars so this is safe, but the 1-char difference is unexplained. Likely intentional headroom.

updateChannelScope isolation — sets only scope and updatedAt, leaving all other fields untouched. The test at channels.test.ts:189-207 verifies this. ✅

Routes & Security ✅

  • Both write routes (PUT /channels/:id with scope, POST /config/default-scope) require requireAuth() + requirePermission('configuration', 'write', { sourceIdFrom: 'params.id' }) — source-scoped permissions correct.
  • Input validation uses ^[A-Za-z0-9-]{1,63}$ consistently between both routes and the manager.
  • transformChannel always surfaces scope (not sensitive); PSK is still gated behind includePsk. ✅
  • SHA-256 key derivation: createHash('sha256').update(name, 'utf8') — note 'utf8' as the encoding argument to update() is deprecated in Node 22+. The modern form is Buffer.from(name, 'utf8') as the data argument. This is non-breaking on the support matrix (20/22/24/25) but will generate deprecation warnings in future Node versions.

UI ✅

  • MeshCoreChannelsConfigSection uses a cancellation guard (cancelled = true in cleanup) in both the channel load and hashtag-secret derivation effects.
  • The MeshCoreSettingsView useEffect does not have cancellation guards for getDefaultScope and getDiscoverable, but this matches the existing pattern and is accepted as-is.
  • editScope state correctly initializes to '' (not null) for both startAdd and cancelEdit, avoiding React controlled/uncontrolled input issues.

Minor Nits

  1. meshcoreNativeBackend.ts:1050: createHash('sha256').update(name, 'utf8') — the string-encoding overload of update() is deprecated in Node ≥ 22. Consider createHash('sha256').update(Buffer.from(name)) for forward compatibility.

  2. meshcoreRoutes.ts:619-621: A comment noting that an absent scope key returns 400 (by design, body must include scope) would save future confusion.

  3. MeshCoreSettingsView.tsx:48: result === null check as failure sentinel — since setDefaultScope now always returns a string (never null), this check is actually dead code. It works correctly (null is never returned so the error toast never fires spuriously), but the type Promise<string | null> in MeshCoreActions still lists null as a possible return. If the contract is "null on error, string on success", the manager's setDefaultScope should be able to throw and the hook should catch it, returning null. Currently the manager never returns null — it either returns the normalized string or throws. The hook in useMeshCore.ts should be checked to confirm it catches and returns null correctly.

Summary

Solid, 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 success field behavior (point #1 above) — everything else is a nit or documented follow-up.

Approved.

@Yeraze Yeraze merged commit d2d6765 into main Jun 23, 2026
15 checks passed
@Yeraze Yeraze deleted the feature/meshcore-scope-3667 branch June 23, 2026 16:55
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.

[FEAT] MeshCore: Region/Scope Support

1 participant