From 9aaa8573c0ecceade58a613c209effeab52aeec0 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 17 May 2026 01:48:02 -0400 Subject: [PATCH] fix(mesh): gate Trigger 2 re-bootstrap on actual api_token change (#1076) The PUT /api/nodes/:id handler closed and re-dialed the mesh callback bridge on every save that included api_token in the body, even when the token was unchanged. The frontend always sends the full formData on Save, so renames and compose_dir edits against a mesh-enabled proxy remote produced a wasted closeBridge + ensureBridge round-trip and a spurious manager_rejected entry in the activity log. Gate the re-bootstrap on a real value diff against the persisted token. Adds an existing-node lookup (returns 404 on missing id, which the handler previously lacked) so the comparison has the pre-update value. Reorders the guards so resource-not-found beats payload validation. Adds one integration test for the same-token path; the existing three trigger-2 cases continue to assert close + ensure firing on a real rotation, no-token-in-payload, and mesh-disabled. --- .../mesh-token-rotation-integration.test.ts | 20 +++++++++++++++++++ backend/src/routes/nodes.ts | 17 ++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/backend/src/__tests__/mesh-token-rotation-integration.test.ts b/backend/src/__tests__/mesh-token-rotation-integration.test.ts index 9fd2e1a4..217cf406 100644 --- a/backend/src/__tests__/mesh-token-rotation-integration.test.ts +++ b/backend/src/__tests__/mesh-token-rotation-integration.test.ts @@ -174,4 +174,24 @@ describe('Trigger 2: api_token rotation forces re-bootstrap', () => { expect(closeSpy).not.toHaveBeenCalled(); expect(ensureSpy).not.toHaveBeenCalled(); }); + + it('does not fire when api_token in payload equals the current value', async () => { + const closeSpy = vi.spyOn(MeshProxyTunnelDialer.getInstance(), 'closeBridge'); + const ensureSpy = vi.spyOn(MeshProxyTunnelDialer.getInstance(), 'ensureBridge') + .mockResolvedValue(null); + const nodeId = seedProxyNode(true); + const existingToken = DatabaseService.getInstance().getNode(nodeId)?.api_token; + expect(existingToken).toBeTruthy(); + + const res = await request(app) + .put(`/api/nodes/${nodeId}`) + .set('Authorization', authHeader) + .send({ name: `renamed-${uniqueSuffix()}`, api_token: existingToken }); + + expect(res.status).toBe(200); + await new Promise((r) => setImmediate(r)); + + expect(closeSpy).not.toHaveBeenCalled(); + expect(ensureSpy).not.toHaveBeenCalled(); + }); }); diff --git a/backend/src/routes/nodes.ts b/backend/src/routes/nodes.ts index 2ff64bab..e051a4dc 100644 --- a/backend/src/routes/nodes.ts +++ b/backend/src/routes/nodes.ts @@ -224,6 +224,11 @@ nodesRouter.put('/:id', async (req: Request, res: Response) => { const id = parseInt(nodeId); const updates = req.body; + const existingNode = DatabaseService.getInstance().getNode(id); + if (!existingNode) { + return res.status(404).json({ error: 'Node not found' }); + } + if (updates.api_url !== undefined && updates.api_url !== '') { const urlCheck = isValidRemoteUrl(updates.api_url); if (!urlCheck.valid) { @@ -239,11 +244,15 @@ nodesRouter.put('/:id', async (req: Request, res: Response) => { // Trigger 2: if the api_token was rotated on a mesh-enabled proxy-mode // remote, close the existing callback bridge and re-dial. The next // ensureBridge mints a JWT with the fresh token fingerprint so the - // remote's tunnel auth gate accepts the upgrade. - if (typeof updates.api_token === 'string') { - const node = DatabaseService.getInstance().getNode(id); + // remote's tunnel auth gate accepts the upgrade. Gate on actual value + // change so a Save that only edits name / compose_dir does not cycle + // the bridge (the frontend sends the full formData on every Save). + const tokenChanged = + typeof updates.api_token === 'string' && + updates.api_token !== existingNode.api_token; + if (tokenChanged) { const meshEnabled = DatabaseService.getInstance().getNodeMeshEnabled(id); - if (node && node.type === 'remote' && node.mode === 'proxy' && meshEnabled) { + if (existingNode.type === 'remote' && existingNode.mode === 'proxy' && meshEnabled) { MeshProxyTunnelDialer.getInstance().closeBridge(id, 'peer token rotated'); void MeshProxyTunnelDialer.getInstance().ensureBridge(id).catch((err) => { console.warn(`[Mesh] proactive re-bootstrap on token rotation failed for node ${id}: ${(err as Error).message}`);