dsh-device-fleet 0.0.0-stage → 0.1.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CONTEXT.md +123 -0
  2. package/LICENSE +21 -0
  3. package/README.md +95 -2
  4. package/cordis.patch.yml +24 -0
  5. package/docs/adr/0001-fleet-enrollment-over-account-auto-discovery.md +17 -0
  6. package/docs/adr/0002-shared-room-as-coordination-unit.md +17 -0
  7. package/docs/adr/0003-direct-call-and-room-as-two-channels.md +22 -0
  8. package/docs/adr/0004-direct-call-policy-owned-by-target-device.md +23 -0
  9. package/docs/adr/0005-upgrade-session-to-room-host.md +22 -0
  10. package/docs/adr/0006-addressed-messages-and-any-device-speaks.md +16 -0
  11. package/docs/adr/0007-user-speech-decoupled-from-device.md +21 -0
  12. package/docs/adr/0008-extend-global-task-list-as-room-board.md +21 -0
  13. package/docs/adr/0009-room-policy-configured-at-creation.md +21 -0
  14. package/docs/adr/0010-room-policy-v1-fields-and-custom-room-types.md +24 -0
  15. package/docs/adr/0011-room-board-lifecycle-and-hooks.md +27 -0
  16. package/docs/adr/0012-lazy-wake-and-directed-speech.md +22 -0
  17. package/docs/adr/0013-direct-call-as-device-agnostic-functions.md +26 -0
  18. package/docs/adr/0014-fleet-call-unified-entry-with-typed-shortcuts.md +22 -0
  19. package/docs/adr/0015-room-plus-local-teams-and-delegation.md +22 -0
  20. package/docs/adr/0016-delegation-upgrade-reuses-session.md +20 -0
  21. package/docs/adr/0017-network-phases-direct-and-relay.md +21 -0
  22. package/docs/adr/0018-offline-fail-fast-and-durable-room-log.md +21 -0
  23. package/docs/adr/0019-execution-context-configured-locally.md +24 -0
  24. package/docs/adr/0020-approval-inbox-with-scoped-grants.md +22 -0
  25. package/docs/adr/0021-room-visibility-user-full-agents-scoped.md +26 -0
  26. package/docs/adr/0022-tool-adoption-mechanisms.md +23 -0
  27. package/docs/adr/0023-reuse-session-trajectory-for-call-details.md +22 -0
  28. package/docs/adr/0024-strict-version-match-with-reserved-capabilities.md +22 -0
  29. package/docs/notes/design-decisions-summary.md +49 -0
  30. package/docs/notes/dsh-global-task-list-evaluation.md +54 -0
  31. package/docs/notes/network-v1-v2-implementation.md +71 -0
  32. package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
  33. package/package.json +45 -6
  34. package/src/host/direct-call.js +190 -0
  35. package/src/host/execution-slot.js +82 -0
  36. package/src/host/index.js +6 -0
  37. package/src/host/store.js +128 -0
  38. package/src/host/tool-policy.js +150 -0
  39. package/src/host/tools.js +215 -0
  40. package/src/index.js +3 -0
  41. package/src/net/direct.js +69 -0
  42. package/src/net/discovery.js +105 -0
  43. package/src/net/fleet-node.js +414 -0
  44. package/src/net/identity.js +156 -0
  45. package/src/net/index.js +9 -0
  46. package/src/net/protocol.js +73 -0
  47. package/src/net/relay-client.js +258 -0
  48. package/src/net/relay-server.js +366 -0
  49. package/src/net/secure-channel.js +538 -0
  50. package/src/plugin/index.js +276 -0
@@ -0,0 +1,414 @@
1
+ // FleetNode: one device's networking facade.
2
+ //
3
+ // It owns the identity, the fleet roster, a direct listener, optional LAN
4
+ // discovery, an optional relay client, and the RPC layer that runs over
5
+ // authenticated SecureChannels.
6
+ import { EventEmitter } from 'node:events'
7
+ import { randomUUID } from 'node:crypto'
8
+ import { createDirectServer, connectDirect } from './direct.js'
9
+ import { LanDiscovery } from './discovery.js'
10
+ import { RelayClient } from './relay-client.js'
11
+ import { SecureChannel } from './secure-channel.js'
12
+ import {
13
+ FRAME,
14
+ PROTOCOL_VERSION,
15
+ eventFrame,
16
+ rpcError,
17
+ rpcRequest,
18
+ rpcResult,
19
+ serializeError,
20
+ } from './protocol.js'
21
+
22
+ const DEFAULT_CONNECT_TIMEOUT_MS = 12_000
23
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000
24
+
25
+ export class FleetNode extends EventEmitter {
26
+ /**
27
+ * @param {object} options
28
+ * @param {object} options.identity device identity (generateIdentity)
29
+ * @param {{fleetId: string, fleetKey: string, name?: string}} options.fleet
30
+ * @param {string} [options.name]
31
+ * @param {string|null} [options.accountFingerprint]
32
+ * @param {{host?: string, port?: number}|false} [options.direct] direct listener (false disables)
33
+ * @param {false|{group?: string, port?: number, intervalMs?: number}} [options.discovery]
34
+ * @param {null|{host: string, port: number, dataPort: number, reconnect?: object}} [options.relay]
35
+ * @param {Array<{deviceId: string, edPublic: string, xPublic: string, name?: string}>} [options.roster]
36
+ * @param {boolean} [options.trustOnFirstUse]
37
+ * @param {boolean} [options.acceptRelayConnections]
38
+ */
39
+ constructor({
40
+ identity,
41
+ fleet,
42
+ name = identity?.name ?? 'device',
43
+ accountFingerprint = null,
44
+ direct = { host: '127.0.0.1', port: 0 },
45
+ discovery = false,
46
+ relay = null,
47
+ roster = [],
48
+ trustOnFirstUse = true,
49
+ acceptRelayConnections = true,
50
+ connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
51
+ requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
52
+ pluginVersion = '0.1.0',
53
+ dshVersion = 'unknown',
54
+ capabilities = [],
55
+ } = {}) {
56
+ super()
57
+ if (!identity?.deviceId || !identity.edPrivate || !identity.edPublic) throw new Error('FleetNode needs an identity')
58
+ if (!fleet?.fleetId || !fleet?.fleetKey) throw new Error('FleetNode needs a fleet')
59
+ this.identity = identity
60
+ this.fleet = fleet
61
+ this.name = name
62
+ this.accountFingerprint = accountFingerprint
63
+ this.directConfig = direct
64
+ this.discoveryConfig = discovery
65
+ this.relayConfig = relay
66
+ this.trustOnFirstUse = trustOnFirstUse
67
+ this.acceptRelayConnections = acceptRelayConnections
68
+ this.connectTimeoutMs = connectTimeoutMs
69
+ this.requestTimeoutMs = requestTimeoutMs
70
+ this.pluginVersion = pluginVersion
71
+ this.dshVersion = dshVersion
72
+ this.capabilities = capabilities
73
+
74
+ this.roster = new Map()
75
+ for (const entry of roster) this.roster.set(entry.deviceId, { name: 'device', ...entry })
76
+ this.discovered = new Map()
77
+ this.channels = new Map()
78
+ this.pendingRpc = new Map()
79
+ this.requestHandler = null
80
+ this.eventHandler = null
81
+
82
+ this.directServer = null
83
+ this.discovery = null
84
+ this.relayClient = null
85
+ this.started = false
86
+ this.closed = false
87
+ }
88
+
89
+ get directPort() {
90
+ return this.directAddress?.port ?? null
91
+ }
92
+
93
+ rosterEntries() {
94
+ return [...this.roster.values()]
95
+ }
96
+
97
+ upsertRoster(entry) {
98
+ const existing = this.roster.get(entry.deviceId)
99
+ this.roster.set(entry.deviceId, { ...existing, name: 'device', ...entry })
100
+ this.emit('roster', this.rosterEntries())
101
+ }
102
+
103
+ onRequest(handler) {
104
+ this.requestHandler = handler
105
+ }
106
+
107
+ onEvent(handler) {
108
+ this.eventHandler = handler
109
+ }
110
+
111
+ async start() {
112
+ if (this.started) return this
113
+ this.started = true
114
+
115
+ if (this.directConfig !== false) {
116
+ this.directServer = createDirectServer({
117
+ host: this.directConfig.host ?? '0.0.0.0',
118
+ port: this.directConfig.port ?? 0,
119
+ })
120
+ this.directAddress = await this.directServer.ready
121
+ this.directServer.server.on('connection', (socket) => this.acceptInbound(socket, 'direct'))
122
+ }
123
+
124
+ if (this.discoveryConfig !== false && this.discoveryConfig !== undefined) {
125
+ if (!this.directAddress) throw new Error('discovery needs the direct listener')
126
+ const config = this.discoveryConfig === true ? {} : this.discoveryConfig
127
+ this.discovery = new LanDiscovery({
128
+ fleetId: this.fleet.fleetId,
129
+ deviceId: this.identity.deviceId,
130
+ name: this.name,
131
+ directPort: this.directAddress.port,
132
+ group: config.group,
133
+ port: config.port,
134
+ intervalMs: config.intervalMs,
135
+ })
136
+ this.discovery.on('peer', (peer) => {
137
+ this.discovered.set(peer.deviceId, peer)
138
+ this.emit('discovered', peer)
139
+ })
140
+ this.discovery.on('error', (error) => this.emit('warning', error))
141
+ this.discovery.start()
142
+ }
143
+
144
+ if (this.relayConfig) {
145
+ this.relayClient = new RelayClient({
146
+ host: this.relayConfig.host,
147
+ port: this.relayConfig.port,
148
+ dataPort: this.relayConfig.dataPort,
149
+ fleetId: this.fleet.fleetId,
150
+ deviceId: this.identity.deviceId,
151
+ fleetKey: this.fleet.fleetKey,
152
+ reconnect: this.relayConfig.reconnect,
153
+ })
154
+ this.relayClient.on('online', () => this.emit('relay-online'))
155
+ this.relayClient.on('offline', () => this.emit('relay-offline'))
156
+ this.relayClient.on('relay-error', (error) => this.emit('warning', error))
157
+ this.relayClient.on('incoming', (controller) => this.acceptIncomingRelay(controller))
158
+ try {
159
+ await this.relayClient.start(5_000)
160
+ } catch (error) {
161
+ this.emit('warning', error)
162
+ }
163
+ }
164
+
165
+ this.emit('started', { direct: this.directAddress ?? null })
166
+ return this
167
+ }
168
+
169
+ async acceptIncomingRelay(controller) {
170
+ if (!this.acceptRelayConnections) {
171
+ controller.reject()
172
+ return
173
+ }
174
+ try {
175
+ const socket = await controller.accept()
176
+ this.acceptInbound(socket, 'relay')
177
+ } catch (error) {
178
+ this.emit('warning', error)
179
+ }
180
+ }
181
+
182
+ acceptInbound(socket, via) {
183
+ const channel = new SecureChannel({
184
+ socket,
185
+ identity: this.identity,
186
+ fleet: this.fleet,
187
+ role: 'responder',
188
+ accountFingerprint: this.accountFingerprint,
189
+ verifyPeer: (peer) => this.authorizePeer(peer),
190
+ pluginVersion: this.pluginVersion,
191
+ dshVersion: this.dshVersion,
192
+ capabilities: this.capabilities,
193
+ })
194
+ this.attachChannel(channel, via)
195
+ }
196
+
197
+ attachChannel(channel, via) {
198
+ channel.on('error', (error) => this.emit('warning', error))
199
+ channel.on('ready', (peer) => {
200
+ this.registerChannel(peer, channel, via)
201
+ })
202
+ channel.on('close', () => {
203
+ const current = this.channels.get(channel.peer?.deviceId)
204
+ if (current === channel) this.channels.delete(channel.peer.deviceId)
205
+ this.rejectPendingForChannel(channel, Object.assign(new Error('peer connection closed'), { code: 'peer-offline' }))
206
+ this.emit('peer-disconnected', { deviceId: channel.peer?.deviceId, via })
207
+ })
208
+ }
209
+
210
+ registerChannel(peer, channel, via) {
211
+ this.channels.set(peer.deviceId, channel)
212
+ channel.on('frame', (message) => this.onFrame(peer.deviceId, message, channel))
213
+ this.emit('peer', { deviceId: peer.deviceId, name: peer.name, via, channel })
214
+ }
215
+
216
+ authorizePeer(peer) {
217
+ const existing = this.roster.get(peer.deviceId)
218
+ if (existing) {
219
+ return existing.edPublic === peer.edPublic && existing.xPublic === peer.xPublic
220
+ }
221
+ if (!this.trustOnFirstUse) return false
222
+ this.roster.set(peer.deviceId, {
223
+ deviceId: peer.deviceId,
224
+ name: peer.name ?? 'device',
225
+ edPublic: peer.edPublic,
226
+ xPublic: peer.xPublic,
227
+ addedAt: Date.now(),
228
+ })
229
+ this.emit('roster', this.rosterEntries())
230
+ return true
231
+ }
232
+
233
+ async connect(deviceId, { host, port, timeoutMs = this.connectTimeoutMs } = {}) {
234
+ if (typeof deviceId !== 'string' || deviceId === '') throw new Error('connect needs a device id')
235
+ if (deviceId === this.identity.deviceId) throw new Error('cannot connect to self')
236
+ const existing = this.channels.get(deviceId)
237
+ if (existing && !existing.closed) return existing
238
+
239
+ const expected = this.roster.get(deviceId) ?? { deviceId }
240
+ const discovered = this.discovered.get(deviceId)
241
+ const endpoint = host && port ? { host, port } : discovered ? { host: discovered.host, port: discovered.port } : null
242
+ let directError = null
243
+ if (endpoint) {
244
+ try {
245
+ const socket = await connectDirect({ host: endpoint.host, port: endpoint.port, timeoutMs })
246
+ const channel = this.createInitiator(socket, expected)
247
+ await this.waitReady(channel, timeoutMs)
248
+ return channel
249
+ } catch (error) {
250
+ directError = error
251
+ }
252
+ }
253
+ if (this.relayClient) {
254
+ try {
255
+ const socket = await this.relayClient.requestConnect(deviceId, timeoutMs)
256
+ const channel = this.createInitiator(socket, expected)
257
+ await this.waitReady(channel, timeoutMs)
258
+ return channel
259
+ } catch (error) {
260
+ throw Object.assign(new Error(`cannot reach ${deviceId} via relay: ${String(error.message)}`), {
261
+ code: 'unreachable',
262
+ cause: error,
263
+ })
264
+ }
265
+ }
266
+ throw Object.assign(
267
+ new Error(`cannot reach ${deviceId}${directError ? `: ${String(directError.message)}` : ' (no endpoint)'}`),
268
+ { code: 'unreachable', cause: directError ?? undefined },
269
+ )
270
+ }
271
+
272
+ createInitiator(socket, expectedPeer) {
273
+ const channel = new SecureChannel({
274
+ socket,
275
+ identity: this.identity,
276
+ fleet: this.fleet,
277
+ role: 'initiator',
278
+ expectedPeer,
279
+ accountFingerprint: this.accountFingerprint,
280
+ verifyPeer: (peer) => this.authorizePeer(peer),
281
+ pluginVersion: this.pluginVersion,
282
+ dshVersion: this.dshVersion,
283
+ capabilities: this.capabilities,
284
+ })
285
+ this.attachChannel(channel, 'outbound')
286
+ return channel
287
+ }
288
+
289
+ waitReady(channel, timeoutMs) {
290
+ return new Promise((resolve, reject) => {
291
+ if (channel.authenticated) return resolve(channel)
292
+ const timer = setTimeout(() => {
293
+ cleanup()
294
+ reject(Object.assign(new Error('secure channel handshake timed out'), { code: 'handshake-timeout' }))
295
+ channel.destroy()
296
+ }, timeoutMs)
297
+ timer.unref?.()
298
+ const onReady = () => {
299
+ cleanup()
300
+ resolve(channel)
301
+ }
302
+ const onClose = () => {
303
+ cleanup()
304
+ reject(Object.assign(new Error('secure channel closed during handshake'), { code: 'handshake-closed' }))
305
+ }
306
+ const cleanup = () => {
307
+ clearTimeout(timer)
308
+ channel.off('ready', onReady)
309
+ channel.off('close', onClose)
310
+ }
311
+ channel.once('ready', onReady)
312
+ channel.once('close', onClose)
313
+ })
314
+ }
315
+
316
+ onFrame(deviceId, message, channel) {
317
+ switch (message.t) {
318
+ case FRAME.RPC_REQUEST: {
319
+ const handler = this.requestHandler
320
+ if (typeof handler !== 'function') {
321
+ channel.sendFrame(rpcError({ id: message.id, code: 'no-handler', message: 'no request handler' }))
322
+ return
323
+ }
324
+ Promise.resolve()
325
+ .then(() => handler({ deviceId, method: message.method, params: message.params }))
326
+ .then((result) => channel.sendFrame(rpcResult({ id: message.id, result })))
327
+ .catch((error) => {
328
+ channel.sendFrame(rpcError({ id: message.id, ...serializeError(error) }))
329
+ })
330
+ return
331
+ }
332
+ case FRAME.RPC_RESULT: {
333
+ const pending = this.pendingRpc.get(message.id)
334
+ if (!pending) return
335
+ this.pendingRpc.delete(message.id)
336
+ clearTimeout(pending.timer)
337
+ pending.resolve(message.result)
338
+ return
339
+ }
340
+ case FRAME.RPC_ERROR: {
341
+ const pending = this.pendingRpc.get(message.id)
342
+ if (!pending) return
343
+ this.pendingRpc.delete(message.id)
344
+ clearTimeout(pending.timer)
345
+ const error = new Error(message.error?.message ?? 'remote error')
346
+ error.code = message.error?.code ?? 'remote-error'
347
+ error.data = message.error?.data ?? null
348
+ pending.reject(error)
349
+ return
350
+ }
351
+ case FRAME.EVENT: {
352
+ this.emit('event', { deviceId, topic: message.topic, data: message.data })
353
+ return
354
+ }
355
+ default:
356
+ return
357
+ }
358
+ }
359
+
360
+ rejectPendingForChannel(channel, error) {
361
+ for (const [id, pending] of this.pendingRpc) {
362
+ if (pending.channel === channel) {
363
+ this.pendingRpc.delete(id)
364
+ clearTimeout(pending.timer)
365
+ pending.reject(error)
366
+ }
367
+ }
368
+ }
369
+
370
+ async request(deviceId, method, params, { timeoutMs = this.requestTimeoutMs } = {}) {
371
+ const channel = await this.connect(deviceId)
372
+ const id = randomUUID()
373
+ return new Promise((resolve, reject) => {
374
+ const timer = setTimeout(() => {
375
+ this.pendingRpc.delete(id)
376
+ reject(Object.assign(new Error(`request ${method} to ${deviceId} timed out`), { code: 'timeout' }))
377
+ }, timeoutMs)
378
+ timer.unref?.()
379
+ this.pendingRpc.set(id, { resolve, reject, timer, channel })
380
+ try {
381
+ channel.sendFrame(rpcRequest({ id, method, params }))
382
+ } catch (error) {
383
+ this.pendingRpc.delete(id)
384
+ clearTimeout(timer)
385
+ reject(error)
386
+ }
387
+ })
388
+ }
389
+
390
+ async sendEvent(deviceId, topic, data) {
391
+ const channel = await this.connect(deviceId)
392
+ channel.sendFrame(eventFrame({ topic, data }))
393
+ }
394
+
395
+ peerIds() {
396
+ return [...this.channels.keys()]
397
+ }
398
+
399
+ async close() {
400
+ this.closed = true
401
+ for (const channel of this.channels.values()) channel.close()
402
+ this.channels.clear()
403
+ for (const [id, pending] of this.pendingRpc) {
404
+ this.pendingRpc.delete(id)
405
+ clearTimeout(pending.timer)
406
+ pending.reject(Object.assign(new Error('node closed'), { code: 'closed' }))
407
+ }
408
+ this.relayClient?.close()
409
+ this.discovery?.close()
410
+ if (this.directServer) await this.directServer.close()
411
+ }
412
+ }
413
+
414
+ export { PROTOCOL_VERSION }
@@ -0,0 +1,156 @@
1
+ // Device identity, fleet invites and membership proofs.
2
+ //
3
+ // Every device owns an Ed25519 keypair (authentication) and an X25519 keypair
4
+ // (key agreement). A Fleet is identified by `fleetId` and a shared 32-byte
5
+ // `fleetKey` carried by invites; the key proves membership and bootstraps trust
6
+ // on first contact (TOFU), after which peer public keys are pinned in the roster.
7
+ import {
8
+ createHash,
9
+ createHmac,
10
+ createPrivateKey,
11
+ createPublicKey,
12
+ generateKeyPairSync,
13
+ randomBytes,
14
+ randomUUID,
15
+ } from 'node:crypto'
16
+
17
+ const INVITE_PREFIX = 'DSHF1.'
18
+ const PROOF_PREFIX = 'member'
19
+ const RELAY_PREFIX = 'relay-register'
20
+ const ACCOUNT_PREFIX = 'account'
21
+
22
+ export function exportKey(key) {
23
+ return key.export({ type: key.type === 'private' ? 'pkcs8' : 'spki', format: 'pem' }).toString()
24
+ }
25
+
26
+ export function importPrivateKey(pem) {
27
+ return createPrivateKey(pem)
28
+ }
29
+
30
+ export function importPublicKey(pem) {
31
+ return createPublicKey(pem)
32
+ }
33
+
34
+ function randomBase64Url(bytes) {
35
+ return randomBytes(bytes).toString('base64url')
36
+ }
37
+
38
+ /** A fresh device identity. Caller persists it (e.g. `$DSH_HOME/device-fleet/identity.json`). */
39
+ export function generateIdentity({ name = 'device' } = {}) {
40
+ const ed = generateKeyPairSync('ed25519')
41
+ const x = generateKeyPairSync('x25519')
42
+ return {
43
+ deviceId: randomUUID(),
44
+ name,
45
+ createdAt: Date.now(),
46
+ edPrivate: exportKey(ed.privateKey),
47
+ edPublic: exportKey(ed.publicKey),
48
+ xPrivate: exportKey(x.privateKey),
49
+ xPublic: exportKey(x.publicKey),
50
+ }
51
+ }
52
+
53
+ /** Public half of an identity, safe to put in a roster/invite. */
54
+ export function publicIdentity(identity) {
55
+ return {
56
+ deviceId: identity.deviceId,
57
+ name: identity.name,
58
+ edPublic: identity.edPublic,
59
+ xPublic: identity.xPublic,
60
+ }
61
+ }
62
+
63
+ /** A new fleet. `fleetKey` is the fleet-wide membership secret carried by invites. */
64
+ export function createFleet({ name = 'fleet' } = {}) {
65
+ return {
66
+ fleetId: randomUUID(),
67
+ fleetKey: randomBase64Url(32),
68
+ name,
69
+ createdAt: Date.now(),
70
+ }
71
+ }
72
+
73
+ export function encodeInvite({ fleet, accountFingerprint = null, relay = null }) {
74
+ const payload = {
75
+ v: 1,
76
+ fleetId: fleet.fleetId,
77
+ fleetKey: fleet.fleetKey,
78
+ name: fleet.name,
79
+ accountFingerprint,
80
+ relay,
81
+ createdAt: Date.now(),
82
+ }
83
+ return INVITE_PREFIX + Buffer.from(JSON.stringify(payload)).toString('base64url')
84
+ }
85
+
86
+ export function decodeInvite(code) {
87
+ if (typeof code !== 'string' || !code.startsWith(INVITE_PREFIX)) throw new Error('invalid invite')
88
+ const raw = code.slice(INVITE_PREFIX.length)
89
+ let payload
90
+ try {
91
+ payload = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'))
92
+ } catch {
93
+ throw new Error('invalid invite payload')
94
+ }
95
+ if (payload?.v !== 1 || typeof payload.fleetId !== 'string' || typeof payload.fleetKey !== 'string') {
96
+ throw new Error('unsupported invite')
97
+ }
98
+ return {
99
+ fleet: { fleetId: payload.fleetId, fleetKey: payload.fleetKey, name: payload.name ?? 'fleet' },
100
+ accountFingerprint: payload.accountFingerprint ?? null,
101
+ relay: payload.relay ?? null,
102
+ createdAt: payload.createdAt ?? null,
103
+ }
104
+ }
105
+
106
+ /** Account fingerprint: proves "same account" inside the fleet without exposing the account id. */
107
+ export function accountFingerprint(fleetKey, accountUserId) {
108
+ if (!accountUserId) return null
109
+ return createHmac('sha256', fleetKey).update(`${ACCOUNT_PREFIX}|${accountUserId}`).digest('base64url')
110
+ }
111
+
112
+ /** HMAC proof that this device belongs to the fleet (checked by peers during the handshake). */
113
+ export function membershipProof({ fleetKey, fleetId, deviceId, edPublic, xPublic, accountFingerprint = null }) {
114
+ return createHmac('sha256', fleetKey)
115
+ .update(`${PROOF_PREFIX}|${fleetId}|${deviceId}|${edPublic}|${xPublic}|${accountFingerprint ?? ''}`)
116
+ .digest('base64url')
117
+ }
118
+
119
+ export function verifyMembershipProof(proof, fields) {
120
+ const expected = membershipProof(fields)
121
+ const a = Buffer.from(String(proof))
122
+ const b = Buffer.from(expected)
123
+ return a.length === b.length && a.equals(b)
124
+ }
125
+
126
+ /** `fleetId` hash advertised in LAN announcements (the full id stays off the wire). */
127
+ export function fleetHash(fleetId) {
128
+ return createHash('sha256').update(`fleet|${fleetId}`).digest('hex').slice(0, 16)
129
+ }
130
+
131
+ export function shortFingerprint(edPublic, xPublic) {
132
+ return createHash('sha256')
133
+ .update(`${edPublic}|${xPublic}`)
134
+ .digest('hex')
135
+ .slice(0, 16)
136
+ }
137
+
138
+ /** Token proving fleet membership to a self-hosted relay. */
139
+ export function relayAuthToken({ fleetKey, fleetId, deviceId, ts }) {
140
+ return createHmac('sha256', fleetKey)
141
+ .update(`${RELAY_PREFIX}|${fleetId}|${deviceId}|${ts}`)
142
+ .digest('base64url')
143
+ }
144
+
145
+ export function verifyRelayAuthToken(token, { fleetKey, fleetId, deviceId, ts }) {
146
+ const expected = relayAuthToken({ fleetKey, fleetId, deviceId, ts })
147
+ const a = Buffer.from(String(token))
148
+ const b = Buffer.from(expected)
149
+ return a.length === b.length && a.equals(b)
150
+ }
151
+
152
+ /** Ephemeral X25519 pair for one handshake. */
153
+ export function generateEphemeral() {
154
+ const pair = generateKeyPairSync('x25519')
155
+ return { privateKey: pair.privateKey, publicKey: exportKey(pair.publicKey), privateKeyPem: exportKey(pair.privateKey) }
156
+ }
@@ -0,0 +1,9 @@
1
+ // Network layer exports.
2
+ export * from './identity.js'
3
+ export * from './protocol.js'
4
+ export * from './secure-channel.js'
5
+ export * from './direct.js'
6
+ export * from './discovery.js'
7
+ export * from './relay-server.js'
8
+ export * from './relay-client.js'
9
+ export * from './fleet-node.js'
@@ -0,0 +1,73 @@
1
+ // Wire protocol constants, canonical JSON and RPC envelope helpers.
2
+
3
+ export const PROTOCOL_VERSION = 1
4
+
5
+ /** Largest accepted frame payload (ciphertext bytes, excluding the length prefix). */
6
+ export const MAX_FRAME_BYTES = 32 * 1024 * 1024
7
+
8
+ /** Handshake messages are newline-delimited JSON; encrypted frames are length-prefixed. */
9
+ export const HANDSHAKE_TIMEOUT_MS = 10_000
10
+
11
+ export const FRAME = Object.freeze({
12
+ READY: 'ready',
13
+ RPC_REQUEST: 'rpc.req',
14
+ RPC_RESULT: 'rpc.res',
15
+ RPC_ERROR: 'rpc.err',
16
+ EVENT: 'event',
17
+ PING: 'ping',
18
+ PONG: 'pong',
19
+ })
20
+
21
+ export function stableStringify(value) {
22
+ return JSON.stringify(sortValue(value))
23
+ }
24
+
25
+ function sortValue(value) {
26
+ if (Array.isArray(value)) return value.map(sortValue)
27
+ if (value && typeof value === 'object') {
28
+ const out = {}
29
+ for (const key of Object.keys(value).sort()) out[key] = sortValue(value[key])
30
+ return out
31
+ }
32
+ return value
33
+ }
34
+
35
+ export function rpcRequest({ id, method, params }) {
36
+ return { t: FRAME.RPC_REQUEST, v: PROTOCOL_VERSION, id, method, params: params ?? null }
37
+ }
38
+
39
+ export function rpcResult({ id, result }) {
40
+ return { t: FRAME.RPC_RESULT, v: PROTOCOL_VERSION, id, result: result ?? null }
41
+ }
42
+
43
+ export function rpcError({ id, code, message, data = null }) {
44
+ return { t: FRAME.RPC_ERROR, v: PROTOCOL_VERSION, id, error: { code, message, data } }
45
+ }
46
+
47
+ export function eventFrame({ topic, data }) {
48
+ return { t: FRAME.EVENT, v: PROTOCOL_VERSION, topic, data: data ?? null }
49
+ }
50
+
51
+ export function pingFrame() {
52
+ return { t: FRAME.PING, v: PROTOCOL_VERSION }
53
+ }
54
+
55
+ export function pongFrame() {
56
+ return { t: FRAME.PONG, v: PROTOCOL_VERSION }
57
+ }
58
+
59
+ export function serializeError(error) {
60
+ if (!error) return { code: 'internal', message: 'unknown error', data: null }
61
+ return {
62
+ code: typeof error.code === 'string' ? error.code : 'internal',
63
+ message: String(error.message ?? error),
64
+ data: error.data ?? null,
65
+ }
66
+ }
67
+
68
+ export function makeError(code, message, data = null) {
69
+ const error = new Error(message)
70
+ error.code = code
71
+ error.data = data
72
+ return error
73
+ }