@tetherto/mdk-worker 0.0.0-stage → 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.
package/README.md CHANGED
@@ -1,3 +1,207 @@
1
- # Temporary Holding Version
1
+ # @tetherto/mdk-worker
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `WorkerRuntime` hosts a Worker Plugin's devices behind one HRPC channel to Kernel. Every
4
+ Worker package in this monorepo (miners, containers, power meters, temperature, minerpools, …) constructs a
5
+ `WorkerRuntime` from its own boot function; see [Workers](../../workers/README.md) for the plugin side of that
6
+ contract.
7
+
8
+ ## What this package exports
9
+
10
+ ```js
11
+ const { WorkerRuntime, loadPlugin, loadContract, createInstance, createModuleContext } = require('@tetherto/mdk-worker')
12
+ ```
13
+
14
+ - `WorkerRuntime`: starts the Hyperswarm RPC server, dispatches MDK Protocol actions to per-device handlers, and
15
+ persists the DHT/RPC keypair. It creates `opts.storeDir` owner-only (mode `0700`) and writes any file-backed seed
16
+ `0600`, so the Worker's identity stays readable only by its owner
17
+ - `loadPlugin`: loads a plugin object (`mdk-contract.json`, handlers, ...) from a directory
18
+ - `loadContract`, `createInstance`, `createModuleContext` — the loader internals `loadPlugin` composes
19
+
20
+ ## Store services
21
+
22
+ > [!CAUTION]
23
+ > **Deprecated.** Store services are the legacy thing/adapter Worker surface: `opts.services`, the built-ins in this
24
+ > section, `ctx.services` in handlers, and the `@tetherto/mdk-core` layer behind them
25
+ > ([`createWorkerInfra`](../mdk/lib/worker-infra.js), the `*Service` classes, and the `Thing` base classes the in-repo
26
+ > device drivers extend). Nothing is removed, because the in-repo Workers still run on it. Build a new Worker as a
27
+ > directory-loaded package on `WorkerRuntimeV2` with no `services`, the shape
28
+ > [Build a third-party Worker](../../../docs/guides/workers/build-a-worker.md) walks through.
29
+ >
30
+ > MDK has no `WorkerRuntimeV2` equivalent yet for:
31
+ >
32
+ > - Adding or removing devices at runtime (`registerThing`, `updateThing`, `forgetThings`): a `WorkerRuntimeV2` device
33
+ > list is fixed at construction, so a device change is a config edit and a restart
34
+ > - Store-backed history and aggregates (`logs`, `historical_logs`, `logs_multi`, `stats`) and pool `ext_data`
35
+ > - Device comments and Worker settings
36
+ > - Kernel write actions (`pushAction`): the Kernel resolves candidate writes with `write.calls.request`, which only a
37
+ > Worker with the `actions` service answers
38
+
39
+ A [`WorkerRuntime`](lib/worker-runtime.js) host can inject an `opts.services` object naming which of the store-backed
40
+ capabilities below to serve. When present, [`service-builtins.js`](lib/service-builtins.js) answers the matching
41
+ queries and commands directly from a host-managed persisted store — never from the plugin's device handlers. A
42
+ Worker Plugin that doesn't need any of this passes `services: null`; see
43
+ [Build a third-party Worker](../../../docs/guides/workers/build-a-worker.md)'s minimal reference implementation.
44
+
45
+ Each entry activates only when its named service exists on `opts.services`:
46
+
47
+ | Service | Telemetry it activates | Commands it activates | What it needs implemented |
48
+ |--------------|-------------------------|-----------------------|-----------------------------------|
49
+ | `logHistory` | `logs`, `historical_logs`, `logs_multi` | — | `tailLog({thingId, ...})`, `getHistoricalLogs({thingId, ...})` |
50
+ | `settings` | `settings` | `saveSettings` | `getSettings()`, `saveSettingsEntries(params)` |
51
+ | `provisioning` | `thing_config`, `list`, `count`, `config` | `registerThing`, `updateThing`, `forgetThings` | `getThingConf`, `listDevices`, `listDeviceIds`, `registerThing`, `updateThing`, `forgetThings` |
52
+ | `stats` | `stats` | — | `aggrStats(deviceIds, opts)` |
53
+ | `comments` | — | `saveComment`, `editComment`, `deleteComment` | `saveThingComment`, `editThingComment`, `deleteThingComment` |
54
+ | `actions` | `write.calls.request` (wired directly in `WorkerRuntime.handleRequest`, not through the builtin tables) | — | `getWriteCalls(payload)` |
55
+ | `pool` | `ext_data` (scheduler-driven pool Workers) | — | `getWrkExtData({query})` |
56
+ | `snaps` | used only inside the `list` builtin, for `params.status` snapshot enrichment | — | `getLast(deviceId)` |
57
+
58
+ These never reach plugin handlers — they are Worker infrastructure, not device translation. When `provisioning` is
59
+ active, its `list` builtin shadows the runtime's own bare `{ deviceId, status }` device list with the fuller thing
60
+ list (`{ id, code, type, tags, info, comments, address, port, ... }`) some gateways read.
61
+
62
+ > [!IMPORTANT]
63
+ > Built-in commands must be published into the contract or the Kernel dispatcher rejects them
64
+ > (`ERR_COMMAND_NOT_IN_CAPABILITIES`) before they ever reach the runtime. [`mergeBuiltinCommands`](lib/service-builtins.js)
65
+ > handles this: it deep-clones the plugin's contract and appends one entry per *active* builtin command
66
+ > (`registerThing`, `updateThing`, `forgetThings`, `saveSettings`, `saveComment`, `editComment`, `deleteComment`) —
67
+ > the source contract is never mutated, and without `services` (or with every builtin inactive) the original contract
68
+ > reference is returned unchanged.
69
+
70
+ `allowEmptyDevices` (a `WorkerRuntime` constructor option, not a service) is the usual pairing with `provisioning`:
71
+ a fresh host boots with zero devices, takes `registerThing` writes to populate the store, and picks up the
72
+ provisioned set on its next restart. See [Build a third-party Worker](../../../docs/guides/workers/build-a-worker.md)'s
73
+ troubleshooting section for the constructor-level error (`ERR_DEVICES_REQUIRED`).
74
+
75
+ Every in-repo device Worker (Avalon, Antspace, Bitdeer, ABB, SATEC, Schneider, Seneca) wires this surface through
76
+ [`createWorkerInfra`](../mdk/lib/worker-infra.js), which builds the `store`, `services`, and provisioning-first
77
+ `devices` list a `WorkerRuntime` needs, then hands the constructed `services` object straight to
78
+ `new WorkerRuntime(plugin, { ..., services, allowEmptyDevices: true })`. The Ocean and F2Pool pool Workers inject only
79
+ a `pool` service. Reading [`miners/avalon/plugin/boot.js`](../../workers/miners/avalon/plugin/boot.js) alongside this
80
+ section is the fastest way to see the whole surface exercised end to end. Whatsminer and Antminer are
81
+ manufacturer-maintained external Workers, listed in
82
+ [`backend/workers/external-workers.json`](../../workers/external-workers.json), hosted on `WorkerRuntimeV2` with no
83
+ `services`.
84
+
85
+ ## Caller allowlist
86
+
87
+ The Worker RPC server is **fail-closed**. Only public keys on its allowlist may connect; unknown keys are rejected at
88
+ connect time and every request is re-checked (`ERR_WORKER_CALLER_NOT_ALLOWED`), so `revokeKey()` applies to open
89
+ connections. Every request is envelope-validated (`validateFull`) before dispatch.
90
+
91
+ ```js
92
+ const runtime = new WorkerRuntime(plugin, {
93
+ workerId: 'rack-1',
94
+ devices,
95
+ allowedKeys: [kernel.getClientPublicKey()] // the key the Kernel dials Workers with
96
+ })
97
+ runtime.allowKey(gatewayClientKeyHex) // more callers, before or after start()
98
+ runtime.revokeKey(hex)
99
+ runtime.getAllowedKeys()
100
+ await runtime.start()
101
+ ```
102
+
103
+ | Option | Default | Description |
104
+ | --- | --- | --- |
105
+ | `allowedKeys` | `[]` | Hex/Buffer public keys allowed to call. `start()` throws `ERR_WORKER_ALLOWED_KEYS_REQUIRED` when empty |
106
+ | `allowAll` | `false` | Admit every DHT peer that learns the Worker key. Development only; logs a warning at start |
107
+ | `bootstrap` | HyperDHT public bootstrap | Bootstrap nodes for the Worker's HyperDHT node; pass the same list to every component on a private DHT network |
108
+
109
+ Admit the Kernel's **client** key (`kernel.getClientPublicKey()`), not its listener key (`getPublicKey()`). Add a Gateway or other client
110
+ key only when deliberately granting direct Worker access; every admitted key can issue supported commands, regardless of its Kernel role.
111
+ `@tetherto/mdk-core` adds callers automatically for local stacks:
112
+ `trustKernel(runtime, kernel)` adds write-capable Kernel callers in-process, and `trustKeysFromDir(runtime, dir)` adds every published
113
+ key across processes. Omit those helpers when enforcing a Kernel-only Worker allowlist. The
114
+ [site security blueprint](../../../docs/guides/security/index.md#step-3-isolate-workers-and-devices) explains who gets access and what that access allows.
115
+ Every in-repo Worker boot function forwards `allowedKeys` /
116
+ `allowAll` to the runtime.
117
+
118
+ ## Request hardening
119
+
120
+ The Worker RPC key is reachable without going through the Kernel, so the runtime does not trust the caller to have
121
+ checked anything:
122
+
123
+ - **Command params** are validated against the contract's declared `params` (`type`, `required`, `enum`, `min`/`max`)
124
+ before the handler runs, after positional (`{ value }` / `{ args }`) normalization. Undeclared keys pass through;
125
+ a `null` or missing optional param is skipped. The built-in commands are checked against their published entries
126
+ too. Arrays pass an `object` param (the schema has no `array` type).
127
+ - **Device id lists** in `logs_multi` and `stats` must be arrays of strings, at most `MAX_DEVICE_IDS` (1000) long.
128
+ - **Addressing:** the envelope's `deviceId` always wins over a `thingId`/`id` key in params; worker-scoped requests
129
+ (`deviceId: null`) keep the caller's key.
130
+ - **Errors over RPC:** handler and service errors whose message starts with `ERR_` are returned as-is. Anything else
131
+ is logged locally (`DEBUG=mdk:worker:runtime`) and replaced by `ERR_TELEMETRY_FAILED`, `ERR_COMMAND_FAILED` or
132
+ `ERR_REQUEST_FAILED`, so driver messages carrying addresses or credentials never leave the process. Throw
133
+ `ERR_*` errors from handlers for anything the caller should see.
134
+ - **Built-in `health` telemetry** (`WorkerRuntimeV2`) returns only `{ status, id, workerId }`; `env`, device `opts`
135
+ and `config` are never returned.
136
+ - **Seed files** (`storeDir`): an existing seed that is not exactly 32 bytes fails start with
137
+ `ERR_SEED_FILE_MALFORMED` instead of being replaced (which would rotate the Worker's identity). Symlinks
138
+ (`ERR_SEED_FILE_SYMLINK`), non-regular files (`ERR_SEED_FILE_INVALID`) and files owned by another user
139
+ (`ERR_SEED_FILE_OWNER`) are refused; group/other permission bits are stripped to `0600`. New seeds are created
140
+ exclusively (`O_EXCL`) with mode `0600`.
141
+
142
+ ## Errors
143
+
144
+ The plugin and contract loaders validate their inputs and throw on the first problem; the request handlers instead
145
+ return these codes on the response envelope's `error` field rather than throwing.
146
+
147
+ | Code | Fires when | Fix |
148
+ | --- | --- | --- |
149
+ | `ERR_COMMAND_FAILED` | A command handler or built-in service throws an error whose message does not start with `ERR_` (details are logged locally) | Check the Worker's `mdk:worker:runtime` debug log; throw `ERR_*` errors from handlers |
150
+ | `ERR_CONTRACT_DIR_REQUIRED` | `loadContract` is called without a non-empty package-directory string | Pass the plugin package directory as `pkgDir` |
151
+ | `ERR_CONTRACT_INVALID_JSON` | The package's `mdk-contract.json` cannot be parsed as JSON | Correct the JSON syntax in `mdk-contract.json` |
152
+ | `ERR_CONTRACT_NOT_FOUND` | `mdk-contract.json` cannot be read in the package directory | Ensure `mdk-contract.json` exists in the package directory |
153
+ | `ERR_DEVICE_CONFIG_INVALID` | A device spec supplies `config` that is not an object | Pass `config` as an object or omit it |
154
+ | `ERR_DEVICE_ID_DUPLICATE` | Two device specs share the same `deviceId` | Give each device a unique `deviceId` |
155
+ | `ERR_DEVICE_ID_MISSING` | A device spec has no non-empty string `deviceId` | Give every device spec a `deviceId` string |
156
+ | `ERR_DEVICE_ID_REQUIRED` | A non-metrics telemetry query or a device command arrives with no `deviceId` | Include the target `deviceId` in the request |
157
+ | `ERR_DEVICE_INSTANCE_MISSING` | A handler is invoked for a device that has no open instance | Register the device and open its instance before invoking handlers |
158
+ | `ERR_DEVICE_NOT_FOUND` | A request targets a `deviceId` the Worker does not hold | Target a `deviceId` the Worker manages |
159
+ | `ERR_DEVICE_UNAVAILABLE` | A telemetry or command request targets a device that is not online | Wait for the device to reconnect and come online before retrying |
160
+ | `ERR_DEVICES_REQUIRED` | The runtime is started with an empty or missing device list where empty is not allowed | Supply at least one device spec |
161
+ | `ERR_INSTANCE_DEVICE_ID_REQUIRED` | `createInstance` is called with a device that has no non-empty `id` | Give the device object an `id` string |
162
+ | `ERR_INSTANCE_DEVICE_REQUIRED` | `createInstance` is called without a device object | Pass a `device` object to `createInstance` |
163
+ | `ERR_INSTANCE_DIR_REQUIRED` | `createInstance` is called without a non-empty `dir` string | Pass the plugin directory as `dir` |
164
+ | `ERR_INSTANCE_ENTRIES_REQUIRED` | `createInstance` is called without an `entries` object | Pass the resolved `entries` map |
165
+ | `ERR_INSTANCE_HANDLER_LOAD_FAILED` | A handler module throws while being loaded | Fix the error inside the handler module |
166
+ | `ERR_INSTANCE_HANDLER_NOT_FOUND` | A handler file path in the contract cannot be resolved | Correct the handler path in the contract |
167
+ | `ERR_INSTANCE_HANDLER_NOT_FUNCTION` | A resolved handler module does not export a function | Export a function from the handler module |
168
+ | `ERR_MODULE_CONTEXT_DIR_REQUIRED` | The module context is created without a non-empty `dir` string | Pass a directory when creating the module context |
169
+ | `ERR_NO_DEVICE_CONTEXT` | `@tetherto/mdk-worker/device` is required outside a plugin instance | Load the plugin via `createInstance({ dir, entries, device })` so the device context exists |
170
+ | `ERR_PARAM_ENUM` | A command param is not one of its declared `enum` values | Send one of the declared values |
171
+ | `ERR_PARAM_RANGE` | A numeric command param is outside its declared `min`/`max`, or a `deviceIds` list is longer than 1000 | Send a value in range; split long device lists |
172
+ | `ERR_PARAM_REQUIRED` | A param declared `required: true` is missing or `null` | Include the param |
173
+ | `ERR_PARAM_TYPE` | A command param does not match its declared `type`, or `deviceIds` is not an array of strings | Send the declared type |
174
+ | `ERR_PARAMS_INVALID` | Command `params` is not an object after positional normalization | Send `params` as an object |
175
+ | `ERR_PLUGIN_CONNECT_NOT_FUNCTION` | The plugin's `connect` is not a function | Provide a `connect` function on the plugin |
176
+ | `ERR_PLUGIN_CONTRACT_CAPABILITIES_MISSING` | The contract has no `capabilities` object | Add a `capabilities` object to the contract |
177
+ | `ERR_PLUGIN_CONTRACT_METADATA_MISSING` | The contract has no `metadata` object | Add a `metadata` object to the contract |
178
+ | `ERR_PLUGIN_CONTRACT_MISSING` | The plugin carries no contract object | Provide the plugin's contract |
179
+ | `ERR_PLUGIN_DIR_MISSING` | The plugin has no non-empty `dir` string | Set the plugin's `dir` |
180
+ | `ERR_PLUGIN_DISCONNECT_NOT_FUNCTION` | The plugin defines `disconnect` as something other than a function | Make `disconnect` a function or omit it |
181
+ | `ERR_PLUGIN_DUPLICATE_NAME` | Two entries in a contract section share a name | Give each entry in the section a unique name |
182
+ | `ERR_PLUGIN_ENTRY_NAME_MISSING` | A contract section entry has no non-empty `name` | Give every entry a `name` |
183
+ | `ERR_PLUGIN_HANDLER_MISSING` | A contract entry has no non-empty `handler` path | Set the entry's `handler` path |
184
+ | `ERR_PLUGIN_HANDLER_NOT_FOUND` | An entry's handler file cannot be required | Correct the entry's handler path |
185
+ | `ERR_PLUGIN_HANDLER_NOT_FUNCTION` | An entry's handler module does not export a function | Export a function from the handler |
186
+ | `ERR_PLUGIN_LOAD_HANDLER_NOT_FUNCTION` | `loadHandler` is provided but is not a function | Pass a function for `loadHandler` or omit it |
187
+ | `ERR_PLUGIN_REQUIRED` | `loadPlugin` is called without a plugin object | Pass a plugin object to `loadPlugin` |
188
+ | `ERR_PLUGIN_SECTION_NOT_ARRAY` | A contract capabilities section is present but is not an array | Make each capabilities section an array |
189
+ | `ERR_REQUEST_FAILED` | The RPC responder fails before or outside action dispatch (for example an undecodable request) | Send a well-formed MDK envelope |
190
+ | `ERR_SEED_FILE_INVALID` | A seed path in `storeDir` is not a regular file | Remove the entry so a new seed can be created |
191
+ | `ERR_SEED_FILE_MALFORMED` | A seed file in `storeDir` is not exactly 32 bytes | Restore the seed from backup, or delete it to accept a new Worker identity |
192
+ | `ERR_SEED_FILE_OWNER` | A seed file in `storeDir` is owned by another user | Run the Worker as the file's owner or fix the ownership |
193
+ | `ERR_SEED_FILE_SYMLINK` | A seed path in `storeDir` is a symbolic link | Replace the link with the real file |
194
+ | `ERR_TELEMETRY_FAILED` | A telemetry handler or built-in query throws an error whose message does not start with `ERR_` (details are logged locally) | Check the Worker's `mdk:worker:runtime` debug log; throw `ERR_*` errors from handlers |
195
+ | `ERR_UNKNOWN_ACTION` | An envelope carries an action the runtime does not handle | Send a supported action |
196
+ | `ERR_UNKNOWN_COMMAND` | A command request names a command the plugin does not define | Send a command declared in the contract |
197
+ | `ERR_UNKNOWN_QUERY_TYPE` | A telemetry query names a type the plugin does not provide | Query a telemetry type declared in the contract |
198
+ | `ERR_WORKER_DIR_REQUIRED` | WorkerRuntimeV2 is started without a non-empty `dir` string | Pass the Worker package directory as `dir` |
199
+ | `ERR_WORKER_ID_REQUIRED` | The runtime is constructed without a `workerId` string | Pass a `workerId` string |
200
+
201
+ ## Next steps
202
+
203
+ See:
204
+
205
+ - [`backend/core/docs/README.md`](../docs/README.md#worker-runtime-tethertomdk-worker): where this package sits
206
+ in Core's dependency graph
207
+ - [Workers](../../workers/README.md): the plugin packages this runtime hosts
package/device.js ADDED
@@ -0,0 +1,15 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * The ambient per-instance device, required by handler files as
5
+ * `require('@tetherto/mdk-worker/device')` and resolving to that instance's
6
+ * frozen { id, opts, env, config, workerId, logger }.
7
+ *
8
+ * Inside an instance the request never reaches this file: lib/instance-loader.js
9
+ * intercepts it and hands back the device the instance was created with. This
10
+ * file exists to make the specifier resolvable and to fail with a pointer at
11
+ * createInstance() when a plugin module is required outside an instance — a test
12
+ * requiring a handler directly, say — instead of leaving the plugin with
13
+ * `undefined` where its device should be.
14
+ */
15
+ throw new Error('ERR_NO_DEVICE_CONTEXT: @tetherto/mdk-worker/device is only available inside a plugin instance; load the plugin with createInstance({ dir, entries, device }) from @tetherto/mdk-worker')
package/index.js ADDED
@@ -0,0 +1,12 @@
1
+ 'use strict'
2
+
3
+ module.exports = {
4
+ WorkerRuntime: require('./lib/worker-runtime'),
5
+ WorkerRuntimeV2: require('./lib/worker-runtime-v2'),
6
+ loadPlugin: require('./lib/plugin-loader').loadPlugin,
7
+ loadContract: require('./lib/contract-loader').loadContract,
8
+ validateContract: require('./lib/contract-validator').validateContract,
9
+ validateParams: require('./lib/param-validator').validateParams,
10
+ createInstance: require('./lib/instance-loader').createInstance,
11
+ createModuleContext: require('./lib/module-context').createModuleContext
12
+ }
@@ -0,0 +1,74 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { loadPlugin } = require('./plugin-loader')
6
+
7
+ const CONTRACT_FILE = 'mdk-contract.json'
8
+
9
+ /**
10
+ * Load a Worker Plugin from a directory: <pkgDir>/mdk-contract.json is the
11
+ * package's only index. Structural validation is plugin-loader's (same
12
+ * ERR_PLUGIN_* errors for a missing metadata/capabilities object, a missing
13
+ * handler field or a duplicate name), and every handler path is resolved on
14
+ * disk — but never executed. A handler module reads its device from the
15
+ * ambient '@tetherto/mdk-worker/device', which only exists inside an instance,
16
+ * so requiring one here would either throw or bind the wrong device.
17
+ *
18
+ * Returns { dir, contract, publishedContract, entries }, where `entries` is
19
+ * { telemetry: Map, commands: Map } of name -> resolved handler file: the input
20
+ * createInstance() binds once per device.
21
+ */
22
+ function loadContract (pkgDir) {
23
+ if (typeof pkgDir !== 'string' || !pkgDir) throw new Error('ERR_CONTRACT_DIR_REQUIRED')
24
+
25
+ const file = path.join(pkgDir, CONTRACT_FILE)
26
+ let raw
27
+ try {
28
+ raw = fs.readFileSync(file, 'utf8')
29
+ } catch (err) {
30
+ throw new Error(`ERR_CONTRACT_NOT_FOUND: ${file}: ${err.message}`)
31
+ }
32
+
33
+ let contract
34
+ try {
35
+ contract = JSON.parse(raw)
36
+ } catch (err) {
37
+ throw new Error(`ERR_CONTRACT_INVALID_JSON: ${file}: ${err.message}`)
38
+ }
39
+
40
+ const entries = { telemetry: new Map(), commands: new Map() }
41
+ const loaded = loadPlugin({
42
+ contract,
43
+ dir: pkgDir,
44
+ // A directory-loaded plugin has no connect(): WorkerRuntimeV2 builds an
45
+ // instance per device instead. Only present because v1's shape demands it.
46
+ connect: _noConnect,
47
+ loadHandler: (handlerFile, { section, name }) => {
48
+ entries[section].set(name, require.resolve(handlerFile))
49
+ return _unbound(section, name)
50
+ }
51
+ })
52
+
53
+ return {
54
+ dir: pkgDir,
55
+ contract: loaded.contract,
56
+ publishedContract: loaded.publishedContract,
57
+ entries
58
+ }
59
+ }
60
+
61
+ function _noConnect () {
62
+ throw new Error('ERR_CONTRACT_NO_CONNECT')
63
+ }
64
+
65
+ // plugin-loader publishes a function per entry; a contract on its own has no
66
+ // process-wide handler to give it, so the placeholder fails loudly rather than
67
+ // silently standing in for a device-bound one.
68
+ function _unbound (section, name) {
69
+ return () => {
70
+ throw new Error(`ERR_HANDLER_NOT_BOUND: ${section}.${name}`)
71
+ }
72
+ }
73
+
74
+ module.exports = { loadContract }
@@ -0,0 +1,114 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ const SCHEMA_PATH = path.join(__dirname, '..', 'mdk-contract.schema.json')
7
+
8
+ let _schema = null
9
+
10
+ function _loadSchema () {
11
+ if (!_schema) _schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, 'utf8'))
12
+ return _schema
13
+ }
14
+
15
+ function _jsonType (v) {
16
+ if (Array.isArray(v)) return 'array'
17
+ if (v === null) return 'null'
18
+ return typeof v
19
+ }
20
+
21
+ function _matchesType (expected, v) {
22
+ if (expected === 'integer') return typeof v === 'number' && Number.isInteger(v)
23
+ return _jsonType(v) === expected
24
+ }
25
+
26
+ // Implements the JSON Schema subset mdk-contract.schema.json uses (type, enum,
27
+ // required, properties, additionalProperties, items). A schema feature outside
28
+ // this subset would validate as always-passing here; the ajv parity test in
29
+ // backend/workers guards against that drift.
30
+ function _validateNode (schema, value, at, errors) {
31
+ if (schema.type && !_matchesType(schema.type, value)) {
32
+ errors.push(`ERR_CONTRACT_TYPE: ${at || '/'} expected ${schema.type}, got ${_jsonType(value)}`)
33
+ return
34
+ }
35
+ if (schema.enum && !schema.enum.includes(value)) {
36
+ errors.push(`ERR_CONTRACT_ENUM: ${at} is ${JSON.stringify(value)}, allowed: ${schema.enum.join(' | ')}`)
37
+ }
38
+ if (_jsonType(value) === 'object') {
39
+ for (const key of schema.required || []) {
40
+ if (!(key in value)) errors.push(`ERR_CONTRACT_REQUIRED: ${at || '/'} missing required property '${key}'`)
41
+ }
42
+ const props = schema.properties || {}
43
+ for (const [key, v] of Object.entries(value)) {
44
+ if (props[key]) {
45
+ _validateNode(props[key], v, `${at}/${key}`, errors)
46
+ } else if (schema.additionalProperties === false) {
47
+ errors.push(`ERR_CONTRACT_UNEXPECTED: ${at || '/'} has unexpected property '${key}'`)
48
+ } else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
49
+ _validateNode(schema.additionalProperties, v, `${at}/${key}`, errors)
50
+ }
51
+ }
52
+ }
53
+ if (Array.isArray(value) && schema.items) {
54
+ value.forEach((v, i) => _validateNode(schema.items, v, `${at}/${i}`, errors))
55
+ }
56
+ }
57
+
58
+ // Rules the schema cannot express. Handler existence needs the plugin dir, so
59
+ // it only runs when the caller has one (a fetched external contract does not).
60
+ function _semanticChecks (contract, dir, errors) {
61
+ if (_jsonType(contract) !== 'object') return
62
+ const caps = contract.capabilities
63
+ if (_jsonType(caps) !== 'object') return
64
+
65
+ for (const section of ['telemetry', 'commands']) {
66
+ const entries = Array.isArray(caps[section]) ? caps[section] : []
67
+ const seen = new Set()
68
+ entries.forEach((entry, i) => {
69
+ const at = `/capabilities/${section}/${i}`
70
+ if (_jsonType(entry) !== 'object') return
71
+ if (entry.name) {
72
+ if (seen.has(entry.name)) errors.push(`ERR_CONTRACT_DUPLICATE_NAME: ${at} '${entry.name}' already declared in ${section}`)
73
+ seen.add(entry.name)
74
+ }
75
+ if (dir && typeof entry.handler === 'string' && entry.handler) {
76
+ const file = path.resolve(dir, entry.handler)
77
+ if (!fs.existsSync(file)) errors.push(`ERR_CONTRACT_HANDLER_NOT_FOUND: ${at} handler '${entry.handler}' does not exist (resolved: ${file})`)
78
+ }
79
+ })
80
+ }
81
+
82
+ const commands = Array.isArray(caps.commands) ? caps.commands : []
83
+ commands.forEach((cmd, i) => {
84
+ if (_jsonType(cmd) !== 'object' || !Array.isArray(cmd.params)) return
85
+ cmd.params.forEach((p, j) => {
86
+ if (_jsonType(p) !== 'object' || p.type !== 'number') return
87
+ const at = `/capabilities/commands/${i}/params/${j}`
88
+ if (typeof p.min !== 'number' || typeof p.max !== 'number') {
89
+ errors.push(`ERR_CONTRACT_PARAM_BOUNDS_MISSING: ${at} ('${cmd.name}'.'${p.name}') — number params must declare min and max so the Kernel can enforce ERR_PARAM_RANGE`)
90
+ } else if (p.min > p.max) {
91
+ errors.push(`ERR_CONTRACT_PARAM_BOUNDS_INVALID: ${at} min ${p.min} > max ${p.max}`)
92
+ }
93
+ })
94
+ })
95
+ }
96
+
97
+ /**
98
+ * Validate a parsed mdk-contract.json against the vendored schema, plus the
99
+ * semantic rules the schema cannot express: unique telemetry/command names,
100
+ * number params with coherent min/max, and (when opts.dir names the plugin
101
+ * directory) every handler file present on disk.
102
+ *
103
+ * validateContract(contract, { dir?, semantic = true }) -> { ok, errors }
104
+ * where errors is a list of ERR_CONTRACT_* strings. Pass semantic: false for
105
+ * schema-only conformance (the catalogue generator's mode).
106
+ */
107
+ function validateContract (contract, opts = {}) {
108
+ const errors = []
109
+ _validateNode(_loadSchema(), contract, '', errors)
110
+ if (opts.semantic !== false) _semanticChecks(contract, opts.dir, errors)
111
+ return { ok: errors.length === 0, errors }
112
+ }
113
+
114
+ module.exports = { validateContract, SCHEMA_PATH }
@@ -0,0 +1,107 @@
1
+ 'use strict'
2
+
3
+ const path = require('path')
4
+ const debug = require('debug')('mdk:worker:instance-loader')
5
+ const { createModuleContext } = require('./module-context')
6
+
7
+ const SECTIONS = ['telemetry', 'commands']
8
+
9
+ // Builtin telemetry entries synthesized into every instance rather than
10
+ // loaded from a contract-declared handler file. Registered only when the
11
+ // plugin's own contract did not already declare a channel of the same name
12
+ // (see createInstance below) — a real plugin handler always wins. Each
13
+ // closes over this instance's own frozen `device`, so calling it is genuine
14
+ // per-device dispatch (`entry.instance.telemetry.get('health')(...)`), not a
15
+ // runtime-level shortcut: it proves the addressed device's own instance
16
+ // answered, the same way any real handler would.
17
+ //
18
+ // The reply is deliberately limited to identifiers: `env` (secrets resolved
19
+ // from mdk.yaml), `opts` (device credentials) and `config` must never leave
20
+ // the process through a telemetry channel any caller can pull.
21
+ const BUILTIN_TELEMETRY = {
22
+ health: (device) => () => ({
23
+ status: 'OK',
24
+ id: device.id,
25
+ workerId: device.workerId
26
+ })
27
+ }
28
+
29
+ /**
30
+ * Instantiate a directory-loaded Worker Plugin for one device.
31
+ *
32
+ * The plugin's own files get a private module registry (see module-context.js),
33
+ * so everything they do at load time (construct a client, open a store) happens
34
+ * once per device and their module-level state is that device's.
35
+ *
36
+ * createInstance({ dir, entries, device }) -> { telemetry: Map, commands: Map }
37
+ * of plain (params) functions. `entries` is loadContract().entries; `device` is
38
+ * what the plugin sees as '@tetherto/mdk-worker/device'. Per handlers HLD §3
39
+ * this aborts rather than degrades: a handler module that is missing, throws at
40
+ * load or does not export a function fails here, naming the device.
41
+ *
42
+ * `instance.telemetry` also always carries the builtin entries in
43
+ * BUILTIN_TELEMETRY (e.g. `health`) unless the contract declared a real
44
+ * channel of the same name — see BUILTIN_TELEMETRY above.
45
+ */
46
+ function createInstance (opts) {
47
+ const { dir, entries, device } = opts || {}
48
+ if (typeof dir !== 'string' || !dir) throw new Error('ERR_INSTANCE_DIR_REQUIRED')
49
+ if (!entries || typeof entries !== 'object') throw new Error('ERR_INSTANCE_ENTRIES_REQUIRED')
50
+ if (!device || typeof device !== 'object') throw new Error('ERR_INSTANCE_DEVICE_REQUIRED')
51
+ if (typeof device.id !== 'string' || !device.id) throw new Error('ERR_INSTANCE_DEVICE_ID_REQUIRED')
52
+
53
+ // WorkerRuntimeV2 always sets device.logger (a debug() instance); a device
54
+ // built by hand (contract-level tests, say) may omit it, so default to a
55
+ // no-op rather than let a handler's unconditional logger(...) call throw.
56
+ const ambientDevice = device.logger ? device : { ...device, logger: _noopLogger }
57
+
58
+ const context = createModuleContext({
59
+ dir,
60
+ ambient: { '@tetherto/mdk-worker/device': ambientDevice },
61
+ label: `[mdk-instance:${device.id}]`
62
+ })
63
+
64
+ const instance = { telemetry: new Map(), commands: new Map() }
65
+ for (const section of SECTIONS) {
66
+ for (const [name, handler] of _pairs(entries[section])) {
67
+ const where = `${device.id}: ${section}.${name}: ${handler}`
68
+ const request = path.resolve(context.root, handler)
69
+
70
+ let file
71
+ try {
72
+ file = context.resolve(request)
73
+ } catch (err) {
74
+ throw new Error(`ERR_INSTANCE_HANDLER_NOT_FOUND: ${where}`)
75
+ }
76
+
77
+ let fn
78
+ try {
79
+ fn = context.load(file)
80
+ } catch (err) {
81
+ throw new Error(`ERR_INSTANCE_HANDLER_LOAD_FAILED: ${where}: ${err.message}`, { cause: err })
82
+ }
83
+ if (typeof fn !== 'function') throw new Error(`ERR_INSTANCE_HANDLER_NOT_FUNCTION: ${where}`)
84
+
85
+ instance[section].set(name, fn)
86
+ }
87
+ }
88
+
89
+ for (const [name, build] of Object.entries(BUILTIN_TELEMETRY)) {
90
+ if (instance.telemetry.has(name)) continue
91
+ instance.telemetry.set(name, build(device))
92
+ }
93
+
94
+ debug('instance ready for %s (%d private modules)', device.id, context.size)
95
+ return instance
96
+ }
97
+
98
+ // loadContract() hands over Maps; plain objects are accepted so a test can
99
+ // build a section by hand.
100
+ function _pairs (section) {
101
+ if (!section) return []
102
+ return section instanceof Map ? section : Object.entries(section)
103
+ }
104
+
105
+ function _noopLogger () {}
106
+
107
+ module.exports = { createInstance }
@@ -0,0 +1,104 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const Module = require('module')
6
+
7
+ const PRIVATE_EXTENSIONS = new Set(['.js', '.cjs'])
8
+
9
+ /**
10
+ * Private module registry for one plugin load — the interception mechanism
11
+ * behind the ambient context modules ('@tetherto/mdk-worker/device' for worker
12
+ * instances, and the gateway/MCP equivalents).
13
+ *
14
+ * Files under `dir` get their own registry, so module-level state (a client
15
+ * constructed at load time, say) belongs to this context alone. `node_modules`
16
+ * and `node:` builtins keep resolving through the shared cache — `debug` and
17
+ * `node:sqlite` are process singletons, not per-context copies.
18
+ *
19
+ * Isolation rests on Node's Module internals (the pattern proxyquire and
20
+ * friends use): a Module per in-package file, compiled by hand, with `require`
21
+ * routed back here so in-package requests stay private.
22
+ *
23
+ * createModuleContext({ dir, ambient, label }) -> { root, size, resolve, load }.
24
+ * `ambient` maps specifiers to values; both the bare and `.js` spellings are
25
+ * intercepted before resolution, so a plugin does not need the host package
26
+ * installed to require its context. `label` names the synthetic entry parent
27
+ * in Node's require stack when a resolution fails.
28
+ */
29
+ function createModuleContext (opts) {
30
+ const { dir, ambient, label } = opts || {}
31
+ if (typeof dir !== 'string' || !dir) throw new Error('ERR_MODULE_CONTEXT_DIR_REQUIRED')
32
+
33
+ const requests = new Map()
34
+ for (const [specifier, value] of Object.entries(ambient || {})) {
35
+ requests.set(specifier, value)
36
+ requests.set(`${specifier}.js`, value)
37
+ }
38
+
39
+ // Realpath because Node resolves modules through symlinks: a package reached
40
+ // via a workspace link would otherwise look out-of-package and lose isolation.
41
+ const root = _realpath(path.resolve(dir))
42
+ const registry = new Map()
43
+
44
+ const isPrivate = (file) => {
45
+ if (!PRIVATE_EXTENSIONS.has(path.extname(file))) return false
46
+ const rel = path.relative(root, file)
47
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false
48
+ return !rel.split(path.sep).includes('node_modules')
49
+ }
50
+
51
+ const requireFrom = (parent, request) => {
52
+ if (requests.has(request)) return requests.get(request)
53
+ if (Module.isBuiltin(request)) return Module._load(request, parent, false)
54
+
55
+ const file = Module._resolveFilename(request, parent, false)
56
+ if (!isPrivate(file)) return Module._load(request, parent, false)
57
+ return loadPrivate(file, parent)
58
+ }
59
+
60
+ const loadPrivate = (file, parent) => {
61
+ const cached = registry.get(file)
62
+ if (cached) return cached.exports
63
+
64
+ const mod = new Module(file, parent)
65
+ mod.filename = file
66
+ mod.paths = Module._nodeModulePaths(path.dirname(file))
67
+ mod.require = (request) => requireFrom(mod, request)
68
+ // Registered before compiling so a require cycle inside the package sees a
69
+ // partial exports object instead of recursing, as the shared loader does.
70
+ registry.set(file, mod)
71
+ try {
72
+ mod._compile(fs.readFileSync(file, 'utf8'), file)
73
+ } catch (err) {
74
+ registry.delete(file)
75
+ throw err
76
+ }
77
+ mod.loaded = true
78
+ return mod.exports
79
+ }
80
+
81
+ // Synthetic parent for the context's entry files: it carries the package's
82
+ // node_modules paths, and names the context in Node's require stack when a
83
+ // resolution fails.
84
+ const entryParent = new Module(path.join(root, label || '[mdk-module-context]'), null)
85
+ entryParent.filename = entryParent.id
86
+ entryParent.paths = Module._nodeModulePaths(root)
87
+
88
+ return {
89
+ root,
90
+ get size () { return registry.size },
91
+ resolve: (request) => Module._resolveFilename(request, entryParent, false),
92
+ load: (request) => requireFrom(entryParent, request)
93
+ }
94
+ }
95
+
96
+ function _realpath (dir) {
97
+ try {
98
+ return fs.realpathSync(dir)
99
+ } catch {
100
+ return dir
101
+ }
102
+ }
103
+
104
+ module.exports = { createModuleContext }