Add iOS node push-token registration foundation
This commit is contained in:
@@ -860,6 +860,8 @@ server:
|
|||||||
ui.canvas: true
|
ui.canvas: true
|
||||||
location:
|
location:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
push:
|
||||||
|
enabled: true
|
||||||
```
|
```
|
||||||
|
|
||||||
Methods:
|
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.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.location.get` returns the node's stored location payload.
|
||||||
- `node.status.set` publishes companion status/heartbeat fields (`platform`, `appVersion`, `batteryPct`, etc.).
|
- `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.location` provides an operator view of registered node locations.
|
||||||
- `system.nodes` returns registered node snapshots (role, capabilities, identity, location/status).
|
- `system.nodes` returns registered node snapshots (role, capabilities, identity, location/status).
|
||||||
- `system.capabilities` returns gateway protocol and node policy snapshot.
|
- `system.capabilities` returns gateway protocol and node policy snapshot.
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ server:
|
|||||||
feature_gates: {}
|
feature_gates: {}
|
||||||
location:
|
location:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
push:
|
||||||
|
enabled: false
|
||||||
# Local-network service discovery (mDNS/Bonjour). Keep disabled by default.
|
# Local-network service discovery (mDNS/Bonjour). Keep disabled by default.
|
||||||
# Requires server.localhost: false so LAN clients can actually connect.
|
# Requires server.localhost: false so LAN clients can actually connect.
|
||||||
discovery:
|
discovery:
|
||||||
|
|||||||
@@ -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`
|
#### `system.capabilities`
|
||||||
|
|
||||||
Return gateway protocol version, node policy status, and feature-gate snapshot.
|
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": {
|
"nodes": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"locationEnabled": true,
|
"locationEnabled": true,
|
||||||
|
"pushEnabled": true,
|
||||||
"allowedRoles": ["companion"],
|
"allowedRoles": ["companion"],
|
||||||
"registered": true,
|
"registered": true,
|
||||||
"role": "companion",
|
"role": "companion",
|
||||||
@@ -707,6 +727,7 @@ Return the operator-facing snapshot of registered node locations.
|
|||||||
#### `system.nodes`
|
#### `system.nodes`
|
||||||
|
|
||||||
Return the operator-facing snapshot of registered node connections (identity, role, capabilities, location/status).
|
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
|
### Canvas Methods
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
+33
-3
@@ -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"
|
"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": {
|
"qmd-backend": {
|
||||||
"file": "2026-02-16-qmd-backend-checklist.md",
|
"file": "2026-02-16-qmd-backend-checklist.md",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
@@ -3103,7 +3133,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
"total_test_count": 1786,
|
"total_test_count": 1792,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (100%)",
|
"p1_completion": "4/4 (100%)",
|
||||||
@@ -3118,12 +3148,12 @@
|
|||||||
"tier2_completion": "4/4 (100%) — inbound webhooks, vector memory search, Dockerfile, heartbeat monitor",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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": {
|
"soul_md_and_cron_create": {
|
||||||
"date": "2026-02-11",
|
"date": "2026-02-11",
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ describe('configSchema — server', () => {
|
|||||||
expect(result.server.nodes.allowed_roles).toEqual(['companion']);
|
expect(result.server.nodes.allowed_roles).toEqual(['companion']);
|
||||||
expect(result.server.nodes.feature_gates).toEqual({});
|
expect(result.server.nodes.feature_gates).toEqual({});
|
||||||
expect(result.server.nodes.location.enabled).toBe(false);
|
expect(result.server.nodes.location.enabled).toBe(false);
|
||||||
|
expect(result.server.nodes.push.enabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts custom node policy settings', () => {
|
it('accepts custom node policy settings', () => {
|
||||||
@@ -160,6 +161,9 @@ describe('configSchema — server', () => {
|
|||||||
location: {
|
location: {
|
||||||
enabled: true,
|
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['ui.canvas']).toBe(true);
|
||||||
expect(result.server.nodes.feature_gates['fs.sync']).toBe(false);
|
expect(result.server.nodes.feature_gates['fs.sync']).toBe(false);
|
||||||
expect(result.server.nodes.location.enabled).toBe(true);
|
expect(result.server.nodes.location.enabled).toBe(true);
|
||||||
|
expect(result.server.nodes.push.enabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts custom discovery settings', () => {
|
it('accepts custom discovery settings', () => {
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ const serverNodePolicySchema = z.object({
|
|||||||
/** Enable node.location.set/get and system.location visibility. */
|
/** Enable node.location.set/get and system.location visibility. */
|
||||||
enabled: z.boolean().default(false),
|
enabled: z.boolean().default(false),
|
||||||
}).default({}),
|
}).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({});
|
}).default({});
|
||||||
|
|
||||||
const serverSchema = z.object({
|
const serverSchema = z.object({
|
||||||
|
|||||||
@@ -359,6 +359,7 @@ export function createGateway(deps: GatewayDeps): GatewayServer {
|
|||||||
allowedRoles: config.server.nodes.allowed_roles,
|
allowedRoles: config.server.nodes.allowed_roles,
|
||||||
featureGates: config.server.nodes.feature_gates,
|
featureGates: config.server.nodes.feature_gates,
|
||||||
locationEnabled: config.server.nodes.location.enabled,
|
locationEnabled: config.server.nodes.location.enabled,
|
||||||
|
pushEnabled: config.server.nodes.push.enabled,
|
||||||
},
|
},
|
||||||
discovery: {
|
discovery: {
|
||||||
enabled: config.server.discovery.enabled,
|
enabled: config.server.discovery.enabled,
|
||||||
|
|||||||
@@ -198,5 +198,16 @@ describe('authorizeNodeMethod', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(deniedStatus.authenticated).toBe(false);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -158,6 +158,11 @@ const PATCHABLE_KEYS: Record<string, (config: Config, value: unknown) => boolean
|
|||||||
config.server.nodes.location.enabled = value;
|
config.server.nodes.location.enabled = value;
|
||||||
return true;
|
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) {
|
export function createConfigHandlers(deps: ConfigHandlerDeps) {
|
||||||
|
|||||||
@@ -926,6 +926,9 @@ describe('config handlers', () => {
|
|||||||
location: {
|
location: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
|
push: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
models: {
|
models: {
|
||||||
@@ -964,13 +967,14 @@ describe('config handlers', () => {
|
|||||||
'server.queue.mode': 'followup',
|
'server.queue.mode': 'followup',
|
||||||
'server.queue.debounce_ms': 100,
|
'server.queue.debounce_ms': 100,
|
||||||
'server.nodes.location.enabled': true,
|
'server.nodes.location.enabled': true,
|
||||||
|
'server.nodes.push.enabled': true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const result = await handlers['config.patch'](req) as GatewayResponse;
|
const result = await handlers['config.patch'](req) as GatewayResponse;
|
||||||
|
|
||||||
const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean };
|
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.rejected).toEqual([]);
|
||||||
expect(r.persisted).toBe(false);
|
expect(r.persisted).toBe(false);
|
||||||
// Verify the config was actually mutated
|
// Verify the config was actually mutated
|
||||||
@@ -979,6 +983,7 @@ describe('config handlers', () => {
|
|||||||
expect(config.server.queue.mode).toBe('followup');
|
expect(config.server.queue.mode).toBe('followup');
|
||||||
expect(config.server.queue.debounce_ms).toBe(100);
|
expect(config.server.queue.debounce_ms).toBe(100);
|
||||||
expect(config.server.nodes.location.enabled).toBe(true);
|
expect(config.server.nodes.location.enabled).toBe(true);
|
||||||
|
expect(config.server.nodes.push.enabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('config.patch rejects unknown keys', async () => {
|
it('config.patch rejects unknown keys', async () => {
|
||||||
|
|||||||
@@ -20,4 +20,4 @@ export type { HistoryHandlerDeps } from './history.js';
|
|||||||
export { createCanvasHandlers } from './canvas.js';
|
export { createCanvasHandlers } from './canvas.js';
|
||||||
export type { CanvasHandlerDeps } from './canvas.js';
|
export type { CanvasHandlerDeps } from './canvas.js';
|
||||||
export { createNodeHandlers } from './node.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({
|
const handlers = createNodeHandlers({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
locationEnabled: true,
|
locationEnabled: true,
|
||||||
|
pushEnabled: true,
|
||||||
allowedRoles: ['companion'],
|
allowedRoles: ['companion'],
|
||||||
featureGates: { 'ui.canvas': true, 'dangerous.write': false },
|
featureGates: { 'ui.canvas': true, 'dangerous.write': false },
|
||||||
getConnectionState: (connectionId) => states.get(connectionId),
|
getConnectionState: (connectionId) => states.get(connectionId),
|
||||||
@@ -22,6 +23,10 @@ describe('node handlers', () => {
|
|||||||
const prior = states.get(connectionId) ?? {};
|
const prior = states.get(connectionId) ?? {};
|
||||||
states.set(connectionId, { ...prior, status });
|
states.set(connectionId, { ...prior, status });
|
||||||
},
|
},
|
||||||
|
setNodePushToken: (connectionId, pushToken) => {
|
||||||
|
const prior = states.get(connectionId) ?? {};
|
||||||
|
states.set(connectionId, { ...prior, pushToken });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await handlers['node.register']({
|
const result = await handlers['node.register']({
|
||||||
@@ -47,12 +52,14 @@ describe('node handlers', () => {
|
|||||||
const handlers = createNodeHandlers({
|
const handlers = createNodeHandlers({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
locationEnabled: true,
|
locationEnabled: true,
|
||||||
|
pushEnabled: true,
|
||||||
allowedRoles: ['companion'],
|
allowedRoles: ['companion'],
|
||||||
featureGates: {},
|
featureGates: {},
|
||||||
getConnectionState: (connectionId) => states.get(connectionId),
|
getConnectionState: (connectionId) => states.get(connectionId),
|
||||||
setNodeRegistration: () => {},
|
setNodeRegistration: () => {},
|
||||||
setNodeLocation: () => {},
|
setNodeLocation: () => {},
|
||||||
setNodeStatus: () => {},
|
setNodeStatus: () => {},
|
||||||
|
setNodePushToken: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await handlers['node.register']({
|
const result = await handlers['node.register']({
|
||||||
@@ -83,12 +90,14 @@ describe('node handlers', () => {
|
|||||||
const handlers = createNodeHandlers({
|
const handlers = createNodeHandlers({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
locationEnabled: true,
|
locationEnabled: true,
|
||||||
|
pushEnabled: true,
|
||||||
allowedRoles: ['companion'],
|
allowedRoles: ['companion'],
|
||||||
featureGates: { 'ui.canvas': true },
|
featureGates: { 'ui.canvas': true },
|
||||||
getConnectionState: (connectionId) => states.get(connectionId),
|
getConnectionState: (connectionId) => states.get(connectionId),
|
||||||
setNodeRegistration: () => {},
|
setNodeRegistration: () => {},
|
||||||
setNodeLocation: () => {},
|
setNodeLocation: () => {},
|
||||||
setNodeStatus: () => {},
|
setNodeStatus: () => {},
|
||||||
|
setNodePushToken: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await handlers['node.capabilities.get']({
|
const result = await handlers['node.capabilities.get']({
|
||||||
@@ -114,6 +123,7 @@ describe('node handlers', () => {
|
|||||||
const handlers = createNodeHandlers({
|
const handlers = createNodeHandlers({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
locationEnabled: true,
|
locationEnabled: true,
|
||||||
|
pushEnabled: true,
|
||||||
allowedRoles: ['companion'],
|
allowedRoles: ['companion'],
|
||||||
featureGates: {},
|
featureGates: {},
|
||||||
getConnectionState: (connectionId) => states.get(connectionId),
|
getConnectionState: (connectionId) => states.get(connectionId),
|
||||||
@@ -123,6 +133,7 @@ describe('node handlers', () => {
|
|||||||
states.set(connectionId, { ...prior, location });
|
states.set(connectionId, { ...prior, location });
|
||||||
},
|
},
|
||||||
setNodeStatus: () => {},
|
setNodeStatus: () => {},
|
||||||
|
setNodePushToken: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const setResult = await handlers['node.location.set']({
|
const setResult = await handlers['node.location.set']({
|
||||||
@@ -161,12 +172,14 @@ describe('node handlers', () => {
|
|||||||
const handlers = createNodeHandlers({
|
const handlers = createNodeHandlers({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
locationEnabled: false,
|
locationEnabled: false,
|
||||||
|
pushEnabled: false,
|
||||||
allowedRoles: ['companion'],
|
allowedRoles: ['companion'],
|
||||||
featureGates: {},
|
featureGates: {},
|
||||||
getConnectionState: (connectionId) => states.get(connectionId),
|
getConnectionState: (connectionId) => states.get(connectionId),
|
||||||
setNodeRegistration: () => {},
|
setNodeRegistration: () => {},
|
||||||
setNodeLocation: () => {},
|
setNodeLocation: () => {},
|
||||||
setNodeStatus: () => {},
|
setNodeStatus: () => {},
|
||||||
|
setNodePushToken: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await handlers['node.location.set']({
|
const result = await handlers['node.location.set']({
|
||||||
@@ -190,6 +203,7 @@ describe('node handlers', () => {
|
|||||||
const handlers = createNodeHandlers({
|
const handlers = createNodeHandlers({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
locationEnabled: true,
|
locationEnabled: true,
|
||||||
|
pushEnabled: true,
|
||||||
allowedRoles: ['companion'],
|
allowedRoles: ['companion'],
|
||||||
featureGates: {},
|
featureGates: {},
|
||||||
getConnectionState: (connectionId) => states.get(connectionId),
|
getConnectionState: (connectionId) => states.get(connectionId),
|
||||||
@@ -199,6 +213,7 @@ describe('node handlers', () => {
|
|||||||
const prior = states.get(connectionId) ?? {};
|
const prior = states.get(connectionId) ?? {};
|
||||||
states.set(connectionId, { ...prior, status });
|
states.set(connectionId, { ...prior, status });
|
||||||
},
|
},
|
||||||
|
setNodePushToken: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await handlers['node.status.set']({
|
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?.platform).toBe('macos');
|
||||||
expect(states.get('conn-1')?.status?.appVersion).toBe('0.2.0');
|
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,
|
parseNodeLocationSetParams,
|
||||||
parseNodeLocationGetParams,
|
parseNodeLocationGetParams,
|
||||||
parseNodeStatusSetParams,
|
parseNodeStatusSetParams,
|
||||||
|
parseNodePushTokenSetParams,
|
||||||
} from '../protocol.js';
|
} from '../protocol.js';
|
||||||
|
|
||||||
export interface NodeRegistration {
|
export interface NodeRegistration {
|
||||||
@@ -23,6 +24,7 @@ export interface NodeConnectionState {
|
|||||||
node?: NodeRegistration;
|
node?: NodeRegistration;
|
||||||
location?: NodeLocation;
|
location?: NodeLocation;
|
||||||
status?: NodeStatus;
|
status?: NodeStatus;
|
||||||
|
pushToken?: NodePushToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeLocation {
|
export interface NodeLocation {
|
||||||
@@ -47,15 +49,25 @@ export interface NodeStatus {
|
|||||||
reportedAt: number;
|
reportedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NodePushToken {
|
||||||
|
provider: 'apns';
|
||||||
|
token: string;
|
||||||
|
topic?: string;
|
||||||
|
environment: 'sandbox' | 'production';
|
||||||
|
registeredAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NodeHandlerDeps {
|
export interface NodeHandlerDeps {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
locationEnabled: boolean;
|
locationEnabled: boolean;
|
||||||
|
pushEnabled: boolean;
|
||||||
allowedRoles: string[];
|
allowedRoles: string[];
|
||||||
featureGates: Record<string, boolean>;
|
featureGates: Record<string, boolean>;
|
||||||
getConnectionState: (connectionId: string) => NodeConnectionState | undefined;
|
getConnectionState: (connectionId: string) => NodeConnectionState | undefined;
|
||||||
setNodeRegistration: (connectionId: string, registration: NodeRegistration) => void;
|
setNodeRegistration: (connectionId: string, registration: NodeRegistration) => void;
|
||||||
setNodeLocation: (connectionId: string, location: NodeLocation) => void;
|
setNodeLocation: (connectionId: string, location: NodeLocation) => void;
|
||||||
setNodeStatus: (connectionId: string, status: NodeStatus) => void;
|
setNodeStatus: (connectionId: string, status: NodeStatus) => void;
|
||||||
|
setNodePushToken: (connectionId: string, pushToken: NodePushToken) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createNodeHandlers(deps: NodeHandlerDeps) {
|
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> => {
|
'system.capabilities': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||||
const params = request.params as { connectionId?: string } | undefined;
|
const params = request.params as { connectionId?: string } | undefined;
|
||||||
const connectionId = params?.connectionId;
|
const connectionId = params?.connectionId;
|
||||||
@@ -259,6 +314,7 @@ export function createNodeHandlers(deps: NodeHandlerDeps) {
|
|||||||
nodes: {
|
nodes: {
|
||||||
enabled: deps.enabled,
|
enabled: deps.enabled,
|
||||||
locationEnabled: deps.locationEnabled,
|
locationEnabled: deps.locationEnabled,
|
||||||
|
pushEnabled: deps.pushEnabled,
|
||||||
allowedRoles: deps.allowedRoles,
|
allowedRoles: deps.allowedRoles,
|
||||||
registered: Boolean(state?.node),
|
registered: Boolean(state?.node),
|
||||||
role: state?.node?.role,
|
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 { makeResponse, makeError, ErrorCode } from '../protocol.js';
|
||||||
import type { MetricsSnapshot, EventEntry, ActiveRequestInfo } from '../metrics.js';
|
import type { MetricsSnapshot, EventEntry, ActiveRequestInfo } from '../metrics.js';
|
||||||
import type { ServiceInfo } from './services.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. */
|
/** Per-session token usage report returned by system.tokenUsage. */
|
||||||
export interface TokenUsageEntry {
|
export interface TokenUsageEntry {
|
||||||
@@ -39,6 +39,15 @@ export interface NodeEntry {
|
|||||||
registeredAt: number;
|
registeredAt: number;
|
||||||
location?: NodeLocation;
|
location?: NodeLocation;
|
||||||
status?: NodeStatus;
|
status?: NodeStatus;
|
||||||
|
push?: NodePushTokenSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodePushTokenSummary {
|
||||||
|
provider: NodePushToken['provider'];
|
||||||
|
tokenPreview: string;
|
||||||
|
topic?: string;
|
||||||
|
environment: NodePushToken['environment'];
|
||||||
|
registeredAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemHandlerDeps {
|
export interface SystemHandlerDeps {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
parseNodeLocationSetParams,
|
parseNodeLocationSetParams,
|
||||||
parseNodeLocationGetParams,
|
parseNodeLocationGetParams,
|
||||||
parseNodeStatusSetParams,
|
parseNodeStatusSetParams,
|
||||||
|
parseNodePushTokenSetParams,
|
||||||
makeResponse,
|
makeResponse,
|
||||||
makeError,
|
makeError,
|
||||||
makeEvent,
|
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', () => {
|
describe('makeResponse', () => {
|
||||||
it('creates a response message', () => {
|
it('creates a response message', () => {
|
||||||
expect(makeResponse(1, { status: 'ok' })).toEqual({
|
expect(makeResponse(1, { status: 'ok' })).toEqual({
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ export interface NodeStatusSetParams {
|
|||||||
powerSource?: 'ac' | 'battery' | 'unknown';
|
powerSource?: 'ac' | 'battery' | 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NodePushTokenSetParams {
|
||||||
|
connectionId: string;
|
||||||
|
provider: 'apns';
|
||||||
|
token: string;
|
||||||
|
topic?: string;
|
||||||
|
environment?: 'sandbox' | 'production';
|
||||||
|
}
|
||||||
|
|
||||||
// ── Server → Client ────────────────────────────────────────────
|
// ── Server → Client ────────────────────────────────────────────
|
||||||
|
|
||||||
export interface GatewayResponse {
|
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 {
|
export function makeResponse(id: number, result: unknown): GatewayResponse {
|
||||||
return { id, result };
|
return { id, result };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ describe('GatewayServer integration', () => {
|
|||||||
expect(methods).toContain('canvas.list');
|
expect(methods).toContain('canvas.list');
|
||||||
expect(methods).toContain('system.nodes');
|
expect(methods).toContain('system.nodes');
|
||||||
expect(methods).toContain('node.status.set');
|
expect(methods).toContain('node.status.set');
|
||||||
|
expect(methods).toContain('node.push_token.set');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports canvas artifact lifecycle via gateway RPC', async () => {
|
it('supports canvas artifact lifecycle via gateway RPC', async () => {
|
||||||
@@ -641,6 +642,7 @@ describe('GatewayServer node registration and capability negotiation', () => {
|
|||||||
allowedRoles: ['companion'],
|
allowedRoles: ['companion'],
|
||||||
featureGates: { 'ui.canvas': true },
|
featureGates: { 'ui.canvas': true },
|
||||||
locationEnabled: true,
|
locationEnabled: true,
|
||||||
|
pushEnabled: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await nodeServer.start();
|
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
@@ -100,6 +100,7 @@ export interface GatewayServerConfig {
|
|||||||
allowedRoles: string[];
|
allowedRoles: string[];
|
||||||
featureGates: Record<string, boolean>;
|
featureGates: Record<string, boolean>;
|
||||||
locationEnabled?: boolean;
|
locationEnabled?: boolean;
|
||||||
|
pushEnabled?: boolean;
|
||||||
};
|
};
|
||||||
/** Optional pairing manager for DM pairing code management via gateway. */
|
/** Optional pairing manager for DM pairing code management via gateway. */
|
||||||
pairingManager?: PairingManager;
|
pairingManager?: PairingManager;
|
||||||
@@ -231,6 +232,13 @@ export class GatewayServer {
|
|||||||
registeredAt: number;
|
registeredAt: number;
|
||||||
location?: NodeConnectionState['location'];
|
location?: NodeConnectionState['location'];
|
||||||
status?: NodeConnectionState['status'];
|
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()) {
|
for (const [connectionId, state] of this.connectionStateMap.entries()) {
|
||||||
@@ -253,6 +261,15 @@ export class GatewayServer {
|
|||||||
registeredAt: state.node.registeredAt,
|
registeredAt: state.node.registeredAt,
|
||||||
location: state.location,
|
location: state.location,
|
||||||
status: state.status,
|
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({
|
const nodeHandlers = createNodeHandlers({
|
||||||
enabled: this.config.nodes?.enabled ?? false,
|
enabled: this.config.nodes?.enabled ?? false,
|
||||||
locationEnabled: this.config.nodes?.locationEnabled ?? false,
|
locationEnabled: this.config.nodes?.locationEnabled ?? false,
|
||||||
|
pushEnabled: this.config.nodes?.pushEnabled ?? false,
|
||||||
allowedRoles: this.config.nodes?.allowedRoles ?? [],
|
allowedRoles: this.config.nodes?.allowedRoles ?? [],
|
||||||
featureGates: this.config.nodes?.featureGates ?? {},
|
featureGates: this.config.nodes?.featureGates ?? {},
|
||||||
getConnectionState: (connectionId) => this.connectionStateMap.get(connectionId),
|
getConnectionState: (connectionId) => this.connectionStateMap.get(connectionId),
|
||||||
@@ -394,6 +412,16 @@ export class GatewayServer {
|
|||||||
status,
|
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)
|
// Config handlers (only if config object is provided)
|
||||||
@@ -740,7 +768,7 @@ export class GatewayServer {
|
|||||||
nodeRole: this.connectionStateMap.get(connectionId)?.node?.role,
|
nodeRole: this.connectionStateMap.get(connectionId)?.node?.role,
|
||||||
allowedRoles: this.config.nodes?.allowedRoles ?? [],
|
allowedRoles: this.config.nodes?.allowedRoles ?? [],
|
||||||
roleScopes: {
|
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'],
|
observer: ['node.capabilities.get', 'node.location.get'],
|
||||||
automation: ['node.capabilities.get', 'node.location.get'],
|
automation: ['node.capabilities.get', 'node.location.get'],
|
||||||
},
|
},
|
||||||
@@ -849,3 +877,10 @@ export class GatewayServer {
|
|||||||
return readRequestBody(req, { maxBytes });
|
return readRequestBody(req, { maxBytes });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function maskToken(token: string): string {
|
||||||
|
if (token.length <= 8) {
|
||||||
|
return '****';
|
||||||
|
}
|
||||||
|
return `***${token.slice(-8)}`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user