Add iOS node push-token registration foundation

This commit is contained in:
William Valentin
2026-02-16 12:47:34 -08:00
parent bea4c54f3b
commit 58c4b0b9bb
19 changed files with 448 additions and 7 deletions
+11
View File
@@ -198,5 +198,16 @@ describe('authorizeNodeMethod', () => {
},
});
expect(deniedStatus.authenticated).toBe(false);
const allowedPush = authorizeNodeMethod({
enabled: true,
method: 'node.push_token.set',
nodeRole: 'companion',
allowedRoles: ['companion'],
roleScopes: {
companion: ['node.capabilities.get', 'node.push_token.set'],
},
});
expect(allowedPush.authenticated).toBe(true);
});
});
+5
View File
@@ -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) {
+6 -1
View File
@@ -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 () => {
+1 -1
View File
@@ -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';
+57
View File
@@ -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');
});
});
+64
View File
@@ -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}`;
}
+10 -1
View File
@@ -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 {
+38
View File
@@ -6,6 +6,7 @@ import {
parseNodeLocationSetParams,
parseNodeLocationGetParams,
parseNodeStatusSetParams,
parseNodePushTokenSetParams,
makeResponse,
makeError,
makeEvent,
@@ -211,6 +212,43 @@ describe('protocol', () => {
});
});
describe('parseNodePushTokenSetParams', () => {
it('parses valid node push token params', () => {
const parsed = parseNodePushTokenSetParams({
connectionId: 'conn-1',
provider: 'apns',
token: 'abcd1234abcd1234abcd1234abcd1234',
topic: 'com.example.flynn',
environment: 'production',
});
expect(parsed).toEqual({
connectionId: 'conn-1',
provider: 'apns',
token: 'abcd1234abcd1234abcd1234abcd1234',
topic: 'com.example.flynn',
environment: 'production',
});
});
it('rejects invalid node push token params', () => {
expect(parseNodePushTokenSetParams({
connectionId: 'conn-1',
provider: 'fcm',
token: 'abcd1234abcd1234abcd1234abcd1234',
})).toBeNull();
expect(parseNodePushTokenSetParams({
connectionId: 'conn-1',
provider: 'apns',
token: 'short',
})).toBeNull();
expect(parseNodePushTokenSetParams({
connectionId: '',
provider: 'apns',
token: 'abcd1234abcd1234abcd1234abcd1234',
})).toBeNull();
});
});
describe('makeResponse', () => {
it('creates a response message', () => {
expect(makeResponse(1, { status: 'ok' })).toEqual({
+38
View File
@@ -44,6 +44,14 @@ export interface NodeStatusSetParams {
powerSource?: 'ac' | 'battery' | 'unknown';
}
export interface NodePushTokenSetParams {
connectionId: string;
provider: 'apns';
token: string;
topic?: string;
environment?: 'sandbox' | 'production';
}
// ── Server → Client ────────────────────────────────────────────
export interface GatewayResponse {
@@ -293,6 +301,36 @@ export function parseNodeStatusSetParams(params: unknown): NodeStatusSetParams |
};
}
export function parseNodePushTokenSetParams(params: unknown): NodePushTokenSetParams | 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 (p.provider !== 'apns') {
return null;
}
if (typeof p.token !== 'string' || p.token.trim().length < 16) {
return null;
}
if (p.topic !== undefined && typeof p.topic !== 'string') {
return null;
}
if (p.environment !== undefined && p.environment !== 'sandbox' && p.environment !== 'production') {
return null;
}
return {
connectionId: p.connectionId,
provider: 'apns',
token: p.token.trim(),
topic: typeof p.topic === 'string' ? p.topic.trim() : undefined,
environment: p.environment as NodePushTokenSetParams['environment'] | undefined,
};
}
export function makeResponse(id: number, result: unknown): GatewayResponse {
return { id, result };
}
+59
View File
@@ -238,6 +238,7 @@ describe('GatewayServer integration', () => {
expect(methods).toContain('canvas.list');
expect(methods).toContain('system.nodes');
expect(methods).toContain('node.status.set');
expect(methods).toContain('node.push_token.set');
});
it('supports canvas artifact lifecycle via gateway RPC', async () => {
@@ -641,6 +642,7 @@ describe('GatewayServer node registration and capability negotiation', () => {
allowedRoles: ['companion'],
featureGates: { 'ui.canvas': true },
locationEnabled: true,
pushEnabled: true,
},
});
await nodeServer.start();
@@ -809,4 +811,61 @@ describe('GatewayServer node registration and capability negotiation', () => {
}
}
});
it('supports node.push_token.set and exposes masked push summary via system.nodes', 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: 30,
method: 'node.register',
params: {
nodeId: 'node-ios',
role: 'companion',
protocolVersion: 1,
capabilities: ['notifications'],
},
});
expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true);
const push = await sendAndReceive(ws, {
id: 31,
method: 'node.push_token.set',
params: {
provider: 'apns',
token: 'abcd1234abcd1234abcd1234abcd1234',
topic: 'com.example.flynn',
environment: 'sandbox',
},
});
const preview = ((push as GatewayResponse).result as {
push: { tokenPreview: string };
}).push.tokenPreview;
expect(preview).toContain('abcd1234');
const nodes = await sendAndReceive(ws, {
id: 32,
method: 'system.nodes',
params: { role: 'companion', limit: 10 },
});
const list = ((nodes as GatewayResponse).result as {
nodes: Array<{ nodeId: string; push?: { tokenPreview: string } }>;
}).nodes;
const iosNode = list.find((entry) => entry.nodeId === 'node-ios');
expect(iosNode?.push?.tokenPreview).toContain('abcd1234');
expect(iosNode?.push?.tokenPreview).not.toContain('abcd1234abcd1234abcd1234');
} finally {
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
}
});
});
+36 -1
View File
@@ -100,6 +100,7 @@ export interface GatewayServerConfig {
allowedRoles: string[];
featureGates: Record<string, boolean>;
locationEnabled?: boolean;
pushEnabled?: boolean;
};
/** Optional pairing manager for DM pairing code management via gateway. */
pairingManager?: PairingManager;
@@ -231,6 +232,13 @@ export class GatewayServer {
registeredAt: number;
location?: NodeConnectionState['location'];
status?: NodeConnectionState['status'];
push?: {
provider: NonNullable<NodeConnectionState['pushToken']>['provider'];
tokenPreview: string;
topic?: string;
environment: NonNullable<NodeConnectionState['pushToken']>['environment'];
registeredAt: number;
};
}> = [];
for (const [connectionId, state] of this.connectionStateMap.entries()) {
@@ -253,6 +261,15 @@ export class GatewayServer {
registeredAt: state.node.registeredAt,
location: state.location,
status: state.status,
push: state.pushToken
? {
provider: state.pushToken.provider,
tokenPreview: maskToken(state.pushToken.token),
topic: state.pushToken.topic,
environment: state.pushToken.environment,
registeredAt: state.pushToken.registeredAt,
}
: undefined,
});
}
@@ -361,6 +378,7 @@ export class GatewayServer {
const nodeHandlers = createNodeHandlers({
enabled: this.config.nodes?.enabled ?? false,
locationEnabled: this.config.nodes?.locationEnabled ?? false,
pushEnabled: this.config.nodes?.pushEnabled ?? false,
allowedRoles: this.config.nodes?.allowedRoles ?? [],
featureGates: this.config.nodes?.featureGates ?? {},
getConnectionState: (connectionId) => this.connectionStateMap.get(connectionId),
@@ -394,6 +412,16 @@ export class GatewayServer {
status,
});
},
setNodePushToken: (connectionId, pushToken) => {
const existing = this.connectionStateMap.get(connectionId);
if (!existing) {
return;
}
this.connectionStateMap.set(connectionId, {
...existing,
pushToken,
});
},
});
// Config handlers (only if config object is provided)
@@ -740,7 +768,7 @@ export class GatewayServer {
nodeRole: this.connectionStateMap.get(connectionId)?.node?.role,
allowedRoles: this.config.nodes?.allowedRoles ?? [],
roleScopes: {
companion: ['node.capabilities.get', 'node.location.set', 'node.location.get', 'node.status.set'],
companion: ['node.capabilities.get', 'node.location.set', 'node.location.get', 'node.status.set', 'node.push_token.set'],
observer: ['node.capabilities.get', 'node.location.get'],
automation: ['node.capabilities.get', 'node.location.get'],
},
@@ -849,3 +877,10 @@ export class GatewayServer {
return readRequestBody(req, { maxBytes });
}
}
function maskToken(token: string): string {
if (token.length <= 8) {
return '****';
}
return `***${token.slice(-8)}`;
}