@tetherto/mdk-worker 0.0.0 → 0.12.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.
@@ -0,0 +1,589 @@
1
+ 'use strict'
2
+
3
+ const crypto = require('crypto')
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+ const HyperswarmRPC = require('@hyperswarm/rpc')
7
+ const Hyperswarm = require('hyperswarm')
8
+ const DHT = require('hyperdht')
9
+ const debug = require('debug')('mdk:worker:runtime')
10
+ const { ACTIONS } = require('@tetherto/mdk-kernel/protocol/actions')
11
+ const { buildResponse, serialize, deserialize, validateFull: validateEnvelope } = require('@tetherto/mdk-kernel/protocol/envelope')
12
+ const { loadPlugin } = require('./plugin-loader')
13
+ const { telemetryBuiltin, commandBuiltin, mergeBuiltinCommands, builtinCommandParams } = require('./service-builtins')
14
+ const { validateParams } = require('./param-validator')
15
+
16
+ // Errors that already carry a protocol code (ERR_*) are part of the wire
17
+ // contract and go back as-is; anything else (a stack-less TypeError from a
18
+ // handler, a driver message with an address or a credential in it) is logged
19
+ // here and replaced by the fallback code before it crosses the RPC boundary.
20
+ const ERR_CODE = /^ERR_[A-Z0-9_]+/
21
+
22
+ /**
23
+ * Generic host for a Worker Plugin: N same-type devices behind one HRPC
24
+ * channel to the Kernel. The plugin is loaded (never subclassed); every
25
+ * command/telemetry handler is invoked with the addressed device's context
26
+ * ({ deviceId, device, config }) and its return value is wrapped into the
27
+ * MDK Protocol envelope here. Handlers never see envelopes or transport.
28
+ *
29
+ * The device list is fixed at construction. A device whose connect() fails
30
+ * is held as `offline` (requests to it return ERR_DEVICE_UNAVAILABLE) so one
31
+ * unreachable unit cannot take down its siblings.
32
+ *
33
+ * `opts.services` (optional) injects worker-infra services (logs, comments,
34
+ * settings, stats, provisioning, write-call approval). When present, the
35
+ * runtime serves the legacy adapter surface from them as built-ins (see
36
+ * service-builtins.js) and exposes the same object to handlers as
37
+ * ctx.services. Services are process-owned, never plugin-owned.
38
+ *
39
+ * DHT/RPC identity: pass `opts.store` (Hyperbee-style) or `opts.storeDir`
40
+ * (directory of seed files) so the public key is stable across restarts.
41
+ * Without either, seeds are random per boot.
42
+ *
43
+ * RPC admission is fail closed: `opts.allowedKeys` (hex/Buffer public keys —
44
+ * the Kernel's kernel.getClientPublicKey()) or runtime allowKey() name who may
45
+ * call; start() throws ERR_WORKER_ALLOWED_KEYS_REQUIRED with none, unless
46
+ * `opts.allowAll: true` (development only, logs a warning). Every request is
47
+ * envelope-validated (validateFull) before dispatch.
48
+ */
49
+ class WorkerRuntime {
50
+ constructor (plugin, opts) {
51
+ opts = opts || {}
52
+ if (!opts.workerId || typeof opts.workerId !== 'string') throw new Error('ERR_WORKER_ID_REQUIRED')
53
+
54
+ this._plugin = loadPlugin(plugin)
55
+ this.workerId = opts.workerId
56
+ this.kernelTopic = opts.kernelTopic || null
57
+ this.store = opts.store || null
58
+ this.storeDir = opts.storeDir || null
59
+ this.services = opts.services || null
60
+ this._publishedContract = mergeBuiltinCommands(this._plugin.publishedContract, this.services)
61
+
62
+ this._commandParamNames = new Map()
63
+ this._commandParams = new Map()
64
+ for (const entry of this._plugin.contract.capabilities.commands || []) {
65
+ this._commandParamNames.set(entry.name, (entry.params || []).map((p) => p.name))
66
+ this._commandParams.set(entry.name, Array.isArray(entry.params) ? entry.params : [])
67
+ }
68
+ // Optional DHT bootstrap override. Defaults to hyperdht's public bootstrap;
69
+ // set only for a custom DHT network or hermetic tests (hyperdht/testnet).
70
+ this._bootstrap = opts.bootstrap || null
71
+
72
+ // RPC caller allowlist (fail closed). Only these public keys — normally
73
+ // the Kernel's client key, kernel.getClientPublicKey() — may connect;
74
+ // start() refuses to run with none unless allowAll is set explicitly.
75
+ this._allowedKeys = new Set()
76
+ for (const key of opts.allowedKeys || []) this.allowKey(key)
77
+ this._allowAll = opts.allowAll === true
78
+
79
+ this._devices = new Map()
80
+ for (const spec of this._validateDeviceSpecs(opts.devices, opts.allowEmptyDevices)) {
81
+ this._devices.set(spec.deviceId, {
82
+ deviceId: spec.deviceId,
83
+ config: spec.config,
84
+ device: null,
85
+ ctx: null,
86
+ status: 'offline',
87
+ error: null
88
+ })
89
+ }
90
+
91
+ this._rpc = null
92
+ this._server = null
93
+ this._swarm = null
94
+ this._discovery = null
95
+ this._announceTimer = null
96
+ this._dht = null
97
+ this._confBee = null
98
+ }
99
+
100
+ // allowEmpty supports provisioning-first bootstrap: a fresh worker boots
101
+ // with no devices, receives registerThing writes, and is restarted with
102
+ // the provisioned set. Without the flag an empty list stays an error.
103
+ _validateDeviceSpecs (devices, allowEmpty) {
104
+ if (allowEmpty && (!devices || (Array.isArray(devices) && devices.length === 0))) return []
105
+ if (!Array.isArray(devices) || devices.length === 0) throw new Error('ERR_DEVICES_REQUIRED')
106
+ const seen = new Set()
107
+ return devices.map((spec) => {
108
+ if (!spec || typeof spec.deviceId !== 'string' || !spec.deviceId) throw new Error('ERR_DEVICE_ID_MISSING')
109
+ if (seen.has(spec.deviceId)) throw new Error(`ERR_DEVICE_ID_DUPLICATE: ${spec.deviceId}`)
110
+ seen.add(spec.deviceId)
111
+ if (spec.config !== undefined && (typeof spec.config !== 'object' || spec.config === null)) {
112
+ throw new Error(`ERR_DEVICE_CONFIG_INVALID: ${spec.deviceId}`)
113
+ }
114
+ return { deviceId: spec.deviceId, config: spec.config || {} }
115
+ })
116
+ }
117
+
118
+ async start () {
119
+ if (this._allowAll) {
120
+ console.warn(`[mdk-worker] WARNING: worker ${this.workerId} runs with allowAll — any DHT peer that learns its key can command its devices. Never use this outside local development.`)
121
+ } else if (this._allowedKeys.size === 0) {
122
+ throw new Error('ERR_WORKER_ALLOWED_KEYS_REQUIRED: pass opts.allowedKeys (the Kernel client key, kernel.getClientPublicKey()) or opts.allowAll: true for local development')
123
+ }
124
+
125
+ await this._openContexts()
126
+
127
+ const seedDht = await this._getOrCreateSeed('seedDht')
128
+ const seedRpc = await this._getOrCreateSeed('seedRpc')
129
+
130
+ this._dht = new DHT({ keyPair: DHT.keyPair(seedDht), ...(this._bootstrap ? { bootstrap: this._bootstrap } : {}) })
131
+ this._rpc = new HyperswarmRPC({ dht: this._dht, seed: seedRpc })
132
+ this._server = this._rpc.createServer({ firewall: (remotePublicKey) => !this._isAllowed(remotePublicKey) })
133
+
134
+ this._server.respond('mdk', async (reqBuf, rpc) => {
135
+ try {
136
+ // Re-checked per request so revokeKey() takes effect on open connections.
137
+ const remote = rpc && rpc.stream && rpc.stream.remotePublicKey
138
+ if (!remote || !this._isAllowed(remote)) return serialize({ error: 'ERR_WORKER_CALLER_NOT_ALLOWED' })
139
+ const envelope = deserialize(reqBuf)
140
+ const validation = validateEnvelope(envelope)
141
+ if (!validation.valid) return serialize({ error: validation.error })
142
+ const result = await this.handleRequest(envelope)
143
+ return serialize(result)
144
+ } catch (err) {
145
+ return serialize({ error: _publicError(err, 'ERR_REQUEST_FAILED') })
146
+ }
147
+ })
148
+
149
+ await this._server.listen()
150
+ debug('runtime RPC listening (key: %s...)', this._server.publicKey.toString('hex').slice(0, 16))
151
+
152
+ if (this.kernelTopic) {
153
+ await this._joinDiscoveryTopic()
154
+ }
155
+
156
+ return { publicKey: this._server.publicKey }
157
+ }
158
+
159
+ async stop () {
160
+ if (this._announceTimer) { clearInterval(this._announceTimer); this._announceTimer = null }
161
+ this._discovery = null
162
+ if (this._swarm) { await this._swarm.destroy(); this._swarm = null }
163
+ if (this._server) { await this._server.close(); this._server = null }
164
+ if (this._rpc) { await this._rpc.destroy(); this._rpc = null }
165
+ if (this._dht) { await this._dht.destroy(); this._dht = null }
166
+ await this._closeContexts()
167
+ debug('worker runtime stopped')
168
+ }
169
+
170
+ /**
171
+ * Admit an RPC caller (hex or Buffer public key), e.g. a Kernel's
172
+ * getClientPublicKey(). Works before or after start().
173
+ */
174
+ allowKey (key) {
175
+ const hex = _normalizeKey(key)
176
+ this._allowedKeys.add(hex)
177
+ return hex
178
+ }
179
+
180
+ revokeKey (key) {
181
+ return this._allowedKeys.delete(_normalizeKey(key))
182
+ }
183
+
184
+ getAllowedKeys () {
185
+ return [...this._allowedKeys]
186
+ }
187
+
188
+ _isAllowed (remotePublicKey) {
189
+ if (this._allowAll) return true
190
+ const ok = this._allowedKeys.has(remotePublicKey.toString('hex'))
191
+ if (!ok) debug('blocked caller: %s...', remotePublicKey.toString('hex').slice(0, 16))
192
+ return ok
193
+ }
194
+
195
+ getPublicKey () {
196
+ return this._server ? this._server.publicKey : null
197
+ }
198
+
199
+ // Read access to a device's frozen handler context ({ deviceId, device,
200
+ // config, services }) for the process that owns the runtime — e.g. to wire
201
+ // a snap-collection service to the device clients. Null while offline.
202
+ getDeviceContext (deviceId) {
203
+ const entry = this._devices.get(deviceId)
204
+ return entry && entry.status === 'online' ? entry.ctx : null
205
+ }
206
+
207
+ async _openContexts () {
208
+ for (const entry of this._devices.values()) {
209
+ if (entry.status === 'online') continue
210
+ try {
211
+ entry.device = await this._plugin.connect(entry.config, { deviceId: entry.deviceId })
212
+ entry.ctx = Object.freeze({ deviceId: entry.deviceId, device: entry.device, config: entry.config, services: this.services })
213
+ entry.status = 'online'
214
+ entry.error = null
215
+ debug('device context opened: %s', entry.deviceId)
216
+ } catch (err) {
217
+ entry.error = err.message
218
+ debug('connect failed for %s: %s', entry.deviceId, err.message)
219
+ }
220
+ }
221
+ }
222
+
223
+ async _closeContexts () {
224
+ if (!this._plugin.disconnect) return
225
+ for (const entry of this._devices.values()) {
226
+ if (entry.status !== 'online') continue
227
+ try {
228
+ await this._plugin.disconnect(entry.device, { deviceId: entry.deviceId })
229
+ } catch (err) {
230
+ debug('disconnect failed for %s: %s', entry.deviceId, err.message)
231
+ }
232
+ entry.status = 'offline'
233
+ }
234
+ }
235
+
236
+ async _getOrCreateSeed (name) {
237
+ if (this.store) {
238
+ if (!this._confBee) {
239
+ this._confBee = this.store.getBee
240
+ ? this.store.getBee({ name: 'workerConf' }, { keyEncoding: 'utf-8' })
241
+ : this.store.sub('workerConf')
242
+ if (this._confBee.ready) await this._confBee.ready()
243
+ }
244
+
245
+ const existing = await this._confBee.get(name)
246
+ if (existing && existing.value) {
247
+ return Buffer.isBuffer(existing.value) ? existing.value : Buffer.from(existing.value)
248
+ }
249
+
250
+ const seed = crypto.randomBytes(32)
251
+ await this._confBee.put(name, seed)
252
+ debug('created persistent seed: %s', name)
253
+ return seed
254
+ }
255
+
256
+ if (this.storeDir) return this._getOrCreateSeedFile(name)
257
+
258
+ return crypto.randomBytes(32)
259
+ }
260
+
261
+ // File-backed seeds for hosts that own a directory but not a Hyperbee store
262
+ // (WorkerRuntimeV2 / CLI). Same 32-byte seedDht/seedRpc
263
+ // names the Hyperbee path uses, so the RPC public key stays stable across
264
+ // process restarts without pulling in worker-infra.
265
+ //
266
+ // The seed IS the worker's identity, so this fails closed: a malformed
267
+ // seed is an error (never silently replaced, which would rotate the key the
268
+ // Kernel knows), a symlink is refused, a file owned by another user is
269
+ // refused, and group/other permission bits are stripped. A new seed is
270
+ // created exclusively (O_EXCL, so a planted file or link is never written
271
+ // through) with mode 0600.
272
+ _getOrCreateSeedFile (name) {
273
+ fs.mkdirSync(this.storeDir, { recursive: true, mode: 0o700 })
274
+ const seedPath = path.join(this.storeDir, name)
275
+
276
+ const existing = _readSeedFile(seedPath)
277
+ if (existing) return existing
278
+
279
+ const seed = crypto.randomBytes(32)
280
+ const { O_WRONLY, O_CREAT, O_EXCL } = fs.constants
281
+ const fd = fs.openSync(seedPath, O_WRONLY | O_CREAT | O_EXCL, 0o600)
282
+ try {
283
+ fs.writeSync(fd, seed)
284
+ fs.fsyncSync(fd)
285
+ } finally {
286
+ fs.closeSync(fd)
287
+ }
288
+ debug('created persistent seed file: %s', name)
289
+ return seed
290
+ }
291
+
292
+ async _joinDiscoveryTopic () {
293
+ this._swarm = new Hyperswarm({ dht: this._dht })
294
+ const topicBuf = Buffer.isBuffer(this.kernelTopic)
295
+ ? this.kernelTopic
296
+ : Buffer.from(this.kernelTopic, 'hex')
297
+
298
+ // Attach the handler BEFORE join. A Hyperswarm connection is persistent and
299
+ // its `connection` event fires once, immediately on open — if the Kernel client
300
+ // connects before this is attached (e.g. during an awaited flush), the event
301
+ // is missed and the worker never writes its key on that long-lived socket,
302
+ // so the Kernel times out and discovery hangs.
303
+ this._swarm.on('connection', (stream) => {
304
+ stream.write(this._server.publicKey)
305
+ stream.on('error', () => {})
306
+ })
307
+
308
+ // Server join: announce this keypair under the topic. `discovery.flushed()`
309
+ // is the server-mode wait — it resolves once we are announced on the DHT and
310
+ // reachable (swarm.flush() is the client "connect to pending peers" wait, the
311
+ // wrong semantics here). Hold the handle so we can re-announce and leave it.
312
+ this._discovery = this._swarm.join(topicBuf, { server: true, client: false })
313
+ await this._discovery.flushed()
314
+
315
+ // Announce/lookup is an ongoing process; re-announce periodically so a Kernel
316
+ // that joins later (or after our announce expires) still finds us.
317
+ this._announceTimer = setInterval(() => {
318
+ if (this._discovery) this._discovery.refresh({ server: true }).catch(() => {})
319
+ }, 30000)
320
+ this._announceTimer.unref()
321
+
322
+ debug('joined discovery topic: %s... (announced)', topicBuf.toString('hex').slice(0, 16))
323
+ }
324
+
325
+ async handleRequest (envelope) {
326
+ switch (envelope.action) {
327
+ case ACTIONS.IDENTITY_REQUEST:
328
+ return this._handleIdentity(envelope)
329
+ case ACTIONS.CAPABILITY_REQUEST:
330
+ return this._handleCapability(envelope)
331
+ case ACTIONS.TELEMETRY_PULL:
332
+ return this._handleTelemetry(envelope)
333
+ case ACTIONS.COMMAND_REQUEST:
334
+ return this._handleCommand(envelope)
335
+ case ACTIONS.HEALTH_PING:
336
+ return this._handleHealthPing(envelope)
337
+ case ACTIONS.STATE_PULL:
338
+ return this._handleStatePull(envelope)
339
+ case ACTIONS.WRITE_CALLS_REQUEST:
340
+ if (this.services && this.services.actions) return this._handleWriteCalls(envelope)
341
+ break
342
+ }
343
+
344
+ return buildResponse(envelope, envelope.action, {
345
+ error: `ERR_UNKNOWN_ACTION: ${envelope.action}`
346
+ }, this.workerId)
347
+ }
348
+
349
+ async _handleWriteCalls (envelope) {
350
+ const res = await this.services.actions.getWriteCalls(envelope.payload || {})
351
+ return buildResponse(envelope, ACTIONS.WRITE_CALLS_RESPONSE, res, this.workerId)
352
+ }
353
+
354
+ _handleIdentity (envelope) {
355
+ return buildResponse(envelope, ACTIONS.IDENTITY_RESPONSE, {
356
+ workerId: this.workerId,
357
+ devices: [...this._devices.keys()].map((deviceId) => ({ deviceId }))
358
+ }, this.workerId)
359
+ }
360
+
361
+ _handleCapability (envelope) {
362
+ return buildResponse(envelope, ACTIONS.CAPABILITY_RESPONSE, {
363
+ contract: this._publishedContract
364
+ }, this.workerId)
365
+ }
366
+
367
+ async _handleTelemetry (envelope) {
368
+ const query = (envelope.payload && envelope.payload.query) || {}
369
+ const deviceId = envelope.deviceId || (envelope.payload && envelope.payload.deviceId) || null
370
+ const { type = 'metrics', ...params } = query
371
+
372
+ let result
373
+ const builtin = telemetryBuiltin(this.services, type)
374
+ if (builtin) {
375
+ // Adapter parity: worker-infra queries answer for absent/offline
376
+ // devices too (the record lives in the store, not on the device), and
377
+ // their errors come back inside the telemetry payload.
378
+ try {
379
+ result = await builtin.handle(this.services, deviceId, params, {
380
+ workerId: this.workerId,
381
+ metadata: this._publishedContract.metadata,
382
+ deviceIds: [...this._devices.keys()]
383
+ })
384
+ } catch (err) {
385
+ result = { error: _publicError(err, 'ERR_TELEMETRY_FAILED') }
386
+ }
387
+ } else if (type === 'list') {
388
+ result = { devices: [...this._devices.values()].map((e) => ({ deviceId: e.deviceId, status: e.status })) }
389
+ } else if (!deviceId) {
390
+ if (type !== 'metrics') {
391
+ result = { error: `ERR_DEVICE_ID_REQUIRED: ${type}` }
392
+ } else {
393
+ const devices = []
394
+ for (const entry of this._devices.values()) {
395
+ devices.push({ deviceId: entry.deviceId, ...(await this._collectMetrics(entry, params)) })
396
+ }
397
+ result = { devices }
398
+ }
399
+ } else {
400
+ const entry = this._devices.get(deviceId)
401
+ if (!entry) {
402
+ result = { error: `ERR_DEVICE_NOT_FOUND: ${deviceId}` }
403
+ } else if (type === 'metrics') {
404
+ result = await this._collectMetrics(entry, params)
405
+ } else if (this._plugin.handlers.telemetry.has(type)) {
406
+ result = await this._pullChannel(entry, type, params)
407
+ } else {
408
+ result = { error: `ERR_UNKNOWN_QUERY_TYPE: ${type}` }
409
+ }
410
+ }
411
+
412
+ return buildResponse(envelope, ACTIONS.TELEMETRY_RESPONSE, {
413
+ deviceId,
414
+ ...result,
415
+ timestamp: Date.now()
416
+ }, this.workerId)
417
+ }
418
+
419
+ async _collectMetrics (entry, params) {
420
+ if (entry.status !== 'online') return { error: `ERR_DEVICE_UNAVAILABLE: ${entry.deviceId}` }
421
+
422
+ const metrics = {}
423
+ for (const [name, fn] of this._plugin.handlers.telemetry) {
424
+ try {
425
+ metrics[name] = await fn(entry.ctx, params)
426
+ } catch (err) {
427
+ metrics[name] = { error: _publicError(err, 'ERR_TELEMETRY_FAILED') }
428
+ }
429
+ }
430
+ return { metrics }
431
+ }
432
+
433
+ async _pullChannel (entry, name, params) {
434
+ if (entry.status !== 'online') return { error: `ERR_DEVICE_UNAVAILABLE: ${entry.deviceId}` }
435
+
436
+ try {
437
+ return { name, value: await this._plugin.handlers.telemetry.get(name)(entry.ctx, params) }
438
+ } catch (err) {
439
+ return { error: _publicError(err, 'ERR_TELEMETRY_FAILED') }
440
+ }
441
+ }
442
+
443
+ async _handleCommand (envelope) {
444
+ const { commandId, command, params } = envelope.payload
445
+ const deviceId = envelope.deviceId
446
+ const fail = (error) => buildResponse(envelope, ACTIONS.COMMAND_RESULT, {
447
+ commandId,
448
+ status: 'FAILED',
449
+ error
450
+ }, this.workerId)
451
+
452
+ // Built-ins run before the device checks: provisioning commands arrive
453
+ // Worker-scoped (deviceId null) and store-backed commands must work for
454
+ // devices the runtime holds offline.
455
+ const builtin = commandBuiltin(this.services, command)
456
+ if (builtin) {
457
+ const invalid = validateParams(builtinCommandParams(command), params || {})
458
+ if (invalid) return fail(invalid)
459
+ try {
460
+ const result = await builtin.handle(this.services, deviceId, params || {})
461
+ return buildResponse(envelope, ACTIONS.COMMAND_RESULT, {
462
+ commandId,
463
+ status: 'SUCCESS',
464
+ result: result === undefined ? {} : result
465
+ }, this.workerId)
466
+ } catch (err) {
467
+ return fail(_publicError(err, 'ERR_COMMAND_FAILED'))
468
+ }
469
+ }
470
+
471
+ if (!deviceId) return fail('ERR_DEVICE_ID_REQUIRED')
472
+ const entry = this._devices.get(deviceId)
473
+ if (!entry) return fail(`ERR_DEVICE_NOT_FOUND: ${deviceId}`)
474
+ if (entry.status !== 'online') return fail(`ERR_DEVICE_UNAVAILABLE: ${deviceId}`)
475
+ const handler = this._plugin.handlers.commands.get(command)
476
+ if (!handler) return fail(`ERR_UNKNOWN_COMMAND: ${command}`)
477
+
478
+ // Declared params are checked here, not left to each handler: the worker
479
+ // is reachable on its own RPC key, so the Kernel's check is not enough.
480
+ const normalized = this._normalizeParams(command, params)
481
+ const invalid = validateParams(this._commandParams.get(command), normalized)
482
+ if (invalid) return fail(invalid)
483
+
484
+ try {
485
+ const result = await handler(entry.ctx, normalized)
486
+ return buildResponse(envelope, ACTIONS.COMMAND_RESULT, {
487
+ commandId,
488
+ status: 'SUCCESS',
489
+ result: result === undefined ? {} : result
490
+ }, this.workerId)
491
+ } catch (err) {
492
+ return fail(_publicError(err, 'ERR_COMMAND_FAILED'))
493
+ }
494
+ }
495
+
496
+ // The Kernel's action-approver dispatches write actions positionally —
497
+ // `{ value: x }` for one argument, `{ args: [...] }` for several (the
498
+ // legacy applyThings consumed Object.values). Map those onto the
499
+ // contract-declared param names so handlers only ever see named params.
500
+ // Params that already look named (or commands without declared params)
501
+ // pass through untouched.
502
+ _normalizeParams (command, params) {
503
+ const declared = this._commandParamNames.get(command)
504
+ if (!declared || !declared.length) return params || {}
505
+
506
+ let positional = null
507
+ if (Array.isArray(params)) {
508
+ positional = params
509
+ } else if (params && Array.isArray(params.args) && Object.keys(params).length === 1 && !declared.includes('args')) {
510
+ positional = params.args
511
+ } else if (params && Object.keys(params).length === 1 && 'value' in params && !declared.includes('value')) {
512
+ positional = [params.value]
513
+ }
514
+ if (!positional) return params || {}
515
+
516
+ const named = {}
517
+ declared.forEach((name, i) => {
518
+ if (positional[i] !== undefined) named[name] = positional[i]
519
+ })
520
+ return named
521
+ }
522
+
523
+ _handleHealthPing (envelope) {
524
+ return buildResponse(envelope, ACTIONS.HEALTH_PONG, {
525
+ status: 'OK'
526
+ }, this.workerId)
527
+ }
528
+
529
+ _handleStatePull (envelope) {
530
+ const state = {}
531
+ for (const entry of this._devices.values()) {
532
+ state[entry.deviceId] = { status: entry.status }
533
+ }
534
+
535
+ return buildResponse(envelope, ACTIONS.STATE_RESPONSE, {
536
+ state,
537
+ deviceCount: this._devices.size,
538
+ workerId: this.workerId
539
+ }, this.workerId)
540
+ }
541
+ }
542
+
543
+ // Returns the 32-byte seed at seedPath, or null when there is none yet.
544
+ function _readSeedFile (seedPath) {
545
+ let lst
546
+ try {
547
+ lst = fs.lstatSync(seedPath)
548
+ } catch (err) {
549
+ if (err.code === 'ENOENT') return null
550
+ throw err
551
+ }
552
+ if (lst.isSymbolicLink()) throw new Error(`ERR_SEED_FILE_SYMLINK: ${seedPath}`)
553
+ if (!lst.isFile()) throw new Error(`ERR_SEED_FILE_INVALID: ${seedPath}`)
554
+
555
+ // O_NOFOLLOW closes the window between the lstat above and the open.
556
+ const fd = fs.openSync(seedPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0))
557
+ try {
558
+ const st = fs.fstatSync(fd)
559
+ if (!st.isFile()) throw new Error(`ERR_SEED_FILE_INVALID: ${seedPath}`)
560
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) {
561
+ throw new Error(`ERR_SEED_FILE_OWNER: ${seedPath}`)
562
+ }
563
+ if (process.platform !== 'win32' && (st.mode & 0o077)) {
564
+ fs.fchmodSync(fd, 0o600)
565
+ debug('tightened seed file permissions to 0600: %s', seedPath)
566
+ }
567
+ const buf = Buffer.alloc(33)
568
+ const n = fs.readSync(fd, buf, 0, 33, 0)
569
+ if (n !== 32) throw new Error(`ERR_SEED_FILE_MALFORMED: ${seedPath}`)
570
+ return buf.subarray(0, 32)
571
+ } finally {
572
+ fs.closeSync(fd)
573
+ }
574
+ }
575
+
576
+ function _normalizeKey (key) {
577
+ const hex = Buffer.isBuffer(key) ? key.toString('hex') : (typeof key === 'string' ? key.trim().toLowerCase() : '')
578
+ if (!/^[0-9a-f]{64}$/.test(hex)) throw new Error('ERR_WORKER_ALLOWED_KEY_INVALID: expected a 32-byte hex public key')
579
+ return hex
580
+ }
581
+
582
+ function _publicError (err, fallback) {
583
+ const message = err && typeof err.message === 'string' ? err.message : String(err)
584
+ if (ERR_CODE.test(message)) return message
585
+ debug('%s: %s', fallback, (err && err.stack) || message)
586
+ return fallback
587
+ }
588
+
589
+ module.exports = WorkerRuntime