Skip to content

feat(nodes): add Meshtastic contact QR and URL sharing - #4327

Merged
Yeraze merged 2 commits into
Yeraze:mainfrom
wilhel1812:codex/meshtastic-contact-sharing
Jul 26, 2026
Merged

feat(nodes): add Meshtastic contact QR and URL sharing#4327
Yeraze merged 2 commits into
Yeraze:mainfrom
wilhel1812:codex/meshtastic-contact-sharing

Conversation

@wilhel1812

@wilhel1812 wilhel1812 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds canonical Meshtastic SharedContact URL generation and an expandable Share contact card to Meshtastic Node Details.

Any visible node with an internally consistent Meshtastic identity can be shared, including unmessagable nodes and nodes received through MQTT. The generated https://meshtastic.org/v/#… payload preserves the available User identity, always sets shouldIgnore=false, and never claims manual verification.

What changed

  • adds a focused SharedContact encoder with strict node identity, MAC, and public-key validation
  • reuses the payload builder in the existing createAddContactMessage path
  • adds a source-scoped, permission-checked internal contact URL endpoint
  • adds lazy QR/URL generation, copy feedback, error handling, and stale-response protection to Node Details
  • extracts the existing QR canvas behavior for reuse without changing channel export styling
  • documents contact sharing and the meaning of isUnmessagable

Compatibility

The encoder reproduces this known-good unmessagable WAM8 contact URL byte-for-byte:

https://meshtastic.org/v/#CPXr_8UEElgKCSE0OGJmZjVmNRIVUi1TRUQtQkzDhUtBTVBFTi1XQU04GgRXQU04IgbB30i_9fUoCTgCQiA1BZ7pj0ZZzX7VjTUKPMB-j6QbrWAoWS6J0ksAArgJQ0gB

The fixture decodes to node 1220539893 / !48bff5f5, names R-SED-BLÅKAMPEN-WAM8 / WAM8, its hardware model, role, MAC address and 32-byte public key, with isUnmessagable=true.

Protocol/client references:

Validation

  • npm run typecheck
  • npm run lint:ci
  • focused encoder, reused add-contact, route, and component tests: 21 passed
  • npm run build
  • npm run build:server
  • npm run docs:build
  • GitHub CI passed on Node 20, 22, 24, and 25, including Docker build, CodeQL, security, focused tests, and documentation
  • deployed commit 7b9f716e on Vidda against live Meshtastic node data; the QR and URL sharing flow worked as expected
  • desktop collapsed/expanded and 390px responsive layouts checked

Scope

This intentionally does not add contact import, native sharing, QR download, NFC, public API/OpenAPI changes, dependencies, migrations, version changes, or any MeshCore behavior.

@wilhel1812
wilhel1812 marked this pull request as draft July 24, 2026 21:45
@wilhel1812
wilhel1812 marked this pull request as ready for review July 24, 2026 22:05

@Yeraze Yeraze left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Thanks for this — the engineering quality here is high. The byte-exact golden fixture against a real client-generated URL is exactly the right way to validate wire-format work, the route test uses the createRouteTestApp harness with real permission SQL (as CLAUDE.md requires), per-source isolation is covered, and the stale-response cancellation is both implemented and tested. UiIcon used, no raw fetch, no new dependencies, no migrations.

Findings below, verified against the current main.


Should fix

1. The permission gate is not source-scoped — cross-source read leak

nodesRoutes.ts uses bare requirePermission('nodes', 'read') with no sourceIdFrom, then reads sourceId from the query. With sourceIdFrom absent, scopedSourceId stays undefined (authMiddleware.ts:362), and checkPermissionAsync documents that path as "Without sourceId → union across sources (legacy callers that don't scope their lookup)" (database.ts:4358).

So a user holding nodes:read on source B passes the gate when requesting a node from source A.

The second gate, checkNodeChannelAccess(node.nodeId, req.user, sourceId), is correctly source-scoped and narrows this — but it doesn't close it. A user with channel_0:viewOnMap on source A and nodes:read only on source B still gets a contact URL for a source A node.

requirePermission('nodes', 'read', { sourceIdFrom: 'query', requireSourceId: true })

This is the same class of bug #3745 fixed ("forward sourceId everywhere"). The adjacent /nodes/:nodeNum/copy-candidates route has the same unscoped shape, so it is a pre-existing pattern in this file — but I'd treat that as a reason to get it right in a new route rather than inherit it.

The tests only grant on sourceA, so this isn't currently pinned. A negative case would catch it: grant nodes:read on B plus channel_0:viewOnMap on A, request A, expect 403.

2. Handler bypasses the shared response envelope

CLAUDE.md: "New or modified handlers must use these" (ok/fail from src/server/utils/apiResponse.ts). This handler hand-rolls res.json({ success: true, data: { url } }) and res.status(400).json({ error, code }).

ok(res, { url }) emits byte-identical output and fail(res, 400, 'INVALID_NODE_NUM', 'Invalid nodeNum') likewise, so this is a pure drop-in with no consumer impact. (nodesRoutes.ts has zero adoption of the helpers today, so this is greenfield in that file.)

3. New component uses a global stylesheet rather than a CSS module

Per the CSS containment rule (#3962 Task 5.6), MeshtasticContactShare.css should be MeshtasticContactShare.module.css scoped to the component. Also, height: auto !important on .node-contact-share-canvas is fighting the inline dimensions QRCode.toCanvas writes onto the element — if it stays, a comment explaining that would help the next reader.


Worth calling out

4. createAddContactMessage silently gains validation it never had

Routing the existing admin path through buildSharedContactPayload adds two throw conditions that did not previously exist: the nodeNum range check, and nodeId must equal !<nodeNum hex>. The validatePublicKeyLength: false option preserves the key leniency but not the identity leniency.

The runtime caller (meshtasticManager.ts:9035) wraps pushContactToRadio in a catch documented as "radio may already have the contact, or the send failed transiently." A nodeId/nodeNum desync now lands in that catch, so the contact is never pushed — while pkiEncrypted was already set true a few lines above, meaning a PKI-encrypted DM goes out to a radio that lacks the contact.

This requires a DB desync so it should be rare, but it is a new failure mode in an existing path, and the catch comment no longer describes everything that can reach it. Either extend the options object to relax identity checking for that path, or log the SharedContactValidationError distinctly so it doesn't masquerade as a transient send failure.


Minor

  • QrCodeCanvas never clears a stale render. if (!value || !canvasRef.current) return; leaves the previous QR on the canvas. Not reachable from MeshtasticContactShare (it unmounts on collapse), and ExportConfigModal behaved the same way before this PR, so no regression — but an explicit clear would be more correct.
  • Server error text is surfaced verbatim via requestError.message. Strings like nodeId !x does not match nodeNum N are developer-facing; consider mapping the code to a translated string instead.
  • decodeBase64Bytes only rejects normalized.length % 4 === 1; other malformed input is silently truncated by Buffer.from. Harmless wherever a length is asserted (the 32-byte key), but the validatePublicKeyLength: false path will accept garbage.
  • Only en.json is updated; the other nine locales fall back to the inline English defaults. Consistent with how other features have landed, just noting it.

Verdict

No correctness bugs in the encoder itself, and the compatibility fixture gives real confidence in the wire format.

Finding 1 is the one I'd want resolved before merge — it's a per-source permission deviation in a brand-new route, in a codebase where that rule is explicit and has caused a prior incident. 2 and 3 are convention items for a maintainer to call. 4 would benefit from a note on intent from you.

Review by Claude Code at @Yeraze's request.

@wilhel1812

Copy link
Copy Markdown
Contributor Author

Addressed the review feedback in 5252457:

  • Scoped nodes:read to the requested sourceId and added the cross-source denial regression test described in the review.
  • Switched the new handler to the shared ok/fail response helpers.
  • Converted the contact-sharing styles to a CSS Module and documented the QR canvas sizing override.
  • Kept strict identity validation for generated contact URLs while preserving the legacy AdminMessage.add_contact pass-through behavior.
  • Also cleared stale QR canvas content and stopped exposing server error text in the UI.

Validation:

  • Focused contact-sharing suites: 5 files, 24 tests passed
  • npm run typecheck: passed
  • npm run lint:ci: passed
  • client build, server build, and docs build: passed

Local caveats: typecheck:tests still reports the existing broad unrelated test-type backlog. The full test run also hit unrelated timeouts in meshcoreVirtualNodeServer.test.ts and remained held open, so it was terminated after the contact-sharing tests had passed independently.

@Yeraze

Yeraze commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Review — approve, merging

Reviewed the full diff. This conforms to the project's conventions more carefully than most contributions, and the protocol-correctness evidence is unusually strong. Details, since some of it is worth other reviewers knowing about:

Protocol correctness is actually demonstrated, not asserted. The encoder test pins byte-for-byte equality against a real known-good Meshtastic URL (expect(encodeSharedContactUrl(node())).toBe(WAM8_URL)), then round-trips every field — including a non-ASCII long name (R-SED-BLÅKAMPEN-WAM8), MAC, hwModel, role, and the 32-byte public key. That's the right way to prove wire compatibility with another client.

The security-relevant flags are pinned by test. shouldIgnore=false and manuallyVerified=false are explicitly asserted. A shared contact claiming manual verification would be a genuine misrepresentation, so it's good to see that nailed down rather than left implicit.

The identity-consistency guard is the right call. Rejecting a node whose nodeId doesn't match its nodeNum closes an identity-spoofing path, and it's covered — along with unset/broadcast node numbers, malformed MAC, and wrong public-key length — by the it.each rejection block.

Route review:

  • requirePermission('nodes', 'read', { sourceIdFrom: 'query', requireSourceId: true }) — correctly per-source scoped, with the source id required rather than defaulted.
  • The additional checkNodeChannelAccess gate on top of nodes:read is good defence in depth.
  • ok/fail envelope with SCREAMING_SNAKE codes; validation errors mapped to 400 and distinguished from a logged 500.
  • The route test uses createRouteTestApp (the required harness, not the deprecated vi.mock monkey-patch) and — notably — covers cross-source permission confusion: "does not combine node permission from one source with channel access on another." That's exactly the bug class that bit this project in Security: filterNodesByChannelPermission ignores sourceId — cross-source permission leak for guests #3745, and testing for it unprompted is appreciated.

On exposing the public key and MAC via a new endpoint: considered, and I'm satisfied. Both are broadcast openly in NodeInfo over the mesh, so nodes:read plus channel visibility is the right gate — no nodes_private involvement needed.

The ExportConfigModal refactor is behaviour-preserving. QrCodeCanvas defaults to size = 256 with margin: 2, matching the code it replaces, and the caller passes the same colours explicitly. It also clears the canvas when value is empty, which the original didn't — a small improvement, since it prevents a stale QR lingering.

Frontend conventions: UiIcon throughout (no emoji stand-ins), no raw fetch() in src/components/**, new component styled with a CSS module, locale keys added to en.json only. api.ts correctly reads body.data.url — worth noting for anyone copying it, since ApiService.request() deliberately does not unwrap the envelope.

Nothing blocking, and nothing I'd ask you to change. Thanks for the thorough PR description and the deploy-against-live-data validation — it made this much faster to review.

@Yeraze
Yeraze merged commit 5aeaf4b into Yeraze:main Jul 26, 2026
20 checks passed
Yeraze added a commit that referenced this pull request Jul 27, 2026
…4367)

* chore(release): 4.13.2 — version bump, changelog backfill, doc gaps

Rev 4.13.2-rc4 to 4.13.2 across all five version-tracked files, and bring
the documentation current with the 101 commits merged since v4.13.1.

CHANGELOG restructure. 4.13.1 shipped on 2026-07-20 without a section of its
own, so the nine entries it shipped were still sitting under [Unreleased] and
would have been reattributed to 4.13.2. They move into a new [4.13.1] section,
which notes that the GitHub release notes carry the parts never written down
at all (link-quality badges, Noise Floor, MEDIUM_TURBO, the NeighborInfo-hijack
telemetry retry). [4.13.2] now covers all 57 user-facing changes since that
tag — 48 entries, up from the 13 that had been recorded. One entry was also
stale: ATAK Phase 1 still claimed V2 was "labeled but not yet decoded", which
#4321 made untrue.

Feature-doc gaps found and filled. The large features were already documented
(ATAK, receive-only mode, ok_to_mqtt, 3D map, Link Profile, NodeInfo
Enrichment); these four shipped with none:

- docs/configurator.md — Portainer Stack export format (#4282)
- docs/features/maps.md — share a node as a Meshtastic contact (#4327)
- docs/features/automation.md — Auto-Ack resend attempts (#4266)
- docs/features/meshcore.md — {ROUTE}/{ROUTE_NAMES}/{HASH_SIZE} tokens (#4276)

The contact URL is `https://meshtastic.org/v/#…` (SharedContact), not `/e/#`
(channel set) — verified against sharedContactService.ts rather than assumed,
and the distinction is called out in the docs since a contact link carries a
public key but no PSK.

Blog post announcing the release leads with the PostgreSQL/MySQL migration
ledger fix (#4233) rather than the features: those backends replayed every
migration on each boot, and migration 030 rebuilt route_segments from scratch
each time — 865k rows deleted and reinserted per restart on one install.
That is the part affected users need in the first sentence.

Verified: tsc clean, lint:ci clean, full suite green (11,016 passed, 0 failed).
The 12 suites that failed on the first run were the fresh-worktree submodule
gap, not this change — all pass after `git submodule update --init`.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01PZtasD4tS76xq2PTDHMA5o

* docs(changelog): clarify the ATAK V2 cross-reference

Address review: inside the 4.13.2 section, "not decoded in this phase" read
oddly when the V2 decoder entry sits a few lines below in the same release.
Name the phase and say the decoder ships here too.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01PZtasD4tS76xq2PTDHMA5o

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
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