336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
import type { GatewayRequest, OutboundMessage } from '../protocol.js';
|
|
import {
|
|
makeError,
|
|
makeResponse,
|
|
ErrorCode,
|
|
GATEWAY_PROTOCOL_VERSION,
|
|
parseNodeRegisterParams,
|
|
parseNodeLocationSetParams,
|
|
parseNodeLocationGetParams,
|
|
parseNodeStatusSetParams,
|
|
parseNodePushTokenSetParams,
|
|
} from '../protocol.js';
|
|
|
|
export interface NodeRegistration {
|
|
nodeId: string;
|
|
role: string;
|
|
protocolVersion: number;
|
|
capabilities: string[];
|
|
registeredAt: number;
|
|
}
|
|
|
|
export interface NodeConnectionState {
|
|
identity?: string;
|
|
node?: NodeRegistration;
|
|
location?: NodeLocation;
|
|
status?: NodeStatus;
|
|
pushToken?: NodePushToken;
|
|
}
|
|
|
|
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 NodeStatus {
|
|
platform: 'macos' | 'ios' | 'android' | 'linux' | 'windows' | 'unknown';
|
|
appVersion?: string;
|
|
deviceName?: string;
|
|
statusText?: string;
|
|
batteryPct?: number;
|
|
powerSource: 'ac' | 'battery' | 'unknown';
|
|
reportedAt: number;
|
|
}
|
|
|
|
export interface NodePushToken {
|
|
provider: 'apns' | 'fcm';
|
|
token: string;
|
|
topic?: string;
|
|
environment?: 'sandbox' | 'production';
|
|
registeredAt: number;
|
|
}
|
|
|
|
export interface NodeHandlerDeps {
|
|
enabled: boolean;
|
|
locationEnabled: boolean;
|
|
pushEnabled: boolean;
|
|
allowedRoles: string[];
|
|
featureGates: Record<string, boolean>;
|
|
getConnectionState: (connectionId: string) => NodeConnectionState | undefined;
|
|
setNodeRegistration: (connectionId: string, registration: NodeRegistration) => void;
|
|
setNodeLocation: (connectionId: string, location: NodeLocation) => void;
|
|
setNodeStatus: (connectionId: string, status: NodeStatus) => void;
|
|
setNodePushToken: (connectionId: string, pushToken: NodePushToken) => void;
|
|
}
|
|
|
|
export function createNodeHandlers(deps: NodeHandlerDeps) {
|
|
return {
|
|
'node.register': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
|
if (!deps.enabled) {
|
|
return makeError(request.id, ErrorCode.AuthFailed, 'Node RPC is disabled');
|
|
}
|
|
|
|
const parsed = parseNodeRegisterParams(request.params);
|
|
if (!parsed) {
|
|
return makeError(request.id, ErrorCode.InvalidRequest, 'Invalid node.register params');
|
|
}
|
|
|
|
if (deps.allowedRoles.length > 0 && !deps.allowedRoles.includes(parsed.role)) {
|
|
return makeError(request.id, ErrorCode.AuthFailed, `Node role '${parsed.role}' is not allowed`);
|
|
}
|
|
|
|
const negotiatedVersion = Math.min(GATEWAY_PROTOCOL_VERSION, parsed.protocolVersion);
|
|
if (negotiatedVersion < 1) {
|
|
return makeError(request.id, ErrorCode.InvalidRequest, 'Unsupported protocolVersion');
|
|
}
|
|
|
|
const dedupedCapabilities = Array.from(new Set(parsed.capabilities.map((entry) => entry.trim()).filter(Boolean)));
|
|
deps.setNodeRegistration(parsed.connectionId, {
|
|
nodeId: parsed.nodeId,
|
|
role: parsed.role,
|
|
protocolVersion: parsed.protocolVersion,
|
|
capabilities: dedupedCapabilities,
|
|
registeredAt: Date.now(),
|
|
});
|
|
|
|
const enabledCapabilities = dedupedCapabilities.filter((capability) => deps.featureGates[capability] !== false);
|
|
return makeResponse(request.id, {
|
|
registered: true,
|
|
node: {
|
|
id: parsed.nodeId,
|
|
role: parsed.role,
|
|
},
|
|
protocol: {
|
|
serverVersion: GATEWAY_PROTOCOL_VERSION,
|
|
clientVersion: parsed.protocolVersion,
|
|
negotiatedVersion,
|
|
},
|
|
capabilities: {
|
|
declared: dedupedCapabilities,
|
|
enabled: enabledCapabilities,
|
|
},
|
|
});
|
|
},
|
|
|
|
'node.capabilities.get': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
|
if (!deps.enabled) {
|
|
return makeError(request.id, ErrorCode.AuthFailed, 'Node RPC is disabled');
|
|
}
|
|
|
|
const params = request.params as { connectionId?: string } | undefined;
|
|
const connectionId = params?.connectionId;
|
|
if (!connectionId) {
|
|
return makeError(request.id, ErrorCode.InvalidRequest, 'connectionId is required');
|
|
}
|
|
|
|
const state = deps.getConnectionState(connectionId);
|
|
if (!state?.node) {
|
|
return makeError(request.id, ErrorCode.AuthFailed, 'Node is not registered for this connection');
|
|
}
|
|
|
|
const enabledCapabilities = state.node.capabilities.filter((capability) => deps.featureGates[capability] !== false);
|
|
return makeResponse(request.id, {
|
|
protocol: {
|
|
serverVersion: GATEWAY_PROTOCOL_VERSION,
|
|
nodeVersion: state.node.protocolVersion,
|
|
negotiatedVersion: Math.min(GATEWAY_PROTOCOL_VERSION, state.node.protocolVersion),
|
|
},
|
|
node: {
|
|
id: state.node.nodeId,
|
|
role: state.node.role,
|
|
registeredAt: state.node.registeredAt,
|
|
},
|
|
capabilities: {
|
|
declared: state.node.capabilities,
|
|
enabled: enabledCapabilities,
|
|
featureGates: deps.featureGates,
|
|
},
|
|
});
|
|
},
|
|
|
|
'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,
|
|
});
|
|
},
|
|
|
|
'node.status.set': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
|
if (!deps.enabled) {
|
|
return makeError(request.id, ErrorCode.AuthFailed, 'Node RPC is disabled');
|
|
}
|
|
|
|
const parsed = parseNodeStatusSetParams(request.params);
|
|
if (!parsed) {
|
|
return makeError(request.id, ErrorCode.InvalidRequest, 'Invalid node.status.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 status: NodeStatus = {
|
|
platform: parsed.platform,
|
|
appVersion: parsed.appVersion?.trim() || undefined,
|
|
deviceName: parsed.deviceName?.trim() || undefined,
|
|
statusText: parsed.statusText?.trim() || undefined,
|
|
batteryPct: parsed.batteryPct,
|
|
powerSource: parsed.powerSource ?? 'unknown',
|
|
reportedAt: Date.now(),
|
|
};
|
|
|
|
deps.setNodeStatus(parsed.connectionId, status);
|
|
|
|
return makeResponse(request.id, {
|
|
updated: true,
|
|
node: {
|
|
id: state.node.nodeId,
|
|
role: state.node.role,
|
|
},
|
|
status,
|
|
});
|
|
},
|
|
|
|
'node.push_token.set': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
|
if (!deps.enabled) {
|
|
return makeError(request.id, ErrorCode.AuthFailed, 'Node RPC is disabled');
|
|
}
|
|
if (!deps.pushEnabled) {
|
|
return makeError(request.id, ErrorCode.AuthFailed, 'Node push token registration is disabled');
|
|
}
|
|
|
|
const parsed = parseNodePushTokenSetParams(request.params);
|
|
if (!parsed) {
|
|
return makeError(request.id, ErrorCode.InvalidRequest, 'Invalid node.push_token.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 pushToken: NodePushToken = {
|
|
provider: parsed.provider,
|
|
token: parsed.token,
|
|
topic: parsed.topic || undefined,
|
|
environment: parsed.provider === 'apns' ? (parsed.environment ?? 'production') : undefined,
|
|
registeredAt: Date.now(),
|
|
};
|
|
deps.setNodePushToken(parsed.connectionId, pushToken);
|
|
|
|
return makeResponse(request.id, {
|
|
updated: true,
|
|
node: {
|
|
id: state.node.nodeId,
|
|
role: state.node.role,
|
|
},
|
|
push: {
|
|
provider: pushToken.provider,
|
|
tokenPreview: maskToken(pushToken.token),
|
|
topic: pushToken.topic,
|
|
environment: pushToken.environment,
|
|
registeredAt: pushToken.registeredAt,
|
|
},
|
|
});
|
|
},
|
|
|
|
'system.capabilities': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
|
const params = request.params as { connectionId?: string } | undefined;
|
|
const connectionId = params?.connectionId;
|
|
const state = connectionId ? deps.getConnectionState(connectionId) : undefined;
|
|
return makeResponse(request.id, {
|
|
protocol: {
|
|
version: GATEWAY_PROTOCOL_VERSION,
|
|
},
|
|
nodes: {
|
|
enabled: deps.enabled,
|
|
locationEnabled: deps.locationEnabled,
|
|
pushEnabled: deps.pushEnabled,
|
|
allowedRoles: deps.allowedRoles,
|
|
registered: Boolean(state?.node),
|
|
role: state?.node?.role,
|
|
nodeId: state?.node?.nodeId,
|
|
},
|
|
featureGates: deps.featureGates,
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
function maskToken(token: string): string {
|
|
if (token.length <= 8) {
|
|
return '****';
|
|
}
|
|
const suffix = token.slice(-8);
|
|
return `***${suffix}`;
|
|
}
|