@tetherto/mdk-core 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.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +572 -0
  3. package/index.js +519 -0
  4. package/lib/address-policy.js +124 -0
  5. package/lib/local-discovery.js +321 -0
  6. package/lib/services/actions.service.js +68 -0
  7. package/lib/services/alerts.service.js +207 -0
  8. package/lib/services/comments.service.js +118 -0
  9. package/lib/services/log-history.service.js +307 -0
  10. package/lib/services/logs.service.js +249 -0
  11. package/lib/services/pool-utils/constants.js +20 -0
  12. package/lib/services/pool-utils/time.js +49 -0
  13. package/lib/services/pool.service.js +397 -0
  14. package/lib/services/provisioning.service.js +420 -0
  15. package/lib/services/settings.service.js +26 -0
  16. package/lib/services/snaps.service.js +181 -0
  17. package/lib/services/stats.service.js +205 -0
  18. package/lib/stats/README.md +80 -0
  19. package/lib/stats/index.js +57 -0
  20. package/lib/stats/ops/alerts_aggr.js +40 -0
  21. package/lib/stats/ops/alerts_group_cnt.js +43 -0
  22. package/lib/stats/ops/arr_concat.js +27 -0
  23. package/lib/stats/ops/array_obj_calc.js +41 -0
  24. package/lib/stats/ops/avg.js +42 -0
  25. package/lib/stats/ops/cnt.js +19 -0
  26. package/lib/stats/ops/group.js +38 -0
  27. package/lib/stats/ops/group_avg.js +53 -0
  28. package/lib/stats/ops/group_cnt.js +38 -0
  29. package/lib/stats/ops/group_max.js +40 -0
  30. package/lib/stats/ops/group_multiple_stats.js +40 -0
  31. package/lib/stats/ops/group_sum.js +40 -0
  32. package/lib/stats/ops/index.js +35 -0
  33. package/lib/stats/ops/nested_obj_concat.js +54 -0
  34. package/lib/stats/ops/obj_concat.js +33 -0
  35. package/lib/stats/ops/sum.js +27 -0
  36. package/lib/stats/utils.js +99 -0
  37. package/lib/templates/alerts.js +128 -0
  38. package/lib/templates/stats.js +476 -0
  39. package/lib/things/constants.js +65 -0
  40. package/lib/things/container.js +84 -0
  41. package/lib/things/miner.js +190 -0
  42. package/lib/things/powermeter.js +30 -0
  43. package/lib/things/sensor.js +18 -0
  44. package/lib/things/thing.js +93 -0
  45. package/lib/utils.js +603 -0
  46. package/lib/worker-infra.js +221 -0
  47. package/package.json +64 -1
  48. package/services.js +246 -0
  49. package/utils/compose-yaml.js +56 -0
  50. package/utils/constants.js +30 -0
  51. package/utils/index.js +29 -0
  52. package/utils/initialize.js +148 -0
  53. package/utils/service-bootstrap.js +271 -0
  54. package/worker.js +9 -0
package/README.md ADDED
@@ -0,0 +1,572 @@
1
+ # @tetherto/mdk-core
2
+
3
+ ## Overview
4
+
5
+ Bootstrap utilities for MDK. This package is the primary entry point for application developers. It provides
6
+ high-level convenience functions that wire together the [Kernel](../kernel/README.md), [device Workers](../../workers/README.md),
7
+ and the [Gateway](../gateway/README.md) HTTP server without requiring direct knowledge of lower-level APIs.
8
+
9
+ ## Prerequisites
10
+
11
+ - Node.js >= 24
12
+
13
+ ## Install
14
+
15
+ This package is part of the MDK monorepo and requires both core and Worker dependencies. After cloning, install from the repo root:
16
+
17
+ ```bash
18
+ npm install
19
+ ```
20
+
21
+ The [run a mining site tutorial](../../../docs/tutorials/run-a-site.md) is the full clone-and-install walkthrough.
22
+
23
+ > [!NOTE]
24
+ > Installed from npm, this package pulls in `@tetherto/mdk-gateway`, so `npm audit` reports an `@fastify/static` advisory until
25
+ > your app's root `package.json` overrides it. See the Gateway's
26
+ > [known dependency advisory](../gateway/README.md#known-dependency-advisory).
27
+
28
+ ## Usage
29
+
30
+ ```js
31
+ const { getKernel, startGateway, waitForDiscovery, shutdown } = require('@tetherto/mdk-core')
32
+ const { trustKernel } = require('@tetherto/mdk-core/lib/local-discovery')
33
+ const { startAvalonWorker } = require('@tetherto/mdk-worker-avalon')
34
+
35
+ // 1. Start Kernel
36
+ const kernel = await getKernel()
37
+
38
+ // 2. Start a Worker: each Worker package ships its own boot function that
39
+ // constructs a WorkerRuntime internally (see @tetherto/mdk-worker).
40
+ // Worker RPC is fail-closed: allow the key the Kernel dials with, then keep
41
+ // following the callers the Kernel admits (the Gateway calls Workers too).
42
+ const { runtime } = await startAvalonWorker({
43
+ workerId: 'avalon-rack-1',
44
+ model: 'a1346',
45
+ storeDir: './data/avalon',
46
+ allowedKeys: [kernel.getClientPublicKey()],
47
+ seedDevices: [{ info: { serialNum: 'AV-001' }, opts: { address: '192.168.1.10', port: 4028, password: 'admin' } }]
48
+ })
49
+ trustKernel(runtime, kernel)
50
+
51
+ // 3. Register the Worker with Kernel (same-process mode, no DHT/local discovery needed)
52
+ await kernel.registerWorker(runtime.getPublicKey())
53
+
54
+ // 4. Wait for Kernel to discover and register the Worker
55
+ await waitForDiscovery(kernel)
56
+
57
+ // 5. Optionally start the HTTP API (admits the Gateway's key on the Kernel
58
+ // and, through trustKernel, on the Worker)
59
+ const server = await startGateway({ kernel, port: 3000 })
60
+
61
+ // The SDK installs no SIGINT/SIGTERM handlers unless asked
62
+ // (getKernel({ handleSignals: true })). Tear down explicitly:
63
+ // await shutdown(kernel)
64
+ ```
65
+
66
+ > [!NOTE]
67
+ > - There is no single generic `startWorker(WorkerClass, opts)` entry point: every Worker package supplies its own
68
+ > boot function that [builds a `WorkerRuntime` for its plugin](#start-a-worker).
69
+ > - [Worker deployment options](../../../docs/concepts/deployment-topologies.md) include same-process, local, and DHT.
70
+
71
+ ## API
72
+
73
+ ### `getKernel(opts?)` → `Promise<KernelManager>`
74
+
75
+ Start the Kernel with defaults suited for single-process development. Automatically:
76
+ - In DHT mode, reads the topic from `DEFAULT_TOPIC_FILE` or generates one if absent
77
+ - In local mode, watches a shared Worker-key directory and does not create or join a DHT topic
78
+ - Publishes the HRPC public key as hex to `DEFAULT_KEY_FILE` after start, so out-of-process clients can connect without configuration
79
+ - Starts the HRPC listener **fail-closed**: the allowlist is empty unless you pass `opts.hrpc.whitelist`, and nobody is
80
+ admitted until a key is added (`kernel.allowKey()`, `startGateway({ kernel })`, or the trusted-keys dir below). See
81
+ [Trust and admission](#trust-and-admission)
82
+ - Publishes `kernel.pub` (its worker-dialing key, `kernel.getClientPublicKey()`) to the trusted-keys dir and admits the
83
+ other keys published there
84
+ - With `opts.handleSignals: true` only, registers `SIGINT`/`SIGTERM` handlers that drain `kernel._cleanup`, stop the
85
+ Kernel and call `process.exit(0)`. The default (library use) leaves the process's signals alone; call
86
+ [`shutdown(kernel)`](#shutdownhandle--promisevoid) yourself
87
+
88
+ The key file is not deleted on shutdown: the key is stable across restarts (HRPC seeds persist in the Kernel store), so a leftover
89
+ file stays correct for the same store directory.
90
+
91
+ ```js
92
+ const kernel = await getKernel()
93
+ // kernel.topic — hex DHT topic used, or undefined in local mode
94
+ // kernel.getPublicKey() — HRPC public key
95
+ ```
96
+
97
+ | Option | Status | Type | Default | Description |
98
+ | --- | --- | --- | --- | --- |
99
+ | `opts.root` | Optional | `string` | `~/.mdk` | Data root directory |
100
+ | `opts.storeDir` | Optional | `string` | Under `opts.root` | Override Hyperbee store path |
101
+ | `opts.discovery` | Optional | `object` | DHT mode | Discovery config: `{ mode: 'dht' \| 'local', dir?, workerKeys?, allowAnyWorker? }`. In DHT mode a Worker announcing on the topic is admitted only if its RPC key is in `workerKeys` (or added later with `kernel.allowWorkerKey()`); an empty list admits nobody unless `allowAnyWorker: true` (development only, logs a warning). Local mode and `registerWorker()` do not use this list |
102
+ | `opts.topic` | Optional | `string` | Read from `opts.topicFile` if it exists, else a fresh random topic (not persisted) | 32-byte hex DHT topic; overrides the topic file |
103
+ | `opts.topicFile` | Optional | `string` | `DEFAULT_TOPIC_FILE` | Path to the topic file |
104
+ | `opts.keyFile` | Optional | `string\|false` | `DEFAULT_KEY_FILE` | Path for the HRPC key file; `false` to disable publishing |
105
+ | `opts.hrpc` | Optional | `object\|false` | Enabled, empty allowlist (admits nobody) | HRPC config; `false` disables the listener |
106
+ | `opts.hrpc.whitelist` | Optional | `Array<string\|{key, roles}>` | `[]` | Caller allowlist. A hex key gets full access; `{ key, roles }` limits it to `'read'`/`'write'`/`'admin'` ([roles](../kernel/README.md#caller-admission-and-roles)) |
107
+ | `opts.hrpc.allowAll` | Optional | `boolean` | `false` | Admit every DHT peer that knows the Kernel key. Development only; logs a warning |
108
+ | `opts.hrpc.bootstrap` | Optional | `array` | Public DHT | HyperDHT bootstrap nodes; pass the same list to every component on a private DHT network |
109
+ | `opts.trustedKeysDir` | Optional | `string\|false` | `<root>/.trusted-keys` | Same-machine key rendezvous (see [Trust and admission](#trust-and-admission)); `false` disables |
110
+ | `opts.telemetryPullMs` | Optional | `number` | The Kernel's own default | Telemetry poll interval in ms |
111
+ | `opts.healthPingMs` | Optional | `number` | The Kernel's own default | Health ping interval in ms |
112
+ | `opts.statePullMs` | Optional | `number` | The Kernel's own default | Worker identity and device-list refresh interval in ms |
113
+ | `opts.handleSignals` | Optional | `boolean` | `false` | Install `SIGINT`/`SIGTERM` handlers that stop the Kernel and exit the process. Set it in a standalone Kernel process |
114
+
115
+ Cadence options are flat on `getKernel()`. For nested `cadences` configuration, use the
116
+ [`createKernel()` API](../kernel/README.md#createkernelopts--kernelmanager).
117
+
118
+ ### Start a Worker
119
+
120
+ There is no generic `startWorker(WorkerClass, opts)` export in this package. Each Worker package (e.g.
121
+ `@tetherto/mdk-worker-avalon`) supplies its own boot function that constructs a
122
+ [`WorkerRuntime`](../mdk-worker/lib/worker-runtime.js) internally and connects it to Kernel through [DHT, local, or
123
+ same-process discovery](../../../docs/concepts/deployment-topologies.md). Every boot function accepts (`kernelTopic`, `discovery`,
124
+ or direct `kernel.registerWorker(runtime.getPublicKey())`).
125
+
126
+ ```js
127
+ const { startAvalonWorker } = require('@tetherto/mdk-worker-avalon')
128
+
129
+ const { runtime, stop } = await startAvalonWorker({
130
+ workerId: 'avalon-rack-1', // one runtime process = one workerId
131
+ model: 'a1346',
132
+ storeDir: './data/avalon',
133
+ kernelTopic: null, // omit/null to register by key instead of a DHT topic
134
+ seedDevices: [{ info: { serialNum: 'AV-001' }, opts: { address: '192.168.1.10', port: 4028, password: 'admin' } }]
135
+ })
136
+
137
+ await kernel.registerWorker(runtime.getPublicKey()) // same-process discovery
138
+ ```
139
+
140
+ > [!CAUTION]
141
+ > This boot shape (`storeDir`, `seedDevices`) is the deprecated thing/adapter Worker surface. A new Worker runs on
142
+ > `WorkerRuntimeV2`; see [Build a third-party Worker](../../../docs/guides/workers/build-a-worker.md).
143
+
144
+ Returns vary by Worker package, but every boot function returns at least `{ runtime, stop }`: `runtime` is the
145
+ `WorkerRuntime` instance (`getPublicKey()`, `getDeviceContext(deviceId)`); `stop()` tears the Worker down.
146
+
147
+ On this surface `opts.conf.thing` sets the store's sampling and retention. The keys that decide disk growth:
148
+
149
+ | Key | Default | Effect |
150
+ | --- | --- | --- |
151
+ | `storeSnapItvMs` | `300000` | How often one snapshot per device is appended to its `thing-5m-<deviceId>` log |
152
+ | `collectSnapsItvMs` | `60000` | How often the Worker polls its devices |
153
+ | `logRotateMaxLength` | Unset; rotation disabled | Rotate when the segment's Hypercore length reaches this threshold; this is not an exact count of telemetry samples |
154
+ | `logKeepCount` | `3` | Retention parameter used to select older segments for unlinking; it does not enable rotation |
155
+ | `rotateLogsItvMs` | `120000` | How often the rotation check runs |
156
+
157
+ To enable rotation, pass an explicit `opts.conf.thing.logRotateMaxLength`, for example `10000`. The bundled configuration examples
158
+ contain that value, but these boot functions do not load it automatically. Setting `logKeepCount` alone leaves rotation disabled.
159
+ Measure retained disk usage under the site's actual sampling rate and workload rather than treating these settings as a byte limit.
160
+
161
+ ### `startGateway(opts?)` → `Promise<WrkServerHttp>`
162
+
163
+ Start the Fastify-based HTTP server. Writes config files under `opts.root`, deep-merging any override objects
164
+ with the example defaults.
165
+
166
+ ```js
167
+ const server = await startGateway({
168
+ kernel,
169
+ port: 3000,
170
+ root: './data/gateway',
171
+ apps: [{ name: 'site', appDir: './apps/site', keyFile: './run/site.key' }],
172
+ gatewayKeyFile: './run/gateway.key'
173
+ })
174
+ ```
175
+
176
+ | Option | Status | Type | Default | Description |
177
+ | --- | --- | --- | --- | --- |
178
+ | `opts.root` | Optional | `string` | `~/.mdk/gateway` | Config/data root |
179
+ | `opts.port` | Optional | `number` | `3000` | HTTP port |
180
+ | `opts.env` | Optional | `string` | `'development'` | Environment string |
181
+ | `opts.kernel` | Optional, one of `opts.kernel`/`opts.kernelKey`/`opts.keyFile` must resolve a key | `KernelManager` | None | Kernel instance; Gateway stop is registered on cleanup. Its `getPublicKey()` also resolves the Kernel key. |
182
+ | `opts.kernelKey` | Optional, see `opts.kernel` | `string\|Buffer\|false` | None | Kernel HRPC listener public key (hex or Buffer); `false` to run without one. The Gateway passes it to no app; each [app runtime](../app-runtime/README.md) takes its own `kernelKey` |
183
+ | `opts.keyFile` | Optional, see `opts.kernel` | `string` | `DEFAULT_KEY_FILE` | Key file to resolve the Kernel key from |
184
+ | `opts.bootstrap` | Optional | `array` | None | DHT bootstrap nodes for the Gateway's client to app runtimes (testnets) |
185
+ | `opts.common` | Optional | `object` | `{}` | Overrides for `common.json` |
186
+ | `opts.httpd` | Optional | `object` | `{}` | Overrides for `httpd.config.json` |
187
+ | `opts.store` | Optional | `object` | `{}` | Overrides for `store.config.json` |
188
+ | `opts.additionalRoutes` | Optional | `array` | None | Extra Fastify route definitions; a raw escape hatch, prefer `apps` |
189
+ | `opts.apps` | Optional | `array` | None | The complete set of apps this Gateway mounts — nothing else is registered. See [Apps](#apps) |
190
+ | `opts.gatewayKeyFile` | Optional | `string` | None | File the Gateway writes the public key it presents to app runtimes to, once it is serving |
191
+ | `opts.handleSignals` | Optional | `boolean` | `false` | Standalone Gateway (no `opts.kernel`) only: install `SIGINT`/`SIGTERM` handlers that stop it and exit the process |
192
+ | `opts.trustedKeysDir` | Optional | `string\|false` | `opts.kernel.trustedKeysDir`, else `DEFAULT_TRUSTED_KEYS_DIR` | Where the Gateway publishes its client key (`status.rpcClientKey`) as `gateway.pub`; `false` disables. With `opts.kernel` the key is also admitted in-process |
193
+ | `opts.trustedKeyName` | Optional | `string` | `'gateway'` | File name for that key (set it when several Gateways share a dir) |
194
+
195
+ ### Apps
196
+
197
+ Each `opts.apps` entry is `{ name, appDir, keyFile, mount? }`. The Gateway reads only the app's route table (`mdk-plugin.json` in
198
+ `appDir` or `appDir/plugin`) and forwards every request over HRPC to the app's runtime
199
+ ([`@tetherto/mdk-app-runtime`](../app-runtime/README.md)), whose public key it re-reads from `keyFile` on every request; until the
200
+ file holds a key the app's routes answer `503`. `name` matches `^[a-z][a-z0-9-]*$` and is unique; `mount` defaults to `/apps/<name>`,
201
+ and `/` mounts the app at the root. Entries are validated before any boot side effect, throwing `ERR_GATEWAY_APPS_INVALID`,
202
+ `ERR_GATEWAY_APP_NAME_INVALID`, `ERR_GATEWAY_APP_DUPLICATE`, `ERR_GATEWAY_APP_DIR_REQUIRED`, `ERR_GATEWAY_APP_KEY_REQUIRED` or
203
+ `ERR_GATEWAY_APP_MOUNT_INVALID`.
204
+
205
+ Every runtime sees the same Gateway key, stable across restarts (seeded from `<root>/identity/seedRuntimeClient`); point each
206
+ runtime's `allowedKeyFiles` at `opts.gatewayKeyFile`.
207
+
208
+ ### Declaring apps
209
+
210
+ `opts.apps` is the whole list; MDK bundles no apps.
211
+
212
+ ```js
213
+ const { startGateway } = require('@tetherto/mdk-core')
214
+
215
+ await startGateway({
216
+ kernel,
217
+ port: 3847,
218
+ gatewayKeyFile: './run/gateway.key',
219
+ apps: [
220
+ { name: 'demo', appDir: path.join(__dirname, 'apps', 'demo'), keyFile: './run/demo.key' },
221
+ { name: 'site', appDir: path.join(__dirname, 'apps', 'site'), keyFile: './run/site.key', mount: '/' }
222
+ ]
223
+ })
224
+ ```
225
+
226
+
227
+ The Kernel HRPC key is resolved **before any boot side effects**, in this order:
228
+
229
+ 1. `opts.kernelKey`: hex or Buffer; `false` means run without a Kernel connection.
230
+ 2. `opts.kernel.getPublicKey()`: in-process Kernel handle.
231
+ 3. Key file: `opts.keyFile` or `DEFAULT_KEY_FILE`.
232
+ 4. Otherwise throws: `ERR_KERNEL_KEY_FILE_NOT_FOUND`.
233
+
234
+ The Gateway worker opens no Kernel connection: each app runtime builds its own [`@tetherto/mdk-client`](../client/README.md), and
235
+ a failed connect fails that app's calls with
236
+ [`ERR_MDK_CLIENT_UNAVAILABLE`](../client/README.md#createmdkclientconfig-opts--auto-connecting-client) so as not to crash it.
237
+
238
+ ### `startKernel(opts?)` → `Promise<KernelManager>`
239
+
240
+ Lower-level Kernel start. Prefer `getKernel()` for new code. Does not register SIGINT or read the topic file, and writes the key file only when
241
+ `opts.keyFile` is explicitly passed.
242
+ For caller-managed construction and lifecycle, use [`createKernel()` from `@tetherto/mdk-kernel`](../kernel/README.md#createkernelopts--kernelmanager).
243
+
244
+ ### `waitForDiscovery(kernel, timeout?)` → `Promise<WorkerEntry[]>`
245
+
246
+ Poll the registry until at least one Worker reaches `READY` state with devices populated, or `timeout` ms elapses
247
+ (default: 30 000 ms). Returns the full list of registered Workers.
248
+
249
+ ```js
250
+ await waitForDiscovery(kernel, 15000)
251
+ const workers = kernel.registry.listWorkers()
252
+ ```
253
+
254
+ ### `onShutdown(cleanupFn, opts?)` → handler
255
+
256
+ Register a one-shot cleanup handler on `SIGINT` / `SIGTERM`. Returns the handler so tests can invoke it directly.
257
+
258
+ | Option | Status | Type | Default | Description |
259
+ | --- | --- | --- | --- | --- |
260
+ | `opts.signals` | Optional | `string[]` | `['SIGINT', 'SIGTERM']` | Signals to listen for |
261
+ | `opts.forceMs` | Optional | `number` | `3000` | Force-exit timeout in ms if cleanup hangs |
262
+
263
+ > [!NOTE]
264
+ > `getKernel()`, `startGateway()`, and every Worker boot function register their own `onShutdown`
265
+ > handlers internally. Call this only when you need to add teardown logic outside
266
+ > a boot handle — for example, closing a database or flushing a log buffer.
267
+
268
+ ### `shutdown(handle)` → `Promise<void>`
269
+
270
+ Gracefully stop any MDK boot handle — Kernel, Gateway, or Worker. Drains the handle's `_cleanup` array in registration order,
271
+ then calls `.stop()` on the handle itself. Idempotent: calling `shutdown` twice on the same handle is safe.
272
+
273
+ ```js
274
+ await shutdown(kernel) // stops Gateway and Workers (chained), then stops Kernel
275
+ ```
276
+
277
+ Prefer `shutdown(kernel)` over calling `shutdown` on each handle separately: passing the Kernel handle tears everything down in the
278
+ order services were started.
279
+
280
+ ### Constants
281
+
282
+ ```js
283
+ const { DEFAULT_TOPIC_FILE, DEFAULT_KEY_FILE, DEFAULT_TRUSTED_KEYS_DIR } = require('@tetherto/mdk-core')
284
+ // DEFAULT_TOPIC_FILE — ~/.mdk/.dht-topic
285
+ // DEFAULT_KEY_FILE — ~/.mdk/.kernel-key (Kernel HRPC public key, hex)
286
+ // DEFAULT_TRUSTED_KEYS_DIR — ~/.mdk/.trusted-keys (kernel.pub, gateway.pub, ...)
287
+ ```
288
+
289
+ ### `libStats`
290
+
291
+ Telemetry aggregation ops (`applyStats`, `tallyStats`, `groupBy`, `getVal`, `defaults.timeframes`) used by `StatsService` and
292
+ by each Worker plugin's stats template. See [`lib/stats/README.md`](./lib/stats/README.md).
293
+
294
+ ```js
295
+ const { libStats } = require('@tetherto/mdk-core')
296
+ const { groupBy } = libStats
297
+ ```
298
+
299
+ ## Device data and caller input
300
+
301
+ > [!CAUTION]
302
+ > **Deprecated.** [`createWorkerInfra`](./lib/worker-infra.js), the `*Service` classes and the `Thing` base classes
303
+ > (`Thing`, `Miner`, `Container`, `PowerMeter`, `Sensor`) are the legacy thing/adapter Worker surface. The in-repo
304
+ > Workers still run on them, so nothing is removed. A new Worker is a directory-loaded package on `WorkerRuntimeV2` and
305
+ > needs none of them; the [gaps that remain](../mdk-worker/README.md#store-services) are listed with the Worker Runtime
306
+ > store services.
307
+
308
+ The Worker-infra services treat everything arriving over RPC as untrusted:
309
+
310
+ - **Secrets are redacted.** `thing_config`, `list` and `getDeviceMeta` return `password`, `pass`, `passwd`, `apiKey`,
311
+ `token`, `secret`, `privateKey`, `seed`, `mnemonic` (and keys ending in `Password`, `Secret`, `Token`, ...) as
312
+ `'[REDACTED]'`, including `pools[].password`/`pass`. A `'[REDACTED]'` value sent back in an update is ignored, so an edit
313
+ form fed from `thing_config` never overwrites a stored credential.
314
+ - **Queries are allowlisted.** `list`, `forgetThings` and info-history filters accept `$eq $ne $in $nin $exists $gt $gte $lt
315
+ $lte $and $or $nor $not $elemMatch $all $size` and `$regex`. A `$regex` is a literal search, not a pattern: every
316
+ regex metacharacter is matched as itself, except an optional leading `^` (starts with) and trailing `$` (ends with); it
317
+ is capped at 256 characters and `$options` may only be `i`. `$where`, `$expr` and every other operator, paths under
318
+ `opts.*` and secret-named paths are refused with `ERR_QUERY_*`, and filters run over the redacted records.
319
+ - **Device labels are checked.** `registerThing`/`updateThing` accept `info.container` and `info.pos` only as strings of
320
+ up to 64 letters, digits, spaces and `_ . : # -`, starting with a letter or digit (`ERR_THING_INFO_CONTAINER_INVALID`,
321
+ `ERR_THING_INFO_POS_INVALID`). Stats ops build their group maps without a prototype and skip `__proto__`,
322
+ `constructor` and `prototype` keys, and op names are looked up in a fixed table (`ERR_STATS_OP_UNKNOWN`).
323
+ - **Device addresses are checked.** Remote `registerThing`/`updateThing` refuse loopback, unspecified, link-local (including
324
+ `169.254.169.254`), cloud-metadata and multicast addresses, hostnames resolving to them, and ports outside 1–65535. Set
325
+ `conf.thing.allowedAddressCidrs` (for example `['10.0.0.0/8']`) to replace that policy with an allowlist. Seed devices are
326
+ operator config and are not checked. Changing a device's address drops its stored credentials unless the update supplies new ones.
327
+ - **Log reads are bounded.** `offset` must be an integer in `0..logMaxHeight`, `limit` is capped at `conf.thing.logMaxLimit`
328
+ (default 10000), and `tailLog` opens only the `thing-5m` log of the addressed device and `stat-*` logs (plus keys in
329
+ `conf.thing.tailLogKeys`). A device-scoped stat read may only use that device's tags.
330
+ - **Comments need an author.** `saveThingComment`/`editThingComment`/`deleteThingComment(req, ctx)` take the user from the
331
+ authenticated `ctx.user` when the transport supplies one (a different `req.user` is refused), else from `req.user`. Only
332
+ the author may edit or delete. `conf.thing.requireAuthenticatedCommentUser: true` refuses calls without `ctx.user`.
333
+
334
+ `startServices(config)` writes `ecosystem.config.js`, `docker-compose.generated.yml` and `mdk/` into the current directory.
335
+ `config.repoRoot` (required, `ERR_MDK_REPO_ROOT_REQUIRED`) names the MDK source checkout the services run from; it is never
336
+ searched for, and it reaches each process as `MDK_REPO_ROOT` (`/app/repo` under Docker). The Workers it starts join the DHT topic,
337
+ so the Kernel you start with `getKernel()` must admit them: pass their RPC keys in `discovery.workerKeys` (or call
338
+ `kernel.allowWorkerKey()`), or set `discovery.allowAnyWorker: true` for local development. Each
339
+ file carries a `@generated by @tetherto/mdk-core` marker; a file without it is never overwritten
340
+ (`ERR_MDK_GENERATED_FILE_CONFLICT`) unless `config.overwrite: true`. Docker publishes Gateway ports on `127.0.0.1` unless
341
+ `config.host` or `services[].host` names an interface. `initialize({ sources, root })` seeds config from the MDK source
342
+ checkout named by `sources` (required, `ERR_MDK_INIT_SOURCES_REQUIRED`) and throws `ERR_MDK_INIT_SOURCES_NOT_FOUND` when it
343
+ is not one. MDK packages (the Gateway, Worker packages, this package) are always resolved by package name.
344
+
345
+ ## Single-process full stack
346
+
347
+ The typical pattern for running everything in one process during development:
348
+
349
+ ```js
350
+ const { getKernel, startGateway, waitForDiscovery } = require('@tetherto/mdk-core')
351
+ const { trustKernel } = require('@tetherto/mdk-core/lib/local-discovery')
352
+ const { startAvalonWorker } = require('@tetherto/mdk-worker-avalon')
353
+ const { startSatecWorker } = require('@tetherto/mdk-worker-satec')
354
+
355
+ async function main () {
356
+ const kernel = await getKernel()
357
+ const allowedKeys = [kernel.getClientPublicKey()]
358
+
359
+ const { runtime: av } = await startAvalonWorker({
360
+ workerId: 'avalon-rack-1',
361
+ model: 'a1346',
362
+ storeDir: './data/avalon',
363
+ allowedKeys,
364
+ seedDevices: [{ info: { serialNum: 'AV-001' }, opts: { address: '192.168.1.10', port: 4028, password: 'admin' } }]
365
+ })
366
+ await kernel.registerWorker(av.getPublicKey())
367
+
368
+ const { runtime: pm } = await startSatecWorker({
369
+ workerId: 'satec-site-1',
370
+ model: 'pm180',
371
+ storeDir: './data/satec',
372
+ allowedKeys,
373
+ seedDevices: [{ info: { serialNum: 'SATEC-001' }, opts: { address: '192.168.1.30', port: 502, unitId: 1 } }]
374
+ })
375
+ await kernel.registerWorker(pm.getPublicKey())
376
+ // Follow later admissions (the Gateway below also calls Workers directly)
377
+ trustKernel(av, kernel)
378
+ trustKernel(pm, kernel)
379
+
380
+ await waitForDiscovery(kernel)
381
+
382
+ await startGateway({ kernel, port: 3000 })
383
+ console.log('MDK running at http://localhost:3000')
384
+ }
385
+
386
+ main()
387
+ ```
388
+
389
+ ## Trust and admission
390
+
391
+ The Kernel HRPC listener and every Worker RPC server are **fail-closed**: they reject public keys they were not told
392
+ about, and an empty allowlist admits nobody. `allowAll: true` (Kernel `hrpc.allowAll`, Worker `allowAll`) is the only
393
+ way to admit everyone; it is meant for local experiments and logs a warning.
394
+
395
+ Who dials whom, and with which key:
396
+
397
+ | Caller | Dials | With key | Admitted by |
398
+ | --- | --- | --- | --- |
399
+ | Kernel | Workers | `kernel.getClientPublicKey()` (the Kernel's DHT keypair, stable across restarts) | Worker `allowedKeys` |
400
+ | Gateway plugins | Kernel, and Workers directly | `status.rpcClientKey` (Gateway DHT keypair, persisted) | Kernel allowlist and Worker `allowedKeys` |
401
+ | MCP / CLI clients | Kernel (and Workers) | a persistent `callerKeyPair(dir)` passed as `kernelKeyPair` | same |
402
+
403
+ The SDK wires the local cases:
404
+
405
+ - **Same process.** `startGateway({ kernel })` calls `kernel.allowKey(gatewayKey)`. Workers allow
406
+ `kernel.getClientPublicKey()` at boot and call `trustKernel(runtime, kernel)` to mirror every write-capable caller
407
+ the Kernel admits later.
408
+ - **Same machine, separate processes.** Every component meets in a trusted-keys dir (`getKernel` default
409
+ `<root>/.trusted-keys`, `DEFAULT_TRUSTED_KEYS_DIR` = `~/.mdk/.trusted-keys`). The Kernel publishes `kernel.pub` and
410
+ admits every other `<name>.pub`; a standalone `startGateway` publishes `gateway.pub`; a Worker process allows what it
411
+ finds there (`trustKeysFromDir(runtime, dir)`, after `waitForKernelKey(dir)`). The dir is created `0700`; key files
412
+ that are group/world-writable or owned by another user are ignored. Anyone who can write the dir is trusted, so keep it
413
+ private, or pass `trustedKeysDir: false` and configure keys explicitly.
414
+ - **Across machines.** Without a shared trust directory, list caller keys and roles explicitly in Kernel's `hrpc.whitelist`, and
415
+ Kernel's client key in each Worker's `allowedKeys`. Add other Worker callers only to grant direct access to all supported operations;
416
+ Kernel roles do not restrict those direct calls. A Worker that cannot learn a key fails to
417
+ start with `ERR_WORKER_ALLOWED_KEYS_REQUIRED` rather than admitting everyone.
418
+
419
+ Automatic admission is a grant of access, not just key discovery. The [site security blueprint](../../../docs/guides/security/index.md#automatic-admission-during-startup)
420
+ details the grants, explicit configuration, and revocation behavior. In particular, `trustedKeysDir: false` disables directory admission
421
+ on Kernel, but does not prevent `startGateway({ kernel })` from admitting its Gateway key with full access.
422
+
423
+ Helpers in `@tetherto/mdk-core/lib/local-discovery`: `trustedKeysDir(root)`, `publishTrustedKey(dir, name, key)`,
424
+ `readTrustedKeys(dir)`, `watchTrustedKeys(dir, onKey)`, `admitTrustedKeys(kernel, dir)`, `trustKeysFromDir(runtime, dir)`,
425
+ `waitForKernelKey(dir, { timeoutMs })`, `trustKernel(runtime, kernel)`, `callerKeyPair(dir)`.
426
+
427
+ `publishWorkerKey(dir, workerId, key)` / `discoverWorkerKeys(kernel, dir)` (local discovery) apply the same rules as the
428
+ trusted-keys dir: `workerId` must match `[A-Za-z0-9._-]+`, the dir is `0700` (an existing one that is a symlink, owned by
429
+ another user, or group/world-writable is refused with `ERR_KEYS_DIR_UNSAFE`), files are written `0600` atomically, and only
430
+ regular, owned, non-group/world-writable `<name>.key` files holding a 64-hex key are dialled.
431
+
432
+ `utils/service-bootstrap.js` (`SERVICE=worker`, which also needs `MDK_REPO_ROOT`) admits the keys in `DEFAULT_TRUSTED_KEYS_DIR` plus
433
+ `MDK_WORKER_ALLOWED_KEYS` (comma-separated hex); `MDK_WORKER_ALLOW_ALL=1` opts out for development.
434
+
435
+ ## Config management
436
+
437
+ `startGateway()` and each Worker's boot function copy example config files into their `opts.root/config/` (or `storeDir`) on first run.
438
+ After that, the files are left untouched so your edits survive restarts. Pass override objects to `startGateway()` to programmatically
439
+ set specific values without editing files.
440
+
441
+ Config file precedence:
442
+ 1. Existing file on disk (your edits are authoritative).
443
+ 2. Deep-merged overrides from `opts.*`.
444
+ 3. `.example` template from the package.
445
+
446
+ ## Errors
447
+
448
+ | Code | Fires when | Fix |
449
+ | --- | --- | --- |
450
+ | `ERR_ALERTS_LOGS_REQUIRED` | AlertsService is constructed without a `logs` instance | Pass the logs service in the AlertsService constructor options |
451
+ | `ERR_BEE_LOG_META_NOTFOUND` | A log key is rotated but has no meta record to advance | Initialize the log meta before rotating, or rotate only known log keys |
452
+ | `ERR_BEE_ROTATED_LOG_META_NOTFOUND` | A corrupted time log is rotated but its new meta cannot be read back | Inspect the log store for corruption and rebuild the affected log key |
453
+ | `ERR_COMMENT_ACCESS_DENIED` | A comment edit or delete is requested by a user who is not the comment's author, or the comment has no author | Perform the edit or delete as the user who created the comment |
454
+ | `ERR_COMMENT_USER_MISMATCH` | `req.user` differs from the authenticated `ctx.user` | Drop `req.user`; the authenticated identity is used |
455
+ | `ERR_COMMENT_USER_REQUIRED` | A comment call carries no user | Pass `ctx.user` (or `req.user`) |
456
+ | `ERR_COMMENT_USER_UNAUTHENTICATED` | `requireAuthenticatedUser` is set and no `ctx.user` is passed | Forward the authenticated identity as `ctx.user` |
457
+ | `ERR_CRON_UNSUPPORTED` | A cron expression's minute and hour fields do not map to a supported interval | Use a `*/n` minute, `*/n` hour, or `0 0` daily form |
458
+ | `ERR_DEVICE_TYPE_REQUIRED` | `createWorkerInfra` is called without `opts.deviceType` | Pass a `deviceType` string in the Worker infra options |
459
+ | `ERR_DEVICE_UNAVAILABLE` | A device call targets a `deviceId` that has no live runtime context | Register and bind the device before calling it |
460
+ | `ERR_END_INVALID` | A DB range read is requested without an `end` bound | Supply an `end` timestamp in the query |
461
+ | `ERR_ENTRIES_INVALID` | Settings are saved with an entries argument that is not an object | Pass an object of key/value settings entries |
462
+ | `ERR_HIST_LOG_NOTFOUND` | A history log is read but does not parse to an array of entries | Check that the log key holds valid history entries |
463
+ | `ERR_INFO_HISTORY_LOG_NOTFOUND` | An `info` historical-logs request finds no `thing-history-log` time log | Confirm info-history logging is enabled and the log exists |
464
+ | `ERR_INFO_HISTORY_LOG_TYPE_INVALID` | A historical-logs request omits `logType` | Set `logType` to `alerts` or `info` |
465
+ | `ERR_INVALID_ARG_TYPE` | Miner pool pre-processing is given a `pools` value that is not an array | Pass pools as an array |
466
+ | `ERR_INVALID_GROUP_RANGE_FORMAT` | A group-range string does not match the `<number><H\|D\|W\|M>` pattern | Use a range like `1H`, `1D`, `1W` or `1M` |
467
+ | `ERR_KERNEL_CLIENT_KEY_NOT_FOUND` | `waitForKernelKey` finds no `kernel.pub` in the trusted-keys dir in time | Start the Kernel first, or pass the Worker's `allowedKeys` explicitly |
468
+ | `ERR_KERNEL_CLIENT_KEY_UNAVAILABLE` | `trustKernel` is called with a Kernel that has no HRPC client key | Start the Kernel (with HRPC enabled) before its Workers |
469
+ | `ERR_KERNEL_KEY_FILE_NOT_FOUND` | `startGateway` cannot resolve a Kernel key from options or the key file | Start the Kernel first, pass `opts.kernelKey` or `opts.kernel`, or set `kernelKey: false` to run without a Kernel |
470
+ | `ERR_MDK_WORKER_ALLOWED_KEYS_REQUIRED` | `service-bootstrap` boots a Worker with no trusted key | Start the Kernel first, set `MDK_WORKER_ALLOWED_KEYS`, or `MDK_WORKER_ALLOW_ALL=1` for development |
471
+ | `ERR_KEY_INVALID` | A Worker external-data request has a query with no `key` | Include a `key` in the query |
472
+ | `ERR_LOG_HISTORY_LOGS_REQUIRED` | LogHistoryService is constructed without a `logs` instance | Pass the logs service in the constructor options |
473
+ | `ERR_LOG_KEY_INVALID` | A `tailLog` key is malformed or not a `thing-5m` / `stat-*` / `conf.tailLogKeys` log | Use an allowed log key |
474
+ | `ERR_LOG_LIMIT_INVALID` | A log read's `limit` is not a non-negative integer | Pass an integer `limit` |
475
+ | `ERR_LOG_OFFSET_INVALID` | A log read's `offset` is not an integer in `0..logMaxHeight` | Pass an integer offset within the retained rotations |
476
+ | `ERR_LOG_START_INVALID` / `ERR_LOG_END_INVALID` | A log read's `start`/`end` is not a finite timestamp | Pass millisecond timestamps |
477
+ | `ERR_LOG_TAG_FORBIDDEN` | A `tailLog` tag is not a provisioned device (`thing-5m`) or not one of the addressed device's tags | Read only the addressed device's logs |
478
+ | `ERR_LOG_KEY_NOTFOUND` | `tailLog` is called without `req.key` | Provide the log `key` in the request |
479
+ | `ERR_LOG_NOTFOUND` | A requested time log is absent. This covers the `alerts` historical log and any `tailLog` `key`-`tag` log. | Confirm the log key exists and data has been written to it |
480
+ | `ERR_LOG_TAG_INVALID` | `tailLog` is called without `req.tag` | Provide the log `tag` in the request |
481
+ | `ERR_LOGS_META_LOGS_REQUIRED` | LogsService is constructed without a `metaLogs` store | Pass a `metaLogs` store in the constructor options |
482
+ | `ERR_LOGS_STORE_REQUIRED` | LogsService is constructed without a `store` | Pass a `store` in the constructor options |
483
+ | `ERR_MDK_GENERATED_FILE_CONFLICT` | `startServices` would overwrite a file in the site dir it did not generate | Move the file away or pass `overwrite: true` |
484
+ | `ERR_MDK_INIT_SOURCES_NOT_FOUND` | `initialize()` finds no MDK source checkout at `opts.sources` | Pass `opts.sources` pointing at an MDK monorepo checkout |
485
+ | `ERR_MDK_INIT_SOURCES_REQUIRED` | `initialize()` is called without `opts.sources` | Pass `opts.sources` pointing at an MDK monorepo checkout |
486
+ | `ERR_MDK_REPO_ROOT_REQUIRED` | `startServices` has no `config.repoRoot`, or the service bootstrap runs a Worker without `MDK_REPO_ROOT` | Pass `repoRoot` / set `MDK_REPO_ROOT` to the MDK source checkout |
487
+ | `ERR_KEYS_DIR_UNSAFE` | A Worker-keys dir is not a real directory, is owned by another user, or is group/world-writable | Use a private (`0700`) directory owned by the service user |
488
+ | `ERR_STATS_OP_UNKNOWN` | A stats spec names an op that is not one of the built-in ops | Use a built-in op name (`sum`, `avg`, `group_sum`, ...) |
489
+ | `ERR_THING_INFO_INVALID` | `registerThing`/`updateThing` `info` is not an object | Pass `info` as an object |
490
+ | `ERR_THING_INFO_CONTAINER_INVALID` / `ERR_THING_INFO_POS_INVALID` | `info.container` / `info.pos` is not a short label (64 chars of letters, digits, space, `_ . : # -`) or is a prototype key | Use a plain label |
491
+ | `ERR_MDK_SERVICE_ENV` | The `SERVICE` env var is unset | Set `SERVICE` to `gateway` or `worker` |
492
+ | `ERR_MDK_SERVICE_UNKNOWN` | `SERVICE` is set to something other than `gateway` or `worker` | Set `SERVICE` to `gateway` or `worker` |
493
+ | `ERR_MDK_WORKER_ENV` | A Worker is started without both `WORKER` and `RACK`, or a non-pool Worker is started without `TYPE` | Set `WORKER` and `RACK`, and set `TYPE` for non-pool Workers |
494
+ | `ERR_MDK_WORKER_EXPORT` | The resolved Worker package does not export the expected factory function | Check the Worker package exports the factory named in its boot spec |
495
+ | `ERR_MDK_WORKER_UNKNOWN` | The `WORKER` name has no runtime boot entry, or the `WORKER`+`TYPE` pair has no plugin model | Use a known Worker name and a `TYPE` its boot spec defines |
496
+ | `ERR_NO_IMPL` | An abstract Thing or Container method is called on a class that has not overridden it | Implement the method on the concrete device class |
497
+ | `ERR_PROC_RACK_UNDEFINED` | PoolService is constructed with a context that has no `rack` | Pass `ctx.rack` when constructing the pool service |
498
+ | `ERR_PROVISIONING_DB_REQUIRED` | DeviceProvisioningService is constructed without a `db` | Pass a `db` in the constructor options |
499
+ | `ERR_PROVISIONING_DEVICE_TYPE_REQUIRED` | DeviceProvisioningService is constructed without a `deviceType` string | Pass a non-empty `deviceType` string |
500
+ | `ERR_QUERY_INVALID` | A Worker external-data request has no `query`, or a caller filter is not an object | Include a `query` object in the request |
501
+ | `ERR_QUERY_OPERATOR_FORBIDDEN` | A caller filter uses an operator outside the allowlist (`$where`, `$expr`, ...) | Use the allowlisted operators |
502
+ | `ERR_QUERY_PATH_FORBIDDEN` | A caller filter, sort or projection names `opts.*`, a secret-named field or a prototype key | Filter on `info.*`, `tags`, `type`, `id`, `code` |
503
+ | `ERR_QUERY_REGEX_INVALID` | A `$regex` is not a string, is over 256 characters, or has `$options` other than `i` | Send the literal text to search for, with optional `^` / `$` |
504
+ | `ERR_QUERY_TOO_COMPLEX` | A caller filter is deeper than 8 levels or has more than 500 nodes | Simplify the filter |
505
+ | `ERR_QUERY_OFFSET_INVALID` / `ERR_QUERY_LIMIT_INVALID` | `offset`/`limit` is not a non-negative integer | Pass integers |
506
+ | `ERR_STATS_KEYPATH_FORBIDDEN` / `ERR_STATS_KEYPATH_INVALID` | A `libStats` keypath reaches `__proto__`/`constructor`/`prototype` or is not `a.b[0].c` syntax | Use a plain property path |
507
+ | `ERR_RUNTIME_NOT_BOUND` | A device call runs before the runtime device-context getter is bound | Bind the runtime (start the Worker infra) before making device calls |
508
+ | `ERR_SET_LED_ENABLED_INVALID` | A miner `setLED` action is given a non-boolean `enabled` | Pass `enabled` as a boolean |
509
+ | `ERR_SETTINGS_DB_REQUIRED` | SettingsService is constructed without a `settingsDb` | Pass a `settingsDb` in the constructor options |
510
+ | `ERR_SNAPS_COLLECT_SNAP_REQUIRED` | SnapsService is constructed without a `collectSnap` function | Pass a `collectSnap` function in the constructor options |
511
+ | `ERR_SNAPS_LOGS_REQUIRED` | SnapsService is constructed without a `logs` instance | Pass the logs service in the constructor options |
512
+ | `ERR_START_INVALID` | A DB range read is requested without a `start` bound | Supply a `start` timestamp in the query |
513
+ | `ERR_STATS_LOGS_REQUIRED` | StatsService is constructed without a `logs` instance | Pass the logs service in the constructor options |
514
+ | `ERR_STORE_DIR_REQUIRED` | `createWorkerInfra` is called without `opts.storeDir` | Pass a `storeDir` path in the Worker infra options |
515
+ | `ERR_SWITCH_CONTAINER_ENABLED_INVALID` | A container `switchContainer` action is given a non-boolean `enabled` | Pass `enabled` as a boolean |
516
+ | `ERR_SWITCH_COOLING_SYSTEM_ENABLED_INVALID` | A `switchCoolingSystem` action is given a non-boolean `enabled` | Pass `enabled` as a boolean |
517
+ | `ERR_SWITCH_SOCKET_ARGS_INVALID` | A `switchSocket` action's first argument is not an array of tuples | Pass socket instructions as an array |
518
+ | `ERR_SWITCH_SOCKET_ENABLED_INVALID` | A `switchSocket` tuple's `enabled` is not a boolean | Pass the tuple's `enabled` as a boolean |
519
+ | `ERR_SWITCH_SOCKET_PDU_INDEX_INVALID` | A `switchSocket` tuple's PDU index is not a string | Pass the PDU index as a string |
520
+ | `ERR_SWITCH_SOCKET_SOCKET_INDEX_INVALID` | A `switchSocket` tuple's socket index is not a string | Pass the socket index as a string |
521
+ | `ERR_THING_CODE_INVALID` | A device is registered with a `code` that does not end in `-<digits>` | Supply a code ending in a hyphen and numeric suffix, or omit it to auto-generate |
522
+ | `ERR_THING_COMMENT_NOTFOUND` | A comment edit or delete targets an `id` or `ts` that no comment on the thing matches | Reference an existing comment `id` or `ts` |
523
+ | `ERR_THING_COMMENTS_NOTFOUND` | A comment edit or delete runs on a thing that has no comments | Add a comment before editing or deleting one |
524
+ | `ERR_THING_ADDRESS_FORBIDDEN` | A remote register/update points a device at a loopback, link-local, metadata, unspecified or multicast address, or outside `allowedAddressCidrs` | Use the device's LAN address, or add its network to `conf.thing.allowedAddressCidrs` |
525
+ | `ERR_THING_ADDRESS_INVALID` / `ERR_THING_ADDRESS_UNRESOLVABLE` | `opts.address` is not an IP or hostname, or the hostname does not resolve | Pass a valid, resolvable address |
526
+ | `ERR_THING_PORT_INVALID` | `opts.port` is not an integer 1–65535 | Pass a valid port |
527
+ | `ERR_TIME_RANGE_INVALID` / `ERR_TIME_RANGE_TOO_LARGE` | A pool time range is non-numeric or spans more than 9000 buckets | Narrow the range |
528
+ | `ERR_THING_IP_ADDRESS_EXISTS` | A device is registered or updated with an address already held by another device while duplicate IPs are disallowed | Use a unique address or enable `allowDuplicateIPs` |
529
+ | `ERR_THING_MACADDRESS_EXISTS` | A device is registered or updated with a MAC address already held by another device | Use a unique MAC address |
530
+ | `ERR_THING_NOTFOUND` | An operation references a device `id` that is not in the store | Register the device first or correct the `id` |
531
+ | `ERR_THING_POS_EXISTS` | A device is registered or updated with a position and container pair already held by another device | Use a free position within that container |
532
+ | `ERR_THING_SERIALNUM_EXISTS` | A device is registered or updated with a serial number already held by another device | Use a unique serial number |
533
+ | `ERR_THING_TAGS_INVALID` | Auxiliary tags are not an array, number more than 32, or one is not a label of at most 64 characters (letters, digits, space, `_ . : # -`, starting with a letter or digit; `__proto__`, `constructor` and `prototype` refused) | Pass short label strings as an array |
534
+ | `ERR_THING_VALIDATE_OPTS_INVALID` | A device is registered without an `opts` object | Include the device's `opts` in the registration payload |
535
+ | `ERR_THING_WITH_CODE_ALREADY_EXISTS` | A device is registered with a `code` already assigned to another device | Use a unique code or omit it to auto-generate |
536
+ | `ERR_THING_WITH_ID_ALREADY_EXISTS` | A device is registered with an `id` that already exists | Use a new `id` or omit it to auto-generate |
537
+ | `ERR_UNKNOWN_OPERATION` | Numeric aggregation is asked for an operation other than `avg` or `sum` | Use `avg` or `sum` |
538
+
539
+ ## Directory layout
540
+
541
+ ```text
542
+ mdk/
543
+ ├── index.js # `getKernel`, `startGateway`, `waitForDiscovery`
544
+ ├── services.js # `startServices` — facility bootstrap helpers
545
+ ├── worker.js # Worker-side entry point
546
+ ├── lib/
547
+ │ ├── local-discovery.js # `discovery: { mode: 'local' }` helpers and the trusted-keys (admission) helpers
548
+ │ ├── utils.js # Shared helpers (isValidSnap, isOffline, etc.)
549
+ │ ├── worker-infra.js # `createWorkerInfra` — per-Worker infra bootstrap
550
+ │ ├── stats/ # `libStats` — telemetry aggregation ops (sum, avg, group_*, …)
551
+ │ ├── services/ # One class per domain service (Actions, Alerts, Comments, LogHistory, Logs, Pool, Provisioning, Settings, Snaps, Stats)
552
+ │ │ └── pool-utils/ # Shared pool-service constants and time helpers
553
+ │ ├── templates/ # Alert and stats computation templates
554
+ │ └── things/ # Thing subclasses — Container, Miner, PowerMeter, Sensor (base: Thing)
555
+ ├── utils/
556
+ │ ├── constants.js # MDK_STORE and other well-known names
557
+ │ ├── index.js # Facility bootstrap helpers (Intervals, Store, ActionApprover)
558
+ │ ├── initialize.js # Service initialization helpers
559
+ │ ├── service-bootstrap.js # Spawns/manages service subprocesses
560
+ │ └── compose-yaml.js # `buildComposeYaml` — docker-compose file generation
561
+ └── tests/
562
+ ├── unit/ # One suite per lib/service/thing/util module
563
+ └── integration/ # Kernel-key-file, local-discovery, actions-flow (per Worker family)
564
+ ```
565
+
566
+ ## Next steps
567
+
568
+ - [Run the Gateway](../../../docs/guides/gateway/run.md): programmatic and standalone startup, auth configuration, and HRPC key setup
569
+ - [Add hardware devices](../../workers/README.md): understand how Workers register devices and expose them to Kernel
570
+ - [Add custom routes with plugins](../../../docs/guides/gateway/plugins.md): extend the Gateway with `apps`
571
+ - [See the MVP site example](../../../examples/mvp-site/README.md): multi-Worker, multi-device setup in a separate-process topology
572
+ - [Understand the the full MDK layer model](../../../docs/concepts/architecture.md)