fix(admin): decouple session-passkey and config-import from the HTTP request - #4486
Conversation
…request Follow-up to #4482/#4485. Two more handlers held an HTTP request open across the same mesh round-trips that produced upstream 502s: - POST /ensure-session-passkey blocked up to 45s acquiring the passkey. It now answers immediately when one is already cached (no mesh trip needed), and otherwise returns 202 + an operation id and acquires in the background. - POST /import-config on a remote node blocked on the passkey and then a burst of admin sends. Its body moves into a `runImport` thunk: local imports still await it inline and return 200, remote imports return 202 and run in the background. The thunk's contents keep their original indentation so the diff stays reviewable — only the wrapper lines are new. Both reuse the operation registry and the /operations/:id poll endpoint from #4485, so there is no new client-side machinery: the polling loop in ApiService is extracted into a shared `followAdminOperation`, and the existing `sendAdminCommand` now routes through it alongside the new `ensureSessionPasskey` and the remote branch of `importConfig`. AdminOperationResult gains an index signature so endpoint-specific payloads (import counts, passkey expiry) ride along in the result unchanged. The six #4315 import-config tests now assert 202 and wait for the background send before checking the spy, since the send no longer completes before the response. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9
|
Claude finished @Yeraze's task —— View job PR Review: fix(admin): decouple session-passkey and config-import from the HTTP requestTasks
Overall AssessmentThis is a well-executed follow-up to #4485 that correctly applies the async-operation pattern to the two remaining blocking endpoints. The code is clean, the rationale is clearly documented, and the test suite properly covers the new behavior. A few items worth considering: adminRoutes.ts
api.ts
AdminCommandsTab.tsxNo visible changes needing review — The three Test CoverageNew async tests ( Pre-existing Missing test: SecurityThe operation ownership check at SummaryApprove — the implementation is correct and the PR description is unusually thorough. The items above are observations and suggestions, not blockers. The |
…r cold-passkey import - followAdminOperation takes an optional timeoutMs; importConfig passes 300s. The 120s default covers a single command (45s passkey + 30s ACK) but not a remote import, which adds ~1s pacing per channel plus a requestRemoteConfig round-trip on top of the passkey leg. - Document that runImport mutates enclosing-scope state and is therefore single-use, and that its body's indentation is intentional. - TODO on the import passkey being acquired once and reused across every send: it can outlive its TTL on a long import, which running detached makes more reachable. Pre-existing; flagged rather than fixed here. - Add the missing integration coverage: a full remote import from a COLD passkey (202 → acquire → channel sends → settled result), plus the cold-passkey timeout failing the import outright rather than partially. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9
|
Claude finished @Yeraze's task —— View job PR Review: fix(admin): decouple session-passkey and config-import from the HTTP requestTasks
Overall AssessmentThis is a well-structured follow-up to #4485 that correctly applies the async operation pattern to the two remaining blocking endpoints. The implementation is clean and the PR description is unusually thorough. A few observations worth noting: adminRoutes.ts
Shared mutable state ( Stale session passkey ( Error handling in 202 error handler is inconsistent with the success path ( api.ts
Missing AdminCommandsTab.tsxThe three Test CoverageNew async tests ( Cold passkey integration test added ( Updated Missing: operation failure path for remote SecurityNo new surfaces. The operation ownership check (userId matching or null === null guard) carries over from #4485. The 200/202 branching doesn't introduce a timing oracle. The passkey and admin command flows are unchanged — only the HTTP-hold-to-202 split is new. SummaryApprove — correct implementation, good documentation, solid test coverage. The |
|
Thanks — addressed in b4bd24a. Poll budget vs. import worst case — good catch, and the concern is real. I checked the pacing: the remote channel loop sleeps 1000ms per channel and then does a Missing cold-passkey import test — added, and it was worth the ask. Two cases: the full chain (202 while the passkey is outstanding, nothing sent yet → release → channel sends → settled result with the import counts), and a cold-passkey timeout failing the import outright rather than partially. You were right that every existing
Stale passkey across a long import — real, and I've left a
One correction: the
|
… outer catch - Partial imports now report what did NOT land. The channel loop absorbs per-channel errors by design, so a caller previously could not tell "1 channel" from "1 of 2 channels" — worse now that the import runs detached with no log to correlate against. Adds failedChannels / failedChannelDetails to the result (additive). - Convert both touched handlers' outer catch to fail() with machine codes (PASSKEY_FAILED / IMPORT_CONFIG_FAILED), per the response-envelope rule for modified handlers. fail() is always safe — ApiService reads error/code. - Assert IMPORT_CONFIG_FAILED on the cold-passkey timeout, which is what actually reaches the detached closure's outer catch. A separate test for that path was dropped: the per-channel and per-LoRa handlers absorb their own errors, so it could only have been staged against an unreachable path. - Cover the partial-import case: one channel sends, the next is rejected, result reports 1 imported and 1 failed with the failing channel named. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9
|
Round 2 addressed in fdfb59b. Silent partial imports — fixed, and this was the best catch of the round. The channel loop absorbs per-channel errors by design, so a caller could not distinguish "1 channel" from "1 of 2 channels" — worse now that the import runs detached with no log to correlate against. The result carries Bare Missing outer-catch test — worth flagging how this resolved. I first wrote a test that faked a throw from
|
|
Claude finished @Yeraze's task —— View job PR Review: fix(admin): decouple session-passkey and config-import from the HTTP requestTasks
Overall AssessmentThis is a well-polished Round 3 that has fully addressed the concerns from the two prior review passes. The implementation is correct, the documentation comments are thorough, and the test coverage is solid. A few remaining observations below, none of which are blockers. adminRoutes.ts
api.ts
adminOperationService.tsThe Sweep behavior on Test Coverage
Cold-passkey timeout test ( Local node import path ( SecurityNo new surfaces. The operation ownership check ( SummaryApprove — all three review rounds' concerns have been addressed. The |
|
Thanks — stopping the fix loop here, with the two remaining items called out rather than silently dropped. Missing No local-path test for
Everything else from all three rounds is addressed. Remaining open item is unchanged: |
* docs(admin): document asynchronous remote-admin execution Remote admin commands, session-passkey acquisition, and remote config import all became asynchronous in #4485/#4486 (issue #4482), but that shipped with no user-facing documentation. This fills the gap in the Remote Node Support section: - asynchronous execution + completion polling as numbered behaviors - the 202 + operation-id contract, the poll endpoint, and the fact that a cached passkey still answers 200 immediately - the privacy property the implementation already holds but never stated: operation records keep the command name, source/destination, lifecycle state, and timestamps — never command parameters or session keys - lifecycle states, ten-minute terminal retention, and that a restart surfaces as an interrupted operation rather than a success - Confirmation Semantics for favorite/ignore: confirmed vs rejected vs timeout-is-uncertain, and why the optimistic update survives a timeout - partial config imports reporting what failed alongside what landed The prose originates from @wilhel1812's PR #4483, which solved the same issue independently and documented it properly. That PR was preempted by #4485 landing first; this ports its documentation, adapted to the endpoint and error-code names that actually shipped. Co-Authored-By: wilhel1812 <wilhel1812@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9 * docs(admin): tighten accuracy of the async remote-admin section Review follow-ups, all corrections to claims I made: - The privacy sentence omitted userId, which AdminOperation does store for the ownership check. On a claim about what is retained, an incomplete list is the one kind of error that actually matters — now names the requesting administrator explicitly. - Spell out both halves of the ensure-session-passkey contract: an uncached acquisition returns 202, a cached one answers 200. The earlier wording described only the 200 case and left the async path implicit. - Note that not every command visits every lifecycle state — config import goes from awaiting_passkey straight to terminal, since it sends a sequence of packets rather than awaiting one routing ACK. - Clarify that "successful once transmitted" means no routing ACK is awaited, not that the command cannot fail: an unsendable command (e.g. TX_DISABLED) still fails. Co-Authored-By: wilhel1812 <wilhel1812@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9 --------- Co-authored-by: wilhel1812 <wilhel1812@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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>
Follow-up to #4482 / #4485 — the remaining handlers with the same blocking pattern.
Correction to what I reported on #4485
I said the affected handlers were "config export (~L650) and config import (~L1245)". The first was wrong: L650 is
POST /ensure-session-passkey, not export-config.export-configreads channels from the DB/cache and never touches the passkey, so it was never affected. The actual pair is/ensure-session-passkeyand/import-config.The two handlers
POST /ensure-session-passkeyexisted to acquire the passkey, blocking up to 45s doing it. Now:POST /import-configon a remote node blocked on the passkey and a subsequent burst of admin sends (one per channel, plus LoRa config) — the longest hold of the three endpoints. Its body moves into arunImportthunk: local imports stillawaitit inline and return 200, remote imports return 202 and run in the background.The thunk's contents are deliberately left at their original indentation so the diff shows only the wrapper lines rather than a 165-line reflow. Worth knowing when reading the diff.
No new client machinery
Both reuse the operation registry and
GET /operations/:idfrom #4485. The polling loop inApiServiceis extracted into a sharedfollowAdminOperation, and three callers now route through it:sendAdminCommand(unchanged behavior, just refactored onto the shared follower)ensureSessionPasskey— replaces the three rawapiService.post('/api/admin/ensure-session-passkey', …)call sites inAdminCommandsTabimportConfig's remote branchAdminOperationResultgains an index signature so endpoint-specific payloads (import counts, passkey expiry fields) ride along in the result unchanged — the client spreads the whole result, so callers see the same shape they did before.Testing
success: true, 12,946 passed / 0 failed, 0 failed suites.tscclean,lint:ciclean with no baseline growth.adminRoutes.asyncOperations.test.ts(+4): cached passkey answers 200 without opening an operation and without calling the acquisition path at all; a cold passkey returns 202 while acquisition is outstanding (mock gated on an unresolved promise) then settles with the expiry status; timeout recordsPASSKEY_TIMEOUT; local node still synchronous.#4315import-config tests needed updating — they asserted 200 and then checked acreateSetLoRaConfigMessagespy. Remote import now returns 202 and the send happens after the response, so they assert 202 andawait vi.waitFor(...)the spy. Their actual subject (txEnabled preservation precedence) is unchanged.Still not hardware-validated
Same caveat as #4485: no
system-testlabel, so the async path has not run against a real remote node./import-configis the one I would most want exercised on hardware — it is the longest sequence and the one the Configuration Import system-test leg covers.🤖 Generated with Claude Code
https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9