diff --git a/README.md b/README.md index aba1d39..3127cc5 100644 --- a/README.md +++ b/README.md @@ -858,11 +858,16 @@ server: allowed_roles: [companion] feature_gates: ui.canvas: true + location: + enabled: true ``` Methods: - `node.register` registers role + declared capabilities for the current connection. - `node.capabilities.get` returns negotiated protocol version and enabled capabilities. +- `node.location.set` updates the node's last-known location (when `server.nodes.location.enabled` is true). +- `node.location.get` returns the node's stored location payload. +- `system.location` provides an operator view of registered node locations. - `system.capabilities` returns gateway protocol and node policy snapshot. ## Gateway Request Body Limit diff --git a/config/default.yaml b/config/default.yaml index 7a24ca7..8e445bd 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -84,6 +84,8 @@ server: enabled: false allowed_roles: [companion] feature_gates: {} + location: + enabled: false # Local-network service discovery (mDNS/Bonjour). Keep disabled by default. # Requires server.localhost: false so LAN clients can actually connect. discovery: diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index 730cffd..d5c93d4 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -604,17 +604,22 @@ Register node role/capabilities for the current WebSocket connection. Return negotiated capabilities for the currently registered node connection. -#### `system.capabilities` +#### `node.location.set` -Return gateway protocol version, node policy status, and feature-gate snapshot. +Update the last-known location for the currently registered node connection. +Requires `server.nodes.enabled: true` and `server.nodes.location.enabled: true`. **Request:** ```json { - "id": 8, - "method": "agent.cancel", + "id": 10, + "method": "node.location.set", "params": { - "sessionId": "telegram:123456" + "latitude": 37.7749, + "longitude": -122.4194, + "accuracyMeters": 12.4, + "source": "gps", + "capturedAt": 1763241200000 } } ``` @@ -622,13 +627,63 @@ Return gateway protocol version, node policy status, and feature-gate snapshot. **Response:** ```json { - "id": 8, + "id": 10, "result": { - "cancelled": true + "updated": true, + "node": { "id": "companion-desktop", "role": "companion" }, + "location": { + "latitude": 37.7749, + "longitude": -122.4194, + "accuracyMeters": 12.4, + "source": "gps", + "capturedAt": 1763241200000, + "receivedAt": 1763241200451 + } } } ``` +#### `node.location.get` + +Return the stored last-known location for the currently registered node connection. + +#### `system.capabilities` + +Return gateway protocol version, node policy status, and feature-gate snapshot. + +**Request:** +```json +{ + "id": 11, + "method": "system.capabilities" +} +``` + +**Response:** +```json +{ + "id": 11, + "result": { + "protocol": { "version": 1 }, + "nodes": { + "enabled": true, + "locationEnabled": true, + "allowedRoles": ["companion"], + "registered": true, + "role": "companion", + "nodeId": "companion-desktop" + }, + "featureGates": { + "ui.canvas": true + } + } +} +``` + +#### `system.location` + +Return the operator-facing snapshot of registered node locations. + #### `agent.setToolUseCallback` Set callback for tool use events (for confirmation UI). diff --git a/docs/plans/2026-02-16-location-access-checklist.md b/docs/plans/2026-02-16-location-access-checklist.md new file mode 100644 index 0000000..c7ee1f5 --- /dev/null +++ b/docs/plans/2026-02-16-location-access-checklist.md @@ -0,0 +1,53 @@ +# Location Access Checklist + +**Date:** 2026-02-16 +**Scope:** Close OpenClaw "Location access" gap using Flynn's node capability foundation. + +## Goal + +Provide a safe, node-scoped location API so companion clients can publish last-known location and operators can inspect it. + +## Implemented + +- Added node location protocol parsing: + - `parseNodeLocationSetParams()` + - `parseNodeLocationGetParams()` +- Added node RPC methods: + - `node.location.set` + - `node.location.get` +- Added operator visibility method: + - `system.location` +- Extended node state model with stored location payload. +- Added explicit location feature gate in config: + - `server.nodes.location.enabled` (default `false`) +- Wired daemon config to gateway runtime and node handlers. +- Added runtime patch support for: + - `config.patch` key `server.nodes.location.enabled` +- Updated docs: + - `README.md` gateway node section + - `docs/api/PROTOCOL.md` node/system method docs + +## Tests + +- `src/gateway/protocol.test.ts` + - Added validation tests for `node.location.set/get` params parsing. +- `src/gateway/handlers/node.test.ts` + - Added location set/get lifecycle tests. + - Added disabled-gate rejection test. +- `src/gateway/handlers/handlers.test.ts` + - Added `system.location` handler tests. + - Added config patch test for `server.nodes.location.enabled`. +- `src/gateway/server.test.ts` + - Added integration test for node registration + location set/get flow. +- `src/config/schema.test.ts` + - Added default/custom tests for `server.nodes.location.enabled`. +- `src/gateway/auth.test.ts` + - Added role-scope authorization test for `node.location.set`. + +## Validation Run + +```bash +pnpm test:run src/gateway/protocol.test.ts src/gateway/auth.test.ts src/gateway/handlers/node.test.ts src/gateway/handlers/handlers.test.ts src/gateway/server.test.ts src/config/schema.test.ts +pnpm typecheck +pnpm build +``` diff --git a/docs/plans/state.json b/docs/plans/state.json index 1f4512d..9d582fe 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -466,6 +466,35 @@ ], "test_status": "pnpm test:run src/channels/registry.test.ts src/gateway/handlers/handlers.test.ts + pnpm typecheck passing" }, + "location-access": { + "file": "2026-02-16-location-access-checklist.md", + "status": "completed", + "date": "2026-02-16", + "updated": "2026-02-16", + "summary": "Implemented node-scoped location access on top of gateway node capability negotiation: added `node.location.set/get` RPCs, operator `system.location` visibility, config gate `server.nodes.location.enabled` (default false), runtime config patch support, tests, and docs updates.", + "files_created": [ + "docs/plans/2026-02-16-location-access-checklist.md" + ], + "files_modified": [ + "src/gateway/protocol.ts", + "src/gateway/protocol.test.ts", + "src/gateway/handlers/node.ts", + "src/gateway/handlers/node.test.ts", + "src/gateway/handlers/system.ts", + "src/gateway/handlers/handlers.test.ts", + "src/gateway/handlers/config.ts", + "src/gateway/server.ts", + "src/gateway/server.test.ts", + "src/gateway/auth.test.ts", + "src/daemon/services.ts", + "src/config/schema.ts", + "src/config/schema.test.ts", + "config/default.yaml", + "README.md", + "docs/api/PROTOCOL.md" + ], + "test_status": "pnpm test:run src/gateway/protocol.test.ts src/gateway/auth.test.ts src/gateway/handlers/node.test.ts src/gateway/handlers/handlers.test.ts src/gateway/server.test.ts src/config/schema.test.ts + pnpm typecheck + pnpm build passing" + }, "qmd-backend": { "file": "2026-02-16-qmd-backend-checklist.md", "status": "completed", @@ -3042,12 +3071,12 @@ "tier2_completion": "4/4 (100%) — inbound webhooks, vector memory search, Dockerfile, heartbeat monitor", "tier3_completion": "5/5 (100%) — lane queue, credential redaction, web UI token dashboard, xAI (Grok) provider, Voyage AI embeddings", "tier4_completion": "4/4 (100%) — gateway lock, shell completion, Tailscale Serve/Funnel, DM pairing codes", - "feature_gap_scorecard": "118/128 match (92%), 0 partial (0%), 10 missing (8%)", + "feature_gap_scorecard": "119/128 match (93%), 0 partial (0%), 9 missing (7%)", "operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 2/2 plans complete — milestone done", "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", "native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback", "remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", - "next_up": "OpenClaw gap: Location access (open next scoped implementation checklist)" + "next_up": "OpenClaw gap: Canvas / A2UI (open next scoped implementation checklist)" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index f6c95e6..216e997 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -143,6 +143,7 @@ describe('configSchema — server', () => { expect(result.server.nodes.enabled).toBe(false); expect(result.server.nodes.allowed_roles).toEqual(['companion']); expect(result.server.nodes.feature_gates).toEqual({}); + expect(result.server.nodes.location.enabled).toBe(false); }); it('accepts custom node policy settings', () => { @@ -156,6 +157,9 @@ describe('configSchema — server', () => { 'ui.canvas': true, 'fs.sync': false, }, + location: { + enabled: true, + }, }, }, }); @@ -163,6 +167,7 @@ describe('configSchema — server', () => { expect(result.server.nodes.allowed_roles).toEqual(['companion', 'observer']); expect(result.server.nodes.feature_gates['ui.canvas']).toBe(true); expect(result.server.nodes.feature_gates['fs.sync']).toBe(false); + expect(result.server.nodes.location.enabled).toBe(true); }); it('accepts custom discovery settings', () => { diff --git a/src/config/schema.ts b/src/config/schema.ts index 699ec82..91d852a 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -86,6 +86,11 @@ const serverNodePolicySchema = z.object({ allowed_roles: z.array(z.string().min(1)).default(['companion']), /** Optional feature gates exposed via system/node capability APIs. */ feature_gates: z.record(z.string(), z.boolean()).default({}), + /** Node location access controls. */ + location: z.object({ + /** Enable node.location.set/get and system.location visibility. */ + enabled: z.boolean().default(false), + }).default({}), }).default({}); const serverSchema = z.object({ diff --git a/src/daemon/services.ts b/src/daemon/services.ts index 4e8b150..7fe121c 100644 --- a/src/daemon/services.ts +++ b/src/daemon/services.ts @@ -358,6 +358,7 @@ export function createGateway(deps: GatewayDeps): GatewayServer { enabled: config.server.nodes.enabled, allowedRoles: config.server.nodes.allowed_roles, featureGates: config.server.nodes.feature_gates, + locationEnabled: config.server.nodes.location.enabled, }, discovery: { enabled: config.server.discovery.enabled, diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 155e7d7..20d6a72 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -177,5 +177,14 @@ describe('authorizeNodeMethod', () => { roleScopes: { companion: ['node.capabilities.get'] }, }); expect(allowed.authenticated).toBe(true); + + const allowedLocation = authorizeNodeMethod({ + enabled: true, + method: 'node.location.set', + nodeRole: 'companion', + allowedRoles: ['companion'], + roleScopes: { companion: ['node.capabilities.get', 'node.location.set'] }, + }); + expect(allowedLocation.authenticated).toBe(true); }); }); diff --git a/src/gateway/handlers/config.ts b/src/gateway/handlers/config.ts index 62faff9..9c22cc0 100644 --- a/src/gateway/handlers/config.ts +++ b/src/gateway/handlers/config.ts @@ -153,6 +153,11 @@ const PATCHABLE_KEYS: Record 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) { diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index b81fe09..ca8d2e2 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -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 () => { diff --git a/src/gateway/handlers/index.ts b/src/gateway/handlers/index.ts index 8426cf8..9a982f7 100644 --- a/src/gateway/handlers/index.ts +++ b/src/gateway/handlers/index.ts @@ -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'; diff --git a/src/gateway/handlers/node.test.ts b/src/gateway/handlers/node.test.ts index 3b31aaa..0541f49 100644 --- a/src/gateway/handlers/node.test.ts +++ b/src/gateway/handlers/node.test.ts @@ -6,6 +6,7 @@ describe('node handlers', () => { const states = new Map([['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([['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([['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([['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'); + }); }); diff --git a/src/gateway/handlers/node.ts b/src/gateway/handlers/node.ts index b082eac..e627107 100644 --- a/src/gateway/handlers/node.ts +++ b/src/gateway/handlers/node.ts @@ -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; 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 => { + 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 => { + 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 => { 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, diff --git a/src/gateway/handlers/system.ts b/src/gateway/handlers/system.ts index 172eaf1..49429f0 100644 --- a/src/gateway/handlers/system.ts +++ b/src/gateway/handlers/system.ts @@ -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 => { + 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 => { const uptime = Math.floor((Date.now() - deps.startTime) / 1000); const usage = deps.getUsage?.() ?? { totalSessions: 0, activeConnections: 0 }; diff --git a/src/gateway/protocol.test.ts b/src/gateway/protocol.test.ts index 8563a0d..a2eb55c 100644 --- a/src/gateway/protocol.test.ts +++ b/src/gateway/protocol.test.ts @@ -3,6 +3,8 @@ import { isValidRequest, parseMessage, parseNodeRegisterParams, + parseNodeLocationSetParams, + parseNodeLocationGetParams, makeResponse, makeError, makeEvent, @@ -104,6 +106,71 @@ describe('protocol', () => { }); }); + describe('parseNodeLocationSetParams', () => { + it('parses valid node location set params', () => { + const parsed = parseNodeLocationSetParams({ + connectionId: 'conn-1', + latitude: 40.7128, + longitude: -74.0060, + accuracyMeters: 12.5, + source: 'gps', + }); + expect(parsed).toEqual({ + connectionId: 'conn-1', + latitude: 40.7128, + longitude: -74.006, + accuracyMeters: 12.5, + altitudeMeters: undefined, + headingDegrees: undefined, + speedMps: undefined, + source: 'gps', + capturedAt: undefined, + }); + }); + + it('rejects invalid node location set params', () => { + expect(parseNodeLocationSetParams({ + connectionId: 'conn-1', + latitude: 200, + longitude: -74, + })).toBeNull(); + expect(parseNodeLocationSetParams({ + connectionId: 'conn-1', + latitude: 10, + longitude: -190, + })).toBeNull(); + expect(parseNodeLocationSetParams({ + connectionId: 'conn-1', + latitude: 10, + longitude: 20, + source: 'beacon', + })).toBeNull(); + expect(parseNodeLocationSetParams({ + connectionId: 'conn-1', + latitude: 10, + longitude: 20, + headingDegrees: 361, + })).toBeNull(); + }); + }); + + describe('parseNodeLocationGetParams', () => { + it('parses valid node location get params', () => { + const parsed = parseNodeLocationGetParams({ + connectionId: 'conn-1', + }); + expect(parsed).toEqual({ + connectionId: 'conn-1', + }); + }); + + it('rejects invalid node location get params', () => { + expect(parseNodeLocationGetParams({})).toBeNull(); + expect(parseNodeLocationGetParams({ connectionId: '' })).toBeNull(); + expect(parseNodeLocationGetParams(null)).toBeNull(); + }); + }); + describe('makeResponse', () => { it('creates a response message', () => { expect(makeResponse(1, { status: 'ok' })).toEqual({ diff --git a/src/gateway/protocol.ts b/src/gateway/protocol.ts index 3a3dd54..db97cce 100644 --- a/src/gateway/protocol.ts +++ b/src/gateway/protocol.ts @@ -18,6 +18,22 @@ export interface NodeRegisterParams { capabilities: string[]; } +export interface NodeLocationSetParams { + connectionId: string; + latitude: number; + longitude: number; + accuracyMeters?: number; + altitudeMeters?: number; + headingDegrees?: number; + speedMps?: number; + source?: 'gps' | 'network' | 'manual' | 'unknown'; + capturedAt?: number; +} + +export interface NodeLocationGetParams { + connectionId: string; +} + // ── Server → Client ──────────────────────────────────────────── export interface GatewayResponse { @@ -170,6 +186,65 @@ export function parseNodeRegisterParams(params: unknown): NodeRegisterParams | n }; } +export function parseNodeLocationSetParams(params: unknown): NodeLocationSetParams | null { + if (!params || typeof params !== 'object') { + return null; + } + const p = params as Record; + if (typeof p.connectionId !== 'string' || !p.connectionId.trim()) { + return null; + } + if (typeof p.latitude !== 'number' || !Number.isFinite(p.latitude) || p.latitude < -90 || p.latitude > 90) { + return null; + } + if (typeof p.longitude !== 'number' || !Number.isFinite(p.longitude) || p.longitude < -180 || p.longitude > 180) { + return null; + } + if (p.accuracyMeters !== undefined && (typeof p.accuracyMeters !== 'number' || !Number.isFinite(p.accuracyMeters) || p.accuracyMeters < 0)) { + return null; + } + if (p.altitudeMeters !== undefined && (typeof p.altitudeMeters !== 'number' || !Number.isFinite(p.altitudeMeters))) { + return null; + } + if (p.headingDegrees !== undefined && (typeof p.headingDegrees !== 'number' || !Number.isFinite(p.headingDegrees) || p.headingDegrees < 0 || p.headingDegrees > 360)) { + return null; + } + if (p.speedMps !== undefined && (typeof p.speedMps !== 'number' || !Number.isFinite(p.speedMps) || p.speedMps < 0)) { + return null; + } + if (p.capturedAt !== undefined && (typeof p.capturedAt !== 'number' || !Number.isFinite(p.capturedAt) || p.capturedAt <= 0)) { + return null; + } + if (p.source !== undefined && !['gps', 'network', 'manual', 'unknown'].includes(String(p.source))) { + return null; + } + + return { + connectionId: p.connectionId, + latitude: p.latitude, + longitude: p.longitude, + accuracyMeters: p.accuracyMeters as number | undefined, + altitudeMeters: p.altitudeMeters as number | undefined, + headingDegrees: p.headingDegrees as number | undefined, + speedMps: p.speedMps as number | undefined, + source: p.source as NodeLocationSetParams['source'] | undefined, + capturedAt: p.capturedAt as number | undefined, + }; +} + +export function parseNodeLocationGetParams(params: unknown): NodeLocationGetParams | null { + if (!params || typeof params !== 'object') { + return null; + } + const p = params as Record; + if (typeof p.connectionId !== 'string' || !p.connectionId.trim()) { + return null; + } + return { + connectionId: p.connectionId, + }; +} + export function makeResponse(id: number, result: unknown): GatewayResponse { return { id, result }; } diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index a0334f2..060bc3d 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -598,6 +598,7 @@ describe('GatewayServer node registration and capability negotiation', () => { enabled: true, allowedRoles: ['companion'], featureGates: { 'ui.canvas': true }, + locationEnabled: true, }, }); await nodeServer.start(); @@ -659,4 +660,56 @@ describe('GatewayServer node registration and capability negotiation', () => { } } }); + + it('supports node location set/get after registration', async () => { + if (!LISTEN_ALLOWED) { + return; + } + + const ws = await new Promise((resolve, reject) => { + const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`); + c.on('open', () => resolve(c)); + c.on('error', reject); + }); + + try { + const registered = await sendAndReceive(ws, { + id: 10, + method: 'node.register', + params: { + nodeId: 'node-loc', + role: 'companion', + protocolVersion: 1, + capabilities: ['location'], + }, + }); + expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true); + + const setResult = await sendAndReceive(ws, { + id: 11, + method: 'node.location.set', + params: { + latitude: 51.5074, + longitude: -0.1278, + source: 'gps', + }, + }); + expect(((setResult as GatewayResponse).result as { updated: boolean }).updated).toBe(true); + + const getResult = await sendAndReceive(ws, { + id: 12, + method: 'node.location.get', + params: {}, + }); + const location = ((getResult as GatewayResponse).result as { + location: { latitude: number; longitude: number }; + }).location; + expect(location.latitude).toBe(51.5074); + expect(location.longitude).toBe(-0.1278); + } finally { + if (ws.readyState === WebSocket.OPEN) { + ws.close(); + } + } + }); }); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index a339eeb..4833735 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -97,6 +97,7 @@ export interface GatewayServerConfig { enabled: boolean; allowedRoles: string[]; featureGates: Record; + locationEnabled?: boolean; }; /** Optional pairing manager for DM pairing code management via gateway. */ pairingManager?: PairingManager; @@ -185,6 +186,36 @@ export class GatewayServer { getPresence: channelRegistry ? (opts) => channelRegistry.getPresence(opts) : undefined, + getNodeLocations: ({ role, nodeId, limit } = {}) => { + const entries: Array<{ + nodeId: string; + role: string; + connectionId: string; + location: NonNullable; + }> = []; + for (const [connectionId, state] of this.connectionStateMap.entries()) { + if (!state.node || !state.location) { + continue; + } + if (role && state.node.role !== role) { + continue; + } + if (nodeId && state.node.nodeId !== nodeId) { + continue; + } + entries.push({ + nodeId: state.node.nodeId, + role: state.node.role, + connectionId, + location: state.location, + }); + } + const sorted = entries.sort((a, b) => b.location.receivedAt - a.location.receivedAt); + if (typeof limit === 'number' && Number.isFinite(limit) && limit > 0) { + return sorted.slice(0, Math.floor(limit)); + } + return sorted; + }, getUsage: () => ({ totalSessions: this.config.sessionManager.listSessions().length, activeConnections: this.sessionBridge.connectionCount, @@ -279,6 +310,7 @@ export class GatewayServer { const nodeHandlers = createNodeHandlers({ enabled: this.config.nodes?.enabled ?? false, + locationEnabled: this.config.nodes?.locationEnabled ?? false, allowedRoles: this.config.nodes?.allowedRoles ?? [], featureGates: this.config.nodes?.featureGates ?? {}, getConnectionState: (connectionId) => this.connectionStateMap.get(connectionId), @@ -292,6 +324,16 @@ export class GatewayServer { node: registration, }); }, + setNodeLocation: (connectionId, location) => { + const existing = this.connectionStateMap.get(connectionId); + if (!existing) { + return; + } + this.connectionStateMap.set(connectionId, { + ...existing, + location, + }); + }, }); // Config handlers (only if config object is provided) @@ -635,9 +677,9 @@ export class GatewayServer { nodeRole: this.connectionStateMap.get(connectionId)?.node?.role, allowedRoles: this.config.nodes?.allowedRoles ?? [], roleScopes: { - companion: ['node.capabilities.get'], - observer: ['node.capabilities.get'], - automation: ['node.capabilities.get'], + companion: ['node.capabilities.get', 'node.location.set', 'node.location.get'], + observer: ['node.capabilities.get', 'node.location.get'], + automation: ['node.capabilities.get', 'node.location.get'], }, }); if (!nodeAuth.authenticated) {