From 58c4b0b9bb852ad70eb8a89de5eea4c53939983d Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 12:47:34 -0800 Subject: [PATCH] Add iOS node push-token registration foundation --- README.md | 3 + config/default.yaml | 2 + docs/api/PROTOCOL.md | 21 ++++++ ...026-02-16-ios-node-foundation-checklist.md | 53 +++++++++++++++ docs/plans/state.json | 36 ++++++++++- src/config/schema.test.ts | 5 ++ src/config/schema.ts | 5 ++ src/daemon/services.ts | 1 + src/gateway/auth.test.ts | 11 ++++ src/gateway/handlers/config.ts | 5 ++ src/gateway/handlers/handlers.test.ts | 7 +- src/gateway/handlers/index.ts | 2 +- src/gateway/handlers/node.test.ts | 57 +++++++++++++++++ src/gateway/handlers/node.ts | 64 +++++++++++++++++++ src/gateway/handlers/system.ts | 11 +++- src/gateway/protocol.test.ts | 38 +++++++++++ src/gateway/protocol.ts | 38 +++++++++++ src/gateway/server.test.ts | 59 +++++++++++++++++ src/gateway/server.ts | 37 ++++++++++- 19 files changed, 448 insertions(+), 7 deletions(-) create mode 100644 docs/plans/2026-02-16-ios-node-foundation-checklist.md diff --git a/README.md b/README.md index d70a356..775a16b 100644 --- a/README.md +++ b/README.md @@ -860,6 +860,8 @@ server: ui.canvas: true location: enabled: true + push: + enabled: true ``` Methods: @@ -868,6 +870,7 @@ Methods: - `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. - `node.status.set` publishes companion status/heartbeat fields (`platform`, `appVersion`, `batteryPct`, etc.). +- `node.push_token.set` registers node push tokens (e.g. APNs) when `server.nodes.push.enabled` is true. - `system.location` provides an operator view of registered node locations. - `system.nodes` returns registered node snapshots (role, capabilities, identity, location/status). - `system.capabilities` returns gateway protocol and node policy snapshot. diff --git a/config/default.yaml b/config/default.yaml index 8e445bd..6131baa 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -86,6 +86,8 @@ server: feature_gates: {} location: enabled: false + push: + 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 460ad03..f357e7e 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -667,6 +667,25 @@ Publish companion/node runtime status metadata (for example macOS menu-bar heart } ``` +#### `node.push_token.set` + +Register a node push token (currently APNs) for companion delivery routing. +Requires `server.nodes.push.enabled: true`. + +**Request:** +```json +{ + "id": 13, + "method": "node.push_token.set", + "params": { + "provider": "apns", + "token": "abcd1234abcd1234abcd1234abcd1234", + "topic": "com.example.flynn", + "environment": "sandbox" + } +} +``` + #### `system.capabilities` Return gateway protocol version, node policy status, and feature-gate snapshot. @@ -688,6 +707,7 @@ Return gateway protocol version, node policy status, and feature-gate snapshot. "nodes": { "enabled": true, "locationEnabled": true, + "pushEnabled": true, "allowedRoles": ["companion"], "registered": true, "role": "companion", @@ -707,6 +727,7 @@ Return the operator-facing snapshot of registered node locations. #### `system.nodes` Return the operator-facing snapshot of registered node connections (identity, role, capabilities, location/status). +Push tokens are returned as masked previews (`tokenPreview`) and never exposed in full. ### Canvas Methods diff --git a/docs/plans/2026-02-16-ios-node-foundation-checklist.md b/docs/plans/2026-02-16-ios-node-foundation-checklist.md new file mode 100644 index 0000000..f2efee9 --- /dev/null +++ b/docs/plans/2026-02-16-ios-node-foundation-checklist.md @@ -0,0 +1,53 @@ +# iOS Node Foundation Checklist + +**Date:** 2026-02-16 +**Scope:** Close OpenClaw "iOS node" gap with push-token capable companion foundation. + +## Goal + +Extend node RPC so iOS companions can register APNs push tokens and operators can inspect iOS node readiness safely. + +## Implemented + +- Added protocol parser: + - `parseNodePushTokenSetParams()` +- Added node RPC: + - `node.push_token.set` +- Added node connection state support for push registration: + - provider, topic, environment, token, registeredAt +- Added node policy config gate: + - `server.nodes.push.enabled` (default `false`) +- Added push summary visibility in: + - `system.nodes` (masked `tokenPreview`, never full token) +- Updated node capability snapshot: + - `system.capabilities.nodes.pushEnabled` +- Updated role scopes: + - companion role allowed to call `node.push_token.set` + +## Safety Notes + +- Push tokens are stored in-memory for active node sessions. +- Operator API responses expose only masked token previews. + +## Tests + +- `src/gateway/protocol.test.ts` + - push token parser valid/invalid cases. +- `src/gateway/handlers/node.test.ts` + - push token registration path and masked preview response. +- `src/gateway/server.test.ts` + - end-to-end `node.push_token.set` + `system.nodes` masked summary. +- `src/gateway/auth.test.ts` + - role-scope coverage for `node.push_token.set`. +- `src/gateway/handlers/handlers.test.ts` + - runtime config patch coverage for `server.nodes.push.enabled`. +- `src/config/schema.test.ts` + - node push config defaults + custom parsing. + +## 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 2660bad..5c381d3 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -542,6 +542,36 @@ ], "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 + pnpm typecheck + pnpm build passing" }, + "ios-node-foundation": { + "file": "2026-02-16-ios-node-foundation-checklist.md", + "status": "completed", + "date": "2026-02-16", + "updated": "2026-02-16", + "summary": "Implemented iOS node companion foundation by adding APNs push-token registration (`node.push_token.set`) behind `server.nodes.push.enabled`, plus masked push readiness visibility in `system.nodes` and node capability snapshots.", + "files_created": [ + "docs/plans/2026-02-16-ios-node-foundation-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/index.ts", + "src/gateway/handlers/config.ts", + "src/gateway/handlers/handlers.test.ts", + "src/gateway/server.ts", + "src/gateway/server.test.ts", + "src/gateway/auth.test.ts", + "src/config/schema.ts", + "src/config/schema.test.ts", + "src/daemon/services.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", @@ -3103,7 +3133,7 @@ } }, "overall_progress": { - "total_test_count": 1786, + "total_test_count": 1792, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -3118,12 +3148,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": "121/128 match (95%), 0 partial (0%), 7 missing (5%)", + "feature_gap_scorecard": "122/128 match (95%), 0 partial (0%), 6 missing (5%)", "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: iOS node (open next scoped implementation checklist)" + "next_up": "OpenClaw gap: Android node (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 216e997..70f351f 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -144,6 +144,7 @@ describe('configSchema — server', () => { expect(result.server.nodes.allowed_roles).toEqual(['companion']); expect(result.server.nodes.feature_gates).toEqual({}); expect(result.server.nodes.location.enabled).toBe(false); + expect(result.server.nodes.push.enabled).toBe(false); }); it('accepts custom node policy settings', () => { @@ -160,6 +161,9 @@ describe('configSchema — server', () => { location: { enabled: true, }, + push: { + enabled: true, + }, }, }, }); @@ -168,6 +172,7 @@ describe('configSchema — server', () => { 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); + expect(result.server.nodes.push.enabled).toBe(true); }); it('accepts custom discovery settings', () => { diff --git a/src/config/schema.ts b/src/config/schema.ts index 91d852a..a9a47f4 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -91,6 +91,11 @@ const serverNodePolicySchema = z.object({ /** Enable node.location.set/get and system.location visibility. */ enabled: z.boolean().default(false), }).default({}), + /** Node push registration controls (e.g. APNs for iOS companion). */ + push: z.object({ + /** Enable node.push_token.set and push summary visibility in system.nodes. */ + enabled: z.boolean().default(false), + }).default({}), }).default({}); const serverSchema = z.object({ diff --git a/src/daemon/services.ts b/src/daemon/services.ts index 7fe121c..b9a001e 100644 --- a/src/daemon/services.ts +++ b/src/daemon/services.ts @@ -359,6 +359,7 @@ export function createGateway(deps: GatewayDeps): GatewayServer { allowedRoles: config.server.nodes.allowed_roles, featureGates: config.server.nodes.feature_gates, locationEnabled: config.server.nodes.location.enabled, + pushEnabled: config.server.nodes.push.enabled, }, discovery: { enabled: config.server.discovery.enabled, diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 697f429..efc236c 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -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); }); }); diff --git a/src/gateway/handlers/config.ts b/src/gateway/handlers/config.ts index 9c22cc0..eb23756 100644 --- a/src/gateway/handlers/config.ts +++ b/src/gateway/handlers/config.ts @@ -158,6 +158,11 @@ const PATCHABLE_KEYS: Record 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) { diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index d28ca2f..756252e 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -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 () => { diff --git a/src/gateway/handlers/index.ts b/src/gateway/handlers/index.ts index 4d50f53..d3458e0 100644 --- a/src/gateway/handlers/index.ts +++ b/src/gateway/handlers/index.ts @@ -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'; diff --git a/src/gateway/handlers/node.test.ts b/src/gateway/handlers/node.test.ts index 73af5f8..e42be41 100644 --- a/src/gateway/handlers/node.test.ts +++ b/src/gateway/handlers/node.test.ts @@ -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([['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'); + }); }); diff --git a/src/gateway/handlers/node.ts b/src/gateway/handlers/node.ts index c77049f..ad05368 100644 --- a/src/gateway/handlers/node.ts +++ b/src/gateway/handlers/node.ts @@ -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; 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 => { + 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 => { 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}`; +} diff --git a/src/gateway/handlers/system.ts b/src/gateway/handlers/system.ts index 76139f5..eee4bc5 100644 --- a/src/gateway/handlers/system.ts +++ b/src/gateway/handlers/system.ts @@ -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 { diff --git a/src/gateway/protocol.test.ts b/src/gateway/protocol.test.ts index 83f54f7..36f2154 100644 --- a/src/gateway/protocol.test.ts +++ b/src/gateway/protocol.test.ts @@ -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({ diff --git a/src/gateway/protocol.ts b/src/gateway/protocol.ts index 92cb327..84e7697 100644 --- a/src/gateway/protocol.ts +++ b/src/gateway/protocol.ts @@ -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; + 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 }; } diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 879d325..4187bde 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -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((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(); + } + } + }); }); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 39d75cf..f793eaf 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -100,6 +100,7 @@ export interface GatewayServerConfig { allowedRoles: string[]; featureGates: Record; 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['provider']; + tokenPreview: string; + topic?: string; + environment: NonNullable['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)}`; +}