Add iOS node push-token registration foundation
This commit is contained in:
@@ -158,6 +158,11 @@ const PATCHABLE_KEYS: Record<string, (config: Config, value: unknown) => boolean
|
||||
config.server.nodes.location.enabled = value;
|
||||
return true;
|
||||
},
|
||||
'server.nodes.push.enabled': (config, value) => {
|
||||
if (typeof value !== 'boolean') {return false;}
|
||||
config.server.nodes.push.enabled = value;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
export function createConfigHandlers(deps: ConfigHandlerDeps) {
|
||||
|
||||
@@ -926,6 +926,9 @@ describe('config handlers', () => {
|
||||
location: {
|
||||
enabled: false,
|
||||
},
|
||||
push: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
@@ -964,13 +967,14 @@ describe('config handlers', () => {
|
||||
'server.queue.mode': 'followup',
|
||||
'server.queue.debounce_ms': 100,
|
||||
'server.nodes.location.enabled': true,
|
||||
'server.nodes.push.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', 'server.nodes.location.enabled']);
|
||||
expect(r.applied).toEqual(['hooks.confirm', 'hooks.log', 'server.queue.mode', 'server.queue.debounce_ms', 'server.nodes.location.enabled', 'server.nodes.push.enabled']);
|
||||
expect(r.rejected).toEqual([]);
|
||||
expect(r.persisted).toBe(false);
|
||||
// Verify the config was actually mutated
|
||||
@@ -979,6 +983,7 @@ describe('config handlers', () => {
|
||||
expect(config.server.queue.mode).toBe('followup');
|
||||
expect(config.server.queue.debounce_ms).toBe(100);
|
||||
expect(config.server.nodes.location.enabled).toBe(true);
|
||||
expect(config.server.nodes.push.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('config.patch rejects unknown keys', async () => {
|
||||
|
||||
@@ -20,4 +20,4 @@ export type { HistoryHandlerDeps } from './history.js';
|
||||
export { createCanvasHandlers } from './canvas.js';
|
||||
export type { CanvasHandlerDeps } from './canvas.js';
|
||||
export { createNodeHandlers } from './node.js';
|
||||
export type { NodeHandlerDeps, NodeRegistration, NodeConnectionState, NodeLocation, NodeStatus } from './node.js';
|
||||
export type { NodeHandlerDeps, NodeRegistration, NodeConnectionState, NodeLocation, NodeStatus, NodePushToken } from './node.js';
|
||||
|
||||
@@ -7,6 +7,7 @@ describe('node handlers', () => {
|
||||
const handlers = createNodeHandlers({
|
||||
enabled: true,
|
||||
locationEnabled: true,
|
||||
pushEnabled: true,
|
||||
allowedRoles: ['companion'],
|
||||
featureGates: { 'ui.canvas': true, 'dangerous.write': false },
|
||||
getConnectionState: (connectionId) => states.get(connectionId),
|
||||
@@ -22,6 +23,10 @@ describe('node handlers', () => {
|
||||
const prior = states.get(connectionId) ?? {};
|
||||
states.set(connectionId, { ...prior, status });
|
||||
},
|
||||
setNodePushToken: (connectionId, pushToken) => {
|
||||
const prior = states.get(connectionId) ?? {};
|
||||
states.set(connectionId, { ...prior, pushToken });
|
||||
},
|
||||
});
|
||||
|
||||
const result = await handlers['node.register']({
|
||||
@@ -47,12 +52,14 @@ describe('node handlers', () => {
|
||||
const handlers = createNodeHandlers({
|
||||
enabled: true,
|
||||
locationEnabled: true,
|
||||
pushEnabled: true,
|
||||
allowedRoles: ['companion'],
|
||||
featureGates: {},
|
||||
getConnectionState: (connectionId) => states.get(connectionId),
|
||||
setNodeRegistration: () => {},
|
||||
setNodeLocation: () => {},
|
||||
setNodeStatus: () => {},
|
||||
setNodePushToken: () => {},
|
||||
});
|
||||
|
||||
const result = await handlers['node.register']({
|
||||
@@ -83,12 +90,14 @@ describe('node handlers', () => {
|
||||
const handlers = createNodeHandlers({
|
||||
enabled: true,
|
||||
locationEnabled: true,
|
||||
pushEnabled: true,
|
||||
allowedRoles: ['companion'],
|
||||
featureGates: { 'ui.canvas': true },
|
||||
getConnectionState: (connectionId) => states.get(connectionId),
|
||||
setNodeRegistration: () => {},
|
||||
setNodeLocation: () => {},
|
||||
setNodeStatus: () => {},
|
||||
setNodePushToken: () => {},
|
||||
});
|
||||
|
||||
const result = await handlers['node.capabilities.get']({
|
||||
@@ -114,6 +123,7 @@ describe('node handlers', () => {
|
||||
const handlers = createNodeHandlers({
|
||||
enabled: true,
|
||||
locationEnabled: true,
|
||||
pushEnabled: true,
|
||||
allowedRoles: ['companion'],
|
||||
featureGates: {},
|
||||
getConnectionState: (connectionId) => states.get(connectionId),
|
||||
@@ -123,6 +133,7 @@ describe('node handlers', () => {
|
||||
states.set(connectionId, { ...prior, location });
|
||||
},
|
||||
setNodeStatus: () => {},
|
||||
setNodePushToken: () => {},
|
||||
});
|
||||
|
||||
const setResult = await handlers['node.location.set']({
|
||||
@@ -161,12 +172,14 @@ describe('node handlers', () => {
|
||||
const handlers = createNodeHandlers({
|
||||
enabled: true,
|
||||
locationEnabled: false,
|
||||
pushEnabled: false,
|
||||
allowedRoles: ['companion'],
|
||||
featureGates: {},
|
||||
getConnectionState: (connectionId) => states.get(connectionId),
|
||||
setNodeRegistration: () => {},
|
||||
setNodeLocation: () => {},
|
||||
setNodeStatus: () => {},
|
||||
setNodePushToken: () => {},
|
||||
});
|
||||
|
||||
const result = await handlers['node.location.set']({
|
||||
@@ -190,6 +203,7 @@ describe('node handlers', () => {
|
||||
const handlers = createNodeHandlers({
|
||||
enabled: true,
|
||||
locationEnabled: true,
|
||||
pushEnabled: true,
|
||||
allowedRoles: ['companion'],
|
||||
featureGates: {},
|
||||
getConnectionState: (connectionId) => states.get(connectionId),
|
||||
@@ -199,6 +213,7 @@ describe('node handlers', () => {
|
||||
const prior = states.get(connectionId) ?? {};
|
||||
states.set(connectionId, { ...prior, status });
|
||||
},
|
||||
setNodePushToken: () => {},
|
||||
});
|
||||
|
||||
const result = await handlers['node.status.set']({
|
||||
@@ -218,4 +233,46 @@ describe('node handlers', () => {
|
||||
expect(states.get('conn-1')?.status?.platform).toBe('macos');
|
||||
expect(states.get('conn-1')?.status?.appVersion).toBe('0.2.0');
|
||||
});
|
||||
|
||||
it('registers push token and returns masked preview', async () => {
|
||||
const states = new Map<string, NodeConnectionState>([['conn-1', {
|
||||
node: {
|
||||
nodeId: 'ios-node',
|
||||
role: 'companion',
|
||||
protocolVersion: 1,
|
||||
capabilities: ['notifications'],
|
||||
registeredAt: Date.now(),
|
||||
},
|
||||
}]]);
|
||||
const handlers = createNodeHandlers({
|
||||
enabled: true,
|
||||
locationEnabled: true,
|
||||
pushEnabled: true,
|
||||
allowedRoles: ['companion'],
|
||||
featureGates: {},
|
||||
getConnectionState: (connectionId) => states.get(connectionId),
|
||||
setNodeRegistration: () => {},
|
||||
setNodeLocation: () => {},
|
||||
setNodeStatus: () => {},
|
||||
setNodePushToken: (connectionId, pushToken) => {
|
||||
const prior = states.get(connectionId) ?? {};
|
||||
states.set(connectionId, { ...prior, pushToken });
|
||||
},
|
||||
});
|
||||
|
||||
const result = await handlers['node.push_token.set']({
|
||||
id: 8,
|
||||
method: 'node.push_token.set',
|
||||
params: {
|
||||
connectionId: 'conn-1',
|
||||
provider: 'apns',
|
||||
token: 'abcd1234abcd1234abcd1234abcd1234',
|
||||
topic: 'com.example.flynn',
|
||||
environment: 'sandbox',
|
||||
},
|
||||
});
|
||||
expect((result as { result: { updated: boolean } }).result.updated).toBe(true);
|
||||
expect((result as { result: { push: { tokenPreview: string } } }).result.push.tokenPreview).toContain('abcd1234');
|
||||
expect(states.get('conn-1')?.pushToken?.provider).toBe('apns');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
parseNodeLocationSetParams,
|
||||
parseNodeLocationGetParams,
|
||||
parseNodeStatusSetParams,
|
||||
parseNodePushTokenSetParams,
|
||||
} from '../protocol.js';
|
||||
|
||||
export interface NodeRegistration {
|
||||
@@ -23,6 +24,7 @@ export interface NodeConnectionState {
|
||||
node?: NodeRegistration;
|
||||
location?: NodeLocation;
|
||||
status?: NodeStatus;
|
||||
pushToken?: NodePushToken;
|
||||
}
|
||||
|
||||
export interface NodeLocation {
|
||||
@@ -47,15 +49,25 @@ export interface NodeStatus {
|
||||
reportedAt: number;
|
||||
}
|
||||
|
||||
export interface NodePushToken {
|
||||
provider: 'apns';
|
||||
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) {
|
||||
@@ -248,6 +260,49 @@ export function createNodeHandlers(deps: NodeHandlerDeps) {
|
||||
});
|
||||
},
|
||||
|
||||
'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: 'apns',
|
||||
token: parsed.token,
|
||||
topic: parsed.topic || undefined,
|
||||
environment: parsed.environment ?? 'production',
|
||||
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;
|
||||
@@ -259,6 +314,7 @@ export function createNodeHandlers(deps: NodeHandlerDeps) {
|
||||
nodes: {
|
||||
enabled: deps.enabled,
|
||||
locationEnabled: deps.locationEnabled,
|
||||
pushEnabled: deps.pushEnabled,
|
||||
allowedRoles: deps.allowedRoles,
|
||||
registered: Boolean(state?.node),
|
||||
role: state?.node?.role,
|
||||
@@ -269,3 +325,11 @@ export function createNodeHandlers(deps: NodeHandlerDeps) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function maskToken(token: string): string {
|
||||
if (token.length <= 8) {
|
||||
return '****';
|
||||
}
|
||||
const suffix = token.slice(-8);
|
||||
return `***${suffix}`;
|
||||
}
|
||||
|
||||
@@ -2,7 +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, NodeStatus } from './node.js';
|
||||
import type { NodeLocation, NodeStatus, NodePushToken } from './node.js';
|
||||
|
||||
/** Per-session token usage report returned by system.tokenUsage. */
|
||||
export interface TokenUsageEntry {
|
||||
@@ -39,6 +39,15 @@ export interface NodeEntry {
|
||||
registeredAt: number;
|
||||
location?: NodeLocation;
|
||||
status?: NodeStatus;
|
||||
push?: NodePushTokenSummary;
|
||||
}
|
||||
|
||||
export interface NodePushTokenSummary {
|
||||
provider: NodePushToken['provider'];
|
||||
tokenPreview: string;
|
||||
topic?: string;
|
||||
environment: NodePushToken['environment'];
|
||||
registeredAt: number;
|
||||
}
|
||||
|
||||
export interface SystemHandlerDeps {
|
||||
|
||||
Reference in New Issue
Block a user