Add node location access RPCs and operator visibility

This commit is contained in:
William Valentin
2026-02-16 12:30:55 -08:00
parent 1d16cd54e6
commit fe8674e108
19 changed files with 693 additions and 15 deletions
+5
View File
@@ -153,6 +153,11 @@ const PATCHABLE_KEYS: Record<string, (config: Config, value: unknown) => boolean
config.server.queue.summarize_overflow = value;
return true;
},
'server.nodes.location.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
config.server.nodes.location.enabled = value;
return true;
},
};
export function createConfigHandlers(deps: ConfigHandlerDeps) {
+69 -1
View File
@@ -155,6 +155,64 @@ describe('system handlers', () => {
expect(presence[0]?.channel).toBe('telegram');
expect(getPath(result.result, 'summary')).toEqual({ total: 1, online: 1, offline: 0 });
});
it('system.location returns empty result when getNodeLocations is not provided', async () => {
const req: GatewayRequest = { id: 6, method: 'system.location' };
const result = await handlers['system.location'](req) as GatewayResponse;
expect(result.id).toBe(6);
expect(getPath(result.result, 'locations')).toEqual([]);
expect(getPath(result.result, 'summary')).toEqual({ total: 0 });
});
it('system.location returns filtered node locations', async () => {
const handlers = createSystemHandlers({
...deps,
getNodeLocations: ({ role, nodeId, limit } = {}) => {
const all = [
{
nodeId: 'node-1',
role: 'companion',
connectionId: 'c1',
location: {
latitude: 37.7,
longitude: -122.4,
source: 'gps' as const,
capturedAt: 1000,
receivedAt: 1005,
},
},
{
nodeId: 'node-2',
role: 'observer',
connectionId: 'c2',
location: {
latitude: 40.7,
longitude: -74.0,
source: 'network' as const,
capturedAt: 900,
receivedAt: 905,
},
},
];
return all
.filter((entry) => !role || entry.role === role)
.filter((entry) => !nodeId || entry.nodeId === nodeId)
.slice(0, limit ?? 100);
},
});
const req: GatewayRequest = {
id: 7,
method: 'system.location',
params: { role: 'companion', limit: 1 },
};
const result = await handlers['system.location'](req) as GatewayResponse;
const locations = getPath(result.result, 'locations') as Array<{ nodeId: string }>;
expect(locations).toHaveLength(1);
expect(locations[0]?.nodeId).toBe('node-1');
expect(getPath(result.result, 'summary')).toEqual({ total: 1 });
});
});
describe('system.tokenUsage handler', () => {
@@ -731,6 +789,14 @@ describe('config handlers', () => {
debounce_ms: 0,
summarize_overflow: true,
},
nodes: {
enabled: false,
allowed_roles: ['companion'],
feature_gates: {},
location: {
enabled: false,
},
},
},
models: {
default: { provider: 'anthropic' as const, model: 'claude-3-haiku', api_key: 'sk-secret-key' },
@@ -767,13 +833,14 @@ describe('config handlers', () => {
'hooks.log': ['file.read'],
'server.queue.mode': 'followup',
'server.queue.debounce_ms': 100,
'server.nodes.location.enabled': true,
},
},
};
const result = await handlers['config.patch'](req) as GatewayResponse;
const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean };
expect(r.applied).toEqual(['hooks.confirm', 'hooks.log', 'server.queue.mode', 'server.queue.debounce_ms']);
expect(r.applied).toEqual(['hooks.confirm', 'hooks.log', 'server.queue.mode', 'server.queue.debounce_ms', 'server.nodes.location.enabled']);
expect(r.rejected).toEqual([]);
expect(r.persisted).toBe(false);
// Verify the config was actually mutated
@@ -781,6 +848,7 @@ describe('config handlers', () => {
expect(config.hooks.log).toEqual(['file.read']);
expect(config.server.queue.mode).toBe('followup');
expect(config.server.queue.debounce_ms).toBe(100);
expect(config.server.nodes.location.enabled).toBe(true);
});
it('config.patch rejects unknown keys', async () => {
+1 -1
View File
@@ -18,4 +18,4 @@ export type { RoutingHandlerDeps } from './routing.js';
export { createHistoryHandlers } from './history.js';
export type { HistoryHandlerDeps } from './history.js';
export { createNodeHandlers } from './node.js';
export type { NodeHandlerDeps, NodeRegistration, NodeConnectionState } from './node.js';
export type { NodeHandlerDeps, NodeRegistration, NodeConnectionState, NodeLocation } from './node.js';
+83
View File
@@ -6,6 +6,7 @@ describe('node handlers', () => {
const states = new Map<string, NodeConnectionState>([['conn-1', {}]]);
const handlers = createNodeHandlers({
enabled: true,
locationEnabled: true,
allowedRoles: ['companion'],
featureGates: { 'ui.canvas': true, 'dangerous.write': false },
getConnectionState: (connectionId) => states.get(connectionId),
@@ -13,6 +14,10 @@ describe('node handlers', () => {
const prior = states.get(connectionId) ?? {};
states.set(connectionId, { ...prior, node: registration });
},
setNodeLocation: (connectionId, location) => {
const prior = states.get(connectionId) ?? {};
states.set(connectionId, { ...prior, location });
},
});
const result = await handlers['node.register']({
@@ -37,10 +42,12 @@ describe('node handlers', () => {
const states = new Map<string, NodeConnectionState>([['conn-1', {}]]);
const handlers = createNodeHandlers({
enabled: true,
locationEnabled: true,
allowedRoles: ['companion'],
featureGates: {},
getConnectionState: (connectionId) => states.get(connectionId),
setNodeRegistration: () => {},
setNodeLocation: () => {},
});
const result = await handlers['node.register']({
@@ -70,10 +77,12 @@ describe('node handlers', () => {
}]]);
const handlers = createNodeHandlers({
enabled: true,
locationEnabled: true,
allowedRoles: ['companion'],
featureGates: { 'ui.canvas': true },
getConnectionState: (connectionId) => states.get(connectionId),
setNodeRegistration: () => {},
setNodeLocation: () => {},
});
const result = await handlers['node.capabilities.get']({
@@ -85,4 +94,78 @@ describe('node handlers', () => {
const enabled = (result as { result: { capabilities: { enabled: string[] } } }).result.capabilities.enabled;
expect(enabled).toEqual(['ui.canvas']);
});
it('stores location updates and returns latest location', async () => {
const states = new Map<string, NodeConnectionState>([['conn-1', {
node: {
nodeId: 'node-a',
role: 'companion',
protocolVersion: 1,
capabilities: ['location'],
registeredAt: Date.now(),
},
}]]);
const handlers = createNodeHandlers({
enabled: true,
locationEnabled: true,
allowedRoles: ['companion'],
featureGates: {},
getConnectionState: (connectionId) => states.get(connectionId),
setNodeRegistration: () => {},
setNodeLocation: (connectionId, location) => {
const prior = states.get(connectionId) ?? {};
states.set(connectionId, { ...prior, location });
},
});
const setResult = await handlers['node.location.set']({
id: 4,
method: 'node.location.set',
params: {
connectionId: 'conn-1',
latitude: 37.7749,
longitude: -122.4194,
accuracyMeters: 8,
source: 'gps',
},
});
expect((setResult as { result: { updated: boolean } }).result.updated).toBe(true);
const getResult = await handlers['node.location.get']({
id: 5,
method: 'node.location.get',
params: { connectionId: 'conn-1' },
});
const location = (getResult as { result: { location: { latitude: number; longitude: number } } }).result.location;
expect(location.latitude).toBe(37.7749);
expect(location.longitude).toBe(-122.4194);
});
it('rejects location methods when location access is disabled', async () => {
const states = new Map<string, NodeConnectionState>([['conn-1', {
node: {
nodeId: 'node-a',
role: 'companion',
protocolVersion: 1,
capabilities: [],
registeredAt: Date.now(),
},
}]]);
const handlers = createNodeHandlers({
enabled: true,
locationEnabled: false,
allowedRoles: ['companion'],
featureGates: {},
getConnectionState: (connectionId) => states.get(connectionId),
setNodeRegistration: () => {},
setNodeLocation: () => {},
});
const result = await handlers['node.location.set']({
id: 6,
method: 'node.location.set',
params: { connectionId: 'conn-1', latitude: 1, longitude: 2 },
});
expect((result as { error: { message: string } }).error.message).toContain('disabled');
});
});
+93 -1
View File
@@ -1,5 +1,13 @@
import type { GatewayRequest, OutboundMessage } from '../protocol.js';
import { makeError, makeResponse, ErrorCode, GATEWAY_PROTOCOL_VERSION, parseNodeRegisterParams } from '../protocol.js';
import {
makeError,
makeResponse,
ErrorCode,
GATEWAY_PROTOCOL_VERSION,
parseNodeRegisterParams,
parseNodeLocationSetParams,
parseNodeLocationGetParams,
} from '../protocol.js';
export interface NodeRegistration {
nodeId: string;
@@ -12,14 +20,29 @@ export interface NodeRegistration {
export interface NodeConnectionState {
identity?: string;
node?: NodeRegistration;
location?: NodeLocation;
}
export interface NodeLocation {
latitude: number;
longitude: number;
accuracyMeters?: number;
altitudeMeters?: number;
headingDegrees?: number;
speedMps?: number;
source: 'gps' | 'network' | 'manual' | 'unknown';
capturedAt: number;
receivedAt: number;
}
export interface NodeHandlerDeps {
enabled: boolean;
locationEnabled: boolean;
allowedRoles: string[];
featureGates: Record<string, boolean>;
getConnectionState: (connectionId: string) => NodeConnectionState | undefined;
setNodeRegistration: (connectionId: string, registration: NodeRegistration) => void;
setNodeLocation: (connectionId: string, location: NodeLocation) => void;
}
export function createNodeHandlers(deps: NodeHandlerDeps) {
@@ -107,6 +130,74 @@ export function createNodeHandlers(deps: NodeHandlerDeps) {
});
},
'node.location.set': async (request: GatewayRequest): Promise<OutboundMessage> => {
if (!deps.enabled) {
return makeError(request.id, ErrorCode.AuthFailed, 'Node RPC is disabled');
}
if (!deps.locationEnabled) {
return makeError(request.id, ErrorCode.AuthFailed, 'Node location access is disabled');
}
const parsed = parseNodeLocationSetParams(request.params);
if (!parsed) {
return makeError(request.id, ErrorCode.InvalidRequest, 'Invalid node.location.set params');
}
const state = deps.getConnectionState(parsed.connectionId);
if (!state?.node) {
return makeError(request.id, ErrorCode.AuthFailed, 'Node is not registered for this connection');
}
const location: NodeLocation = {
latitude: parsed.latitude,
longitude: parsed.longitude,
accuracyMeters: parsed.accuracyMeters,
altitudeMeters: parsed.altitudeMeters,
headingDegrees: parsed.headingDegrees,
speedMps: parsed.speedMps,
source: parsed.source ?? 'unknown',
capturedAt: parsed.capturedAt ?? Date.now(),
receivedAt: Date.now(),
};
deps.setNodeLocation(parsed.connectionId, location);
return makeResponse(request.id, {
updated: true,
node: {
id: state.node.nodeId,
role: state.node.role,
},
location,
});
},
'node.location.get': async (request: GatewayRequest): Promise<OutboundMessage> => {
if (!deps.enabled) {
return makeError(request.id, ErrorCode.AuthFailed, 'Node RPC is disabled');
}
if (!deps.locationEnabled) {
return makeError(request.id, ErrorCode.AuthFailed, 'Node location access is disabled');
}
const parsed = parseNodeLocationGetParams(request.params);
if (!parsed) {
return makeError(request.id, ErrorCode.InvalidRequest, 'Invalid node.location.get params');
}
const state = deps.getConnectionState(parsed.connectionId);
if (!state?.node) {
return makeError(request.id, ErrorCode.AuthFailed, 'Node is not registered for this connection');
}
return makeResponse(request.id, {
node: {
id: state.node.nodeId,
role: state.node.role,
},
location: state.location ?? null,
});
},
'system.capabilities': async (request: GatewayRequest): Promise<OutboundMessage> => {
const params = request.params as { connectionId?: string } | undefined;
const connectionId = params?.connectionId;
@@ -117,6 +208,7 @@ export function createNodeHandlers(deps: NodeHandlerDeps) {
},
nodes: {
enabled: deps.enabled,
locationEnabled: deps.locationEnabled,
allowedRoles: deps.allowedRoles,
registered: Boolean(state?.node),
role: state?.node?.role,
+29
View File
@@ -2,6 +2,7 @@ import type { GatewayRequest, OutboundMessage } from '../protocol.js';
import { makeResponse, makeError, ErrorCode } from '../protocol.js';
import type { MetricsSnapshot, EventEntry, ActiveRequestInfo } from '../metrics.js';
import type { ServiceInfo } from './services.js';
import type { NodeLocation } from './node.js';
/** Per-session token usage report returned by system.tokenUsage. */
export interface TokenUsageEntry {
@@ -21,6 +22,13 @@ export interface PresenceEntry {
status: 'online' | 'offline';
}
export interface NodeLocationEntry {
nodeId: string;
role: string;
connectionId: string;
location: NodeLocation;
}
export interface SystemHandlerDeps {
startTime: number;
version: string;
@@ -43,6 +51,8 @@ export interface SystemHandlerDeps {
getServices?: () => ServiceInfo[];
/** Optional callback to retrieve tracked sender presence. */
getPresence?: (opts?: { channel?: string; status?: 'online' | 'offline'; limit?: number }) => PresenceEntry[];
/** Optional callback to retrieve latest node location data. */
getNodeLocations?: (opts?: { role?: string; nodeId?: string; limit?: number }) => NodeLocationEntry[];
}
export function createSystemHandlers(deps: SystemHandlerDeps) {
@@ -113,6 +123,25 @@ export function createSystemHandlers(deps: SystemHandlerDeps) {
});
},
'system.location': async (request: GatewayRequest): Promise<OutboundMessage> => {
if (!deps.getNodeLocations) {
return makeResponse(request.id, { locations: [], summary: { total: 0 } });
}
const params = request.params as { role?: string; nodeId?: string; limit?: number } | undefined;
const locations = deps.getNodeLocations({
role: params?.role,
nodeId: params?.nodeId,
limit: params?.limit,
});
return makeResponse(request.id, {
locations,
summary: {
total: locations.length,
},
});
},
'system.usage': async (request: GatewayRequest): Promise<OutboundMessage> => {
const uptime = Math.floor((Date.now() - deps.startTime) / 1000);
const usage = deps.getUsage?.() ?? { totalSessions: 0, activeConnections: 0 };