From 65a69d9ecc7ff9b484e69b7e8c71473356008ff5 Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 2 Jun 2026 10:26:31 -0400 Subject: [PATCH] fix(nodes): never send stored node tokens to clients (#1281) Node read endpoints now return a client-safe projection that omits the stored api_token and exposes a has_token boolean instead, so a node's long-lived proxy credential is never serialized to a browser or API token client. The token stays encrypted at rest and is read server-side only by the components that need it (the remote proxy, the connection test, and the mesh dialer). The edit form opens the API Token field blank, and a blank value keeps the existing credential, so saving an edit without retyping the token no longer clears it; a non-empty value rotates it. The backend enforces the same rule defensively. Node management actions (add, edit, delete, test connection, generate node token) are gated in the UI to match their server-side permission checks, so operators no longer see an action the API would reject. The test-connection route also gains the missing server-side permission and token-scope guards. Also validate the x-node-id header and fall back to the default node for malformed values instead of an obscure 404, and return 400 (not 500) when deleting the default node. --- backend/src/__tests__/nodes.test.ts | 150 ++++++++++++++++++ backend/src/helpers/publicNode.ts | 17 ++ backend/src/middleware/nodeContext.ts | 26 ++- backend/src/routes/nodes.ts | 38 ++++- docs/features/multi-node.mdx | 6 +- .../hooks/useNotifications.test.ts | 2 +- frontend/src/components/NodeManager.tsx | 40 ++--- .../src/components/nodes/useNodeActions.tsx | 30 +++- frontend/src/context/NodeContext.tsx | 4 +- 9 files changed, 273 insertions(+), 40 deletions(-) create mode 100644 backend/src/helpers/publicNode.ts diff --git a/backend/src/__tests__/nodes.test.ts b/backend/src/__tests__/nodes.test.ts index 6c5c40d4..25ece4c4 100644 --- a/backend/src/__tests__/nodes.test.ts +++ b/backend/src/__tests__/nodes.test.ts @@ -8,6 +8,34 @@ import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './he import { disableCapability, enableCapability } from '../services/CapabilityRegistry'; import { NodeRegistry } from '../services/NodeRegistry'; import { CacheService } from '../services/CacheService'; +import { DatabaseService } from '../services/DatabaseService'; +import { nodeContextMiddleware } from '../middleware/nodeContext'; + +/** Mint a Bearer for a non-admin user, creating the row if needed so + * authMiddleware (which resolves the role from the DB) sees the real role. */ +function tokenForRole(username: string, role: 'viewer' | 'deployer'): string { + const db = DatabaseService.getInstance(); + if (!db.getUserByUsername(username)) { + db.addUser({ username, password_hash: 'x', role }); + } + return `Bearer ${jwt.sign({ username }, TEST_JWT_SECRET, { expiresIn: '1m' })}`; +} + +async function createRemoteNode(token: string): Promise { + const res = await request(app) + .post('/api/nodes') + .set('Authorization', token) + .send({ + name: `remote-${Date.now()}-${Math.random().toString(36).slice(2)}`, + type: 'remote', + mode: 'proxy', + api_url: 'http://192.168.1.77:1852', + api_token: 'tok-original', + compose_dir: '/app/compose', + }); + expect(res.status).toBe(200); + return res.body.id as number; +} let tmpDir: string; let app: import('express').Express; @@ -135,3 +163,125 @@ describe('Stack name validation on GET routes (H3 fix)', () => { expect(res.status).toBe(400); }); }); + +describe('Node read endpoints never leak the api_token (C-1)', () => { + it('GET /api/nodes omits api_token and exposes has_token instead', async () => { + const id = await createRemoteNode(authHeader); + const res = await request(app).get('/api/nodes').set('Authorization', authHeader); + expect(res.status).toBe(200); + const nodes = res.body as Array>; + for (const n of nodes) { + expect(Object.prototype.hasOwnProperty.call(n, 'api_token')).toBe(false); + expect(typeof n.has_token).toBe('boolean'); + } + const created = nodes.find((n) => n.id === id)!; + expect(created.has_token).toBe(true); + const local = nodes.find((n) => n.type === 'local')!; + expect(local.has_token).toBe(false); + }); + + it('GET /api/nodes/:id omits api_token and exposes has_token', async () => { + const id = await createRemoteNode(authHeader); + const res = await request(app).get(`/api/nodes/${id}`).set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(Object.prototype.hasOwnProperty.call(res.body, 'api_token')).toBe(false); + expect(res.body.has_token).toBe(true); + // The decrypted secret still lives server-side for the proxy / test paths. + expect(DatabaseService.getInstance().getNode(id)?.api_token).toBe('tok-original'); + }); +}); + +describe('PUT /api/nodes/:id preserves the token unless a new one is supplied (H-1)', () => { + it('keeps the stored token when api_token is omitted', async () => { + const id = await createRemoteNode(authHeader); + const res = await request(app) + .put(`/api/nodes/${id}`) + .set('Authorization', authHeader) + .send({ name: 'renamed-keep', api_url: 'http://192.168.1.77:1852', compose_dir: '/app/compose' }); + expect(res.status).toBe(200); + expect(DatabaseService.getInstance().getNode(id)?.api_token).toBe('tok-original'); + }); + + it('keeps the stored token when api_token is an empty string', async () => { + const id = await createRemoteNode(authHeader); + const res = await request(app) + .put(`/api/nodes/${id}`) + .set('Authorization', authHeader) + .send({ name: 'renamed-blank', api_token: '', api_url: 'http://192.168.1.77:1852', compose_dir: '/app/compose' }); + expect(res.status).toBe(200); + expect(DatabaseService.getInstance().getNode(id)?.api_token).toBe('tok-original'); + }); + + it('rotates the token when a non-empty api_token is supplied', async () => { + const id = await createRemoteNode(authHeader); + const res = await request(app) + .put(`/api/nodes/${id}`) + .set('Authorization', authHeader) + .send({ name: 'renamed-rotate', api_token: 'tok-new', api_url: 'http://192.168.1.77:1852', compose_dir: '/app/compose' }); + expect(res.status).toBe(200); + expect(DatabaseService.getInstance().getNode(id)?.api_token).toBe('tok-new'); + }); +}); + +describe('POST /api/nodes/:id/test authorization (H-3)', () => { + it('403s a non-admin (viewer) with PERMISSION_DENIED', async () => { + const id = await createRemoteNode(authHeader); + const res = await request(app) + .post(`/api/nodes/${id}/test`) + .set('Authorization', tokenForRole('node-test-viewer', 'viewer')); + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('403s a full-admin API token with SCOPE_DENIED', async () => { + const id = await createRemoteNode(authHeader); + const mint = await request(app) + .post('/api/api-tokens') + .set('Authorization', authHeader) + .send({ name: `node-test-reject-${Date.now()}`, scope: 'full-admin' }); + const apiToken = mint.body.token as string; + expect(apiToken).toBeTruthy(); + const res = await request(app) + .post(`/api/nodes/${id}/test`) + .set('Authorization', `Bearer ${apiToken}`); + expect(res.status).toBe(403); + expect(res.body.code).toBe('SCOPE_DENIED'); + }); +}); + +describe('nodeContextMiddleware nodeId validation (M-2)', () => { + type MiddlewareReq = { headers: Record; query: Record; path: string; nodeId?: number }; + + function run(req: MiddlewareReq): { status: number; nextCalled: boolean } { + let status = 0; + let nextCalled = false; + const res = { status: (c: number) => { status = c; return { json: () => undefined }; } }; + nodeContextMiddleware(req as never, res as never, (() => { nextCalled = true; }) as never); + return { status, nextCalled }; + } + + it('falls back to the default node for a malformed x-node-id instead of 404', () => { + const req: MiddlewareReq = { headers: { 'x-node-id': 'abc' }, query: {}, path: '/api/stats' }; + const { status, nextCalled } = run(req); + expect(nextCalled).toBe(true); + expect(status).toBe(0); + expect(req.nodeId).toBe(NodeRegistry.getInstance().getDefaultNodeId()); + }); + + it('still 404s a well-formed but non-existent node id', () => { + const req: MiddlewareReq = { headers: { 'x-node-id': '999999' }, query: {}, path: '/api/stats' }; + const { status, nextCalled } = run(req); + expect(status).toBe(404); + expect(nextCalled).toBe(false); + }); +}); + +describe('DELETE /api/nodes/:id default-node guard (M-4)', () => { + it('400s when deleting the default node', async () => { + const list = await request(app).get('/api/nodes').set('Authorization', authHeader); + const def = (list.body as Array<{ id: number; is_default: boolean }>).find((n) => n.is_default)!; + const res = await request(app).delete(`/api/nodes/${def.id}`).set('Authorization', authHeader); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/default node/i); + }); +}); diff --git a/backend/src/helpers/publicNode.ts b/backend/src/helpers/publicNode.ts new file mode 100644 index 00000000..24f3adf3 --- /dev/null +++ b/backend/src/helpers/publicNode.ts @@ -0,0 +1,17 @@ +import type { Node } from '../services/DatabaseService'; + +/** + * Node shape safe to send to a browser or API-token client. The stored + * `api_token` (a long-lived node_proxy Bearer credential) is never serialized; + * callers that need to know whether a token is configured read `has_token` + * instead. The plaintext credential stays server-side, read via + * `DatabaseService.getNode` by the components that genuinely need it (for + * example the remote proxy, the connection test, and the mesh dialer). + */ +export type PublicNode = Omit & { has_token: boolean }; + +/** Project a stored Node into its client-safe form, dropping the api_token. */ +export function toPublicNode(node: Node): PublicNode { + const { api_token, ...rest } = node; + return { ...rest, has_token: typeof api_token === 'string' && api_token.length > 0 }; +} diff --git a/backend/src/middleware/nodeContext.ts b/backend/src/middleware/nodeContext.ts index 710479b3..3d4ed8b0 100644 --- a/backend/src/middleware/nodeContext.ts +++ b/backend/src/middleware/nodeContext.ts @@ -2,6 +2,7 @@ import type { Request, Response, NextFunction, RequestHandler } from 'express'; import { NodeRegistry } from '../services/NodeRegistry'; import { DatabaseService } from '../services/DatabaseService'; import { isProxyExemptPath } from '../helpers/proxyExemptPaths'; +import { sanitizeForLog } from '../utils/safeLog'; /** * Resolve `req.nodeId` from the `x-node-id` header, `?nodeId=` query param, @@ -15,13 +16,24 @@ import { isProxyExemptPath } from '../helpers/proxyExemptPaths'; export const nodeContextMiddleware: RequestHandler = (req: Request, res: Response, next: NextFunction) => { const nodeIdHeader = req.headers['x-node-id'] as string; const nodeIdQuery = req.query.nodeId as string; - if (nodeIdHeader) { - req.nodeId = parseInt(nodeIdHeader, 10); - } else if (nodeIdQuery) { - req.nodeId = parseInt(nodeIdQuery, 10); - } else { - req.nodeId = NodeRegistry.getInstance().getDefaultNodeId(); - } + // A malformed id (parseInt → NaN, or a non-positive value) must fall back to + // the default node rather than resolve to NaN and trip the obscure 404 below. + // A well-formed id for a node that does not exist still 404s further down; + // only malformed input falls through to the default. + const parseNodeId = (raw: string | undefined): number | null => { + if (!raw) return null; + const n = parseInt(raw, 10); + if (Number.isInteger(n) && n > 0) return n; + // Present but malformed is a client bug: warn so the fall-back to the + // default node is observable instead of a request silently landing on the + // wrong node during debugging. + console.warn(`[NodeContext] Ignoring malformed node id "${sanitizeForLog(raw)}"; using the default node.`); + return null; + }; + req.nodeId = + parseNodeId(nodeIdHeader) ?? + parseNodeId(nodeIdQuery) ?? + NodeRegistry.getInstance().getDefaultNodeId(); if (req.path.startsWith('/api/') && !isProxyExemptPath(req.path)) { const node = DatabaseService.getInstance().getNode(req.nodeId); diff --git a/backend/src/routes/nodes.ts b/backend/src/routes/nodes.ts index e8809aa7..914a0f89 100644 --- a/backend/src/routes/nodes.ts +++ b/backend/src/routes/nodes.ts @@ -18,6 +18,7 @@ import { FleetUpdateTrackerService } from '../services/FleetUpdateTrackerService import { FleetSyncService } from '../services/FleetSyncService'; import { isValidRemoteUrl } from '../utils/validation'; import { getErrorMessage } from '../utils/errors'; +import { toPublicNode } from '../helpers/publicNode'; import { isDebugEnabled } from '../utils/debug'; import { sanitizeForLog } from '../utils/safeLog'; @@ -95,7 +96,7 @@ export const nodesRouter = Router(); nodesRouter.get('/', async (req: Request, res: Response) => { try { const nodes = DatabaseService.getInstance().getNodes(); - res.json(nodes); + res.json(nodes.map(toPublicNode)); } catch (error) { res.status(500).json({ error: 'Failed to fetch nodes' }); } @@ -149,7 +150,7 @@ nodesRouter.get('/:id', async (req: Request, res: Response) => { if (!node) { return res.status(404).json({ error: 'Node not found' }); } - res.json(node); + res.json(toPublicNode(node)); } catch (error) { res.status(500).json({ error: 'Failed to fetch node' }); } @@ -191,6 +192,7 @@ nodesRouter.post('/', enrollmentLimiter, async (req: Request, res: Response) => }); NodeRegistry.getInstance().notifyNodeAdded(id); + console.log(`[Nodes] Created ${type} node "${sanitizeForLog(name)}" (id=${id}, mode=${resolvedMode})`); // Backfill replicated security state on the new remote so an operator who // adds a node mid-life does not have to wait for the next policy edit @@ -257,6 +259,16 @@ nodesRouter.put('/:id', async (req: Request, res: Response) => { const id = parseInt(nodeId); const updates = req.body; + // Keep the existing credential unless a real new token is supplied: the + // stored token is never returned to the client, so an untouched edit form + // must not clear it. Anything that is not a non-empty string (blank, null, + // missing, wrong type) drops the field here (defense-in-depth alongside the + // frontend) so it can never overwrite a configured token; only a non-empty + // string rotates it. + if (typeof updates.api_token !== 'string' || updates.api_token.trim() === '') { + delete updates.api_token; + } + const existingNode = DatabaseService.getInstance().getNode(id); if (!existingNode) { return res.status(404).json({ error: 'Node not found' }); @@ -273,6 +285,7 @@ nodesRouter.put('/:id', async (req: Request, res: Response) => { NodeRegistry.getInstance().evictConnection(id); NodeRegistry.getInstance().notifyNodeUpdated(id); + console.log(`[Nodes] Updated node ${id} ("${sanitizeForLog(existingNode.name)}")`); // 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 @@ -313,6 +326,13 @@ nodesRouter.delete('/:id', async (req: Request, res: Response) => { if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return; try { const id = parseInt(nodeIdParam); + const existing = DatabaseService.getInstance().getNode(id); + if (!existing) { + return res.status(404).json({ error: 'Node not found' }); + } + if (existing.is_default) { + return res.status(400).json({ error: 'Cannot delete the default node' }); + } // Release any live tunnel or mesh bridge before deleting the record so it is // freed immediately rather than lingering until the peer disconnects. Close a // proxy-mode mesh bridge through its dialer FIRST: closeBridge removes the @@ -327,6 +347,7 @@ nodesRouter.delete('/:id', async (req: Request, res: Response) => { NodeRegistry.getInstance().notifyNodeRemoved(id); CacheService.getInstance().invalidate(`${REMOTE_META_NAMESPACE}:${id}`); FleetUpdateTrackerService.getInstance().delete(id); + console.log(`[Nodes] Deleted node ${id} ("${sanitizeForLog(existing.name)}")`); res.json({ success: true }); } catch (error: unknown) { console.error('Failed to delete node:', error); @@ -485,13 +506,23 @@ nodesRouter.post('/:id/fleet-sync/reset-anchor', async (req: Request, res: Respo }); nodesRouter.post('/:id/test', async (req: Request, res: Response) => { + if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return; + const nodeIdParam = req.params.id as string; + if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return; try { - const id = parseInt(req.params.id as string); + const id = parseInt(nodeIdParam); + const startedAt = Date.now(); const result = await NodeRegistry.getInstance().testConnection(id); // An explicit test is the operator asking for fresh truth: drop the cached // /api/meta so the next read rebuilds version and capabilities live rather // than serving a value up to the remote-meta TTL old. invalidateRemoteMetaCache(id); + if (!result.success) { + console.warn(`[Nodes] Connection test failed for node ${id}: ${sanitizeForLog(result.error ?? 'unknown')}`); + } + if (isDebugEnabled()) { + console.log(`[Nodes:diag] test node=${id} success=${result.success} ms=${Date.now() - startedAt}`); + } res.json(result); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Connection test failed'; @@ -507,6 +538,7 @@ nodesRouter.get('/:id/meta', authMiddleware, async (req: Request, res: Response) res.status(404).json({ error: 'Node not found' }); return; } + if (isDebugEnabled()) console.log(`[Nodes:diag] meta node=${id} type=${node.type}`); if (node.type === 'local') { res.json({ version: getSenchoVersion(), capabilities: getActiveCapabilities() }); diff --git a/docs/features/multi-node.mdx b/docs/features/multi-node.mdx index 1d265e65..e79540de 100644 --- a/docs/features/multi-node.mdx +++ b/docs/features/multi-node.mdx @@ -15,6 +15,10 @@ There is no central server. Each Sencho instance manages its own host independen Remote nodes connect in one of two modes. Pick the one that matches your network topology before you add the node, because the add-node flow branches on the choice. + + Adding, editing, removing, and testing nodes, and generating a node token, require an administrator account. + + ## The local node Your control Sencho instance is always listed as **Local**. It is the default node, marked with a star icon, and cannot be deleted. All operations on the local node run directly against the host's Docker socket. @@ -212,7 +216,7 @@ This means: ## Editing and deleting nodes -Click the pencil icon on any row to edit its name, URL, token, or compose directory. For a Pilot Agent row, the Edit modal also surfaces the **Regenerate enrollment token** card described earlier. +Click the pencil icon on any row to edit its name, URL, token, or compose directory. The API Token field opens blank for security: leave it blank to keep the current token, or paste a new one to rotate it. For a Pilot Agent row, the Edit modal also surfaces the **Regenerate enrollment token** card described earlier. Click the trash icon to remove a remote node. The local row hides this icon because the default node cannot be deleted. Removing a node only deletes the routing entry on the control instance; the remote Sencho instance and its containers are not touched. diff --git a/frontend/src/components/EditorLayout/hooks/useNotifications.test.ts b/frontend/src/components/EditorLayout/hooks/useNotifications.test.ts index c4e338ab..1be2ab2f 100644 --- a/frontend/src/components/EditorLayout/hooks/useNotifications.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useNotifications.test.ts @@ -12,7 +12,7 @@ vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn() } })); import { apiFetch } from '@/lib/api'; -const localNode: Node = { id: 1, name: 'Local', type: 'local', api_url: '', api_token: '', compose_dir: '', is_default: true, status: 'online', created_at: 0 }; +const localNode: Node = { id: 1, name: 'Local', type: 'local', api_url: '', compose_dir: '', is_default: true, status: 'online', created_at: 0 }; const makeNotif = (overrides: Partial = {}): NotificationItem => ({ id: 1, level: 'info', message: 'test', timestamp: 1000, is_read: 0, ...overrides, diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index 441ae5ea..afc5ef45 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -38,8 +38,8 @@ export function NodeManager() { const { isAdmin, can } = useAuth(); const canEditLabels = isPaid && isAdmin; // Mirror the backend node:manage guard. This top-level flag checks the global - // role only (admin or global node-admin); the per-row Edit/Delete buttons below - // additionally honor scoped per-node grants via can('node:manage', 'node', id). + // role only (admin or global node-admin); the per-row Test/Edit/Delete buttons + // below additionally honor scoped per-node grants via can('node:manage', 'node', id). // Admins resolve immediately via isAdmin; node-admins once /permissions/me lands. // Generate-token and reset-anchor below stay admin-only to match their stricter // backend guards (requireAdmin, and requireAdmin + requirePaid). @@ -453,23 +453,25 @@ export function NodeManager() { - - - - - - Test Connection - - + {canManageThis && ( + + + + + + Test Connection + + + )} {canManageThis && ( diff --git a/frontend/src/components/nodes/useNodeActions.tsx b/frontend/src/components/nodes/useNodeActions.tsx index 9c7cb08e..1a1812cb 100644 --- a/frontend/src/components/nodes/useNodeActions.tsx +++ b/frontend/src/components/nodes/useNodeActions.tsx @@ -138,9 +138,14 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions const handleEdit = async () => { if (editingNodeId === null) return; try { + // A blank token means "keep the existing credential": the stored token is + // never sent to the browser, so an untouched field must not overwrite it. + // Only a non-empty value rotates the token on the backend. + const { api_token, ...rest } = formData; + const payload = api_token.trim() ? formData : rest; const res = await apiFetch(`/nodes/${editingNodeId}`, { method: 'PUT', - body: JSON.stringify(formData), + body: JSON.stringify(payload), }); if (!res.ok) { const err = await res.json(); @@ -222,18 +227,21 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions }, []); const openEdit = useCallback((node: Node) => { + // The stored api_token is never returned to the browser, so the token field + // always opens blank. A blank value on save keeps the existing credential; + // typing a new value rotates it. setFormData({ name: node.name, type: node.type, mode: (node.mode === 'pilot_agent' ? 'pilot_agent' : 'proxy'), api_url: node.api_url || '', - api_token: node.api_token || '', + api_token: '', compose_dir: node.compose_dir, is_default: node.is_default, }); setOriginalEditValues({ api_url: node.api_url || '', - api_token: node.api_token || '', + api_token: '', }); setEditingNodeId(node.id); setEditOpen(true); @@ -244,7 +252,7 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions setDeleteOpen(true); }, []); - const renderFormFields = () => ( + const renderFormFields = (isEdit: boolean) => (
@@ -331,12 +339,18 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions setFormData({ ...formData, api_token: e.target.value })} />

- Generate this token on the remote Sencho instance using the "Generate Node Token" button in its Settings → Nodes panel. + {isEdit ? ( + 'Leave blank to keep the existing token. Paste a new one only to rotate it.' + ) : ( + <>Generate this token on the remote Sencho instance using the "Generate Node Token" button in its Settings → Nodes panel. + )}

@@ -372,7 +386,7 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions title={formData.type === 'local' ? 'Add local node' : 'Add remote node'} description="Register a Sencho node so you can manage it from this console." /> - {renderFormFields()} + {renderFormFields(false)} setCreateOpen(false)}>Cancel @@ -405,7 +419,7 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions > - {renderFormFields()} + {renderFormFields(true)} {formData.type === 'remote' && formData.mode === 'pilot_agent' && editingNodeId !== null && (

diff --git a/frontend/src/context/NodeContext.tsx b/frontend/src/context/NodeContext.tsx index 2647e3e5..6b9f39b9 100644 --- a/frontend/src/context/NodeContext.tsx +++ b/frontend/src/context/NodeContext.tsx @@ -14,7 +14,9 @@ export interface Node { status: 'online' | 'offline' | 'unknown'; created_at: number; api_url?: string; - api_token?: string; + /** True when a node_proxy token is stored server-side. The token value itself + * is never sent to the browser (see backend helpers/publicNode.ts). */ + has_token?: boolean; pilot_last_seen?: number | null; pilot_agent_version?: string | null; }