Add node location access RPCs and operator visibility
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+62
-7
@@ -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).
|
||||
|
||||
@@ -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
|
||||
```
|
||||
+31
-2
@@ -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",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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<WebSocket>((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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+45
-3
@@ -97,6 +97,7 @@ export interface GatewayServerConfig {
|
||||
enabled: boolean;
|
||||
allowedRoles: string[];
|
||||
featureGates: Record<string, boolean>;
|
||||
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<NodeConnectionState['location']>;
|
||||
}> = [];
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user