From 3d5f0ffacdc3deecfecbea1e9766c68e27b2aa16 Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 15 May 2026 09:00:24 -0400 Subject: [PATCH] fix(mesh): reject opt-in when every service has empty ports (#1056) optInStack rejected empty service lists but not the case where services exist with every ports: []. The newPorts Set ended up empty, the function sailed past collision checks, wrote a mesh_stacks row, and produced an empty-aliases override. The stack then appeared online in the Routing tab but no traffic actually routed. Add a check after building newPorts: throw no_target with a clear message before the SENCHO_LISTEN_PORT collision check. Reuses the existing MeshError code (semantically: no routable target) so the frontend's error toast path is unchanged. --- backend/src/__tests__/mesh-service.test.ts | 20 ++++++++++++++++++++ backend/src/services/MeshService.ts | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/backend/src/__tests__/mesh-service.test.ts b/backend/src/__tests__/mesh-service.test.ts index c887c017..fe120c26 100644 --- a/backend/src/__tests__/mesh-service.test.ts +++ b/backend/src/__tests__/mesh-service.test.ts @@ -70,6 +70,26 @@ describe('MeshService.optInStack', () => { .rejects.toThrow(/port 5432 is already claimed/); }); + it('rejects opt-in when every service declared empty ports (no routable target)', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + + vi.spyOn(svc as unknown as { inspectStackServices: (n: number, s: string) => Promise }, 'inspectStackServices') + .mockResolvedValue([ + { service: 'web', ports: [] }, + { service: 'db', ports: [] }, + ]); + vi.spyOn(svc as unknown as { regenerateOverridesForNode: (n: number) => Promise }, 'regenerateOverridesForNode') + .mockResolvedValue(undefined); + + await expect(svc.optInStack(localNodeId, 'silent', 'tester')) + .rejects.toThrow(/no service ports to mesh/); + // Pre-fix this wrote a row with no aliases and the stack appeared + // "online but doesn't route". Verify the row was not written. + expect(db.isMeshStackEnabled(localNodeId, 'silent')).toBe(false); + }); + it('opt-out removes the row and the override', async () => { const svc = MeshService.getInstance(); vi.spyOn(svc as unknown as { inspectStackServices: (n: number, s: string) => Promise }, 'inspectStackServices') diff --git a/backend/src/services/MeshService.ts b/backend/src/services/MeshService.ts index 4fb77ba8..52cf814c 100644 --- a/backend/src/services/MeshService.ts +++ b/backend/src/services/MeshService.ts @@ -565,6 +565,12 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { const newPorts = new Set(); for (const svc of services) for (const p of svc.ports) newPorts.add(p); + if (newPorts.size === 0) { + throw new MeshError( + 'no_target', + `stack ${stackName} has no service ports to mesh (every service declared ports: [])`, + ); + } if (newPorts.has(SENCHO_LISTEN_PORT)) { throw new MeshError( 'port_collision',