@tetherto/mdk-gateway 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,175 @@
1
+ 'use strict'
2
+
3
+ const crypto = require('crypto')
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+ const async = require('async')
7
+ const TetherWrkBase = require('@tetherto/tether-wrk-base/workers/base.wrk.tether')
8
+ const createLogger = require('debug')
9
+ const debug = createLogger('store:aggr')
10
+ const { createRuntimeClient, getOrCreateSeed, resolvePluginDir } = require('@tetherto/mdk-app-runtime')
11
+ const { loadPlugin } = require('@tetherto/mdk-app-runtime/lib/plugin-loader')
12
+ const { buildFastifyRoutes, errorResponse, resolveAppTimeout } = require('./lib/plugin-adapter')
13
+ const { fastifyLoggerOptions, gatewayLogger } = require('./lib/logger')
14
+ const { resolveSecurityOptions, buildHostAllowlist, createHostCheck } = require('./lib/security')
15
+ const { resolveStoreDir } = require('./lib/utils')
16
+
17
+ // Publishes the key app runtimes allow-list. The directory is private to this
18
+ // user and the file is not group/other-writable (runtimes refuse one that is),
19
+ // and it is replaced atomically so a runtime never reads half a key.
20
+ function writeGatewayKeyFile (file, key) {
21
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
22
+ const tmp = `${file}.${crypto.randomBytes(8).toString('hex')}.tmp`
23
+ try {
24
+ fs.writeFileSync(tmp, key, { mode: 0o644, flag: 'wx' })
25
+ fs.chmodSync(tmp, 0o644) // the umask may have narrowed it; never widened past 0o644
26
+ fs.renameSync(tmp, file)
27
+ } catch (err) {
28
+ try { fs.unlinkSync(tmp) } catch {}
29
+ throw err
30
+ }
31
+ }
32
+
33
+ class WrkServerHttp extends TetherWrkBase {
34
+ constructor (conf, ctx) {
35
+ super(conf, ctx)
36
+
37
+ if (!ctx.port) {
38
+ throw new Error('ERR_HTTP_PORT_INVALID')
39
+ }
40
+
41
+ this.storeDir = 'http'
42
+ this.prefix = `${this.wtype}-${ctx.port}`
43
+ this.queuedRequests = new Map()
44
+
45
+ this.init()
46
+ this.start()
47
+ }
48
+
49
+ init () {
50
+ super.init()
51
+
52
+ const storeFac = (this.conf.init?.facilities || []).find(([, mod, name]) => mod === '@tetherto/hp-svc-facs-store' && name === 's0')
53
+ if (storeFac) storeFac[4] = { ...storeFac[4], storeDir: resolveStoreDir(storeFac[4].storeDir, this.ctx) }
54
+
55
+ // Validated up front so a bad option fails the boot, not the first request.
56
+ this._security = resolveSecurityOptions(this.conf, this.ctx)
57
+ resolveAppTimeout(this.conf)
58
+
59
+ this.setInitFacs([
60
+ ['fac', '@bitfinex/bfx-facs-lru', '10s', '10s', { max: 10000, maxAge: 10000 }],
61
+ ['fac', '@bitfinex/bfx-facs-lru', '15s', '15s', { max: 10000, maxAge: 15000 }],
62
+ ['fac', '@bitfinex/bfx-facs-lru', '30s', '30s', { max: 10000, maxAge: 30000 }],
63
+ ['fac', '@bitfinex/bfx-facs-lru', '1m', '1m', { max: 10000, maxAge: 60000 }],
64
+ ['fac', '@bitfinex/bfx-facs-lru', '15m', '15m', { max: 10000, maxAge: 60000 * 15 }],
65
+ ['fac', '@tetherto/svc-facs-httpd', 'h0', 'h0', {
66
+ // Static serving is opt-in: only when common.json sets staticRootPath.
67
+ staticRootPath: this.conf.staticRootPath,
68
+ staticOn404File: 'index.html',
69
+ port: this.ctx.port,
70
+ logger: fastifyLoggerOptions(this.conf),
71
+ // No GET /echo: the gateway serves only what its plugins and caller declare.
72
+ addDefaultRoutes: false,
73
+ trustProxy: this._security.trustProxy,
74
+ bodyLimit: this._security.bodyLimit
75
+ }, 0]
76
+ ])
77
+
78
+ // ctx.apps (`{ name, appDir, keyFile, mount? }`) is the complete inventory
79
+ // of a gateway's app routes. Each app runs in its own runtime process: only
80
+ // its route table is read here, and every request is forwarded over HRPC.
81
+ this._apps = (this.ctx.apps || []).map(app => ({
82
+ ...app,
83
+ routes: loadPlugin(resolvePluginDir(app.appDir), undefined, { handlers: false }).routes
84
+ }))
85
+ this._runtimeClient = null
86
+ }
87
+
88
+ debugGeneric (msg) {
89
+ debug(`[HTTP/${this.ctx.shard}]`, ...arguments)
90
+ }
91
+
92
+ _start (cb) {
93
+ async.series([
94
+ next => { super._start(next) },
95
+ async () => {
96
+ await this.net_r0.startRpcServer()
97
+
98
+ const httpd = this.httpd_h0
99
+ const security = this._security
100
+ const log = gatewayLogger(this.conf)
101
+
102
+ // Registered before any route so their onRoute/onRequest hooks cover
103
+ // every plugin and additional route.
104
+ if (security.helmet) httpd.addPlugin([require('@fastify/helmet'), security.helmet])
105
+ else log.warn('security headers (helmet) are disabled')
106
+ if (security.rateLimit) httpd.addPlugin([require('@fastify/rate-limit'), security.rateLimit])
107
+ else log.warn('rate limiting is disabled')
108
+
109
+ const hostAllowlist = buildHostAllowlist(security.allowedHosts, {
110
+ port: String(this.ctx.port),
111
+ host: httpd.opts.host || httpd.conf?.host
112
+ })
113
+ if (hostAllowlist) httpd.addHook('onRequest', createHostCheck(hostAllowlist))
114
+ else log.warn('allowedHosts contains "*": the Host check is off, so DNS rebinding is not blocked')
115
+
116
+ this.ctx.additionalRoutes?.forEach(r => {
117
+ httpd.addRoute(r)
118
+ })
119
+
120
+ if (this._apps.length || this.ctx.gatewayKeyFile) {
121
+ // One stable key, across restarts, for every app runtime to allow-list.
122
+ const seed = getOrCreateSeed(path.join(this.ctx.root, 'identity', 'seedRuntimeClient'))
123
+ this._runtimeClient = createRuntimeClient({ seed, bootstrap: this.ctx.kernelBootstrap })
124
+ await this._runtimeClient.connect()
125
+ }
126
+
127
+ for (const app of this._apps) {
128
+ buildFastifyRoutes(app, this._runtimeClient, this, { authVerifier: security.authVerifier }).forEach(r => {
129
+ httpd.addRoute(r)
130
+ })
131
+ }
132
+
133
+ if (!security.authVerifier) {
134
+ const closed = this._apps.flatMap(a => a.routes.filter(r => r.auth === true).map(r => r.id))
135
+ if (closed.length) log.warn(`no authVerifier configured: ${closed.length} auth: true route(s) answer 401 (${closed.join(', ')})`)
136
+ }
137
+
138
+ httpd.addErrorHandler((err, request, reply) => {
139
+ const body = errorResponse(err, request.log)
140
+ reply.status(body.statusCode).send(body)
141
+ })
142
+
143
+ await httpd.startServer()
144
+
145
+ // The net facility's DHT client key; startGateway publishes it to the
146
+ // trusted-keys dir as gateway.pub. App routes use appClientKey below.
147
+ this.status.rpcClientKey = this.net_r0.dht.defaultKeyPair.publicKey.toString('hex')
148
+
149
+ // The key app runtimes allow-list (their allowedKeyFiles), published
150
+ // once the listener is up.
151
+ if (this._runtimeClient) {
152
+ this.status.appClientKey = this._runtimeClient.publicKey.toString('hex')
153
+ if (this.ctx.gatewayKeyFile) {
154
+ writeGatewayKeyFile(this.ctx.gatewayKeyFile, this.status.appClientKey)
155
+ }
156
+ }
157
+ this.saveStatus()
158
+ }
159
+ ], cb)
160
+ }
161
+
162
+ _stop (cb) {
163
+ async.series([
164
+ next => { super._stop(next) },
165
+ async () => {
166
+ const client = this._runtimeClient
167
+ this._runtimeClient = null
168
+ if (client) await client.close()
169
+ }
170
+ ], cb)
171
+ }
172
+ }
173
+
174
+ module.exports = WrkServerHttp
175
+ module.exports.writeGatewayKeyFile = writeGatewayKeyFile
@@ -0,0 +1,7 @@
1
+ 'use strict'
2
+
3
+ const RPC_TIMEOUT = 15000
4
+
5
+ module.exports = {
6
+ RPC_TIMEOUT
7
+ }
@@ -0,0 +1,70 @@
1
+ 'use strict'
2
+
3
+ const pino = require('pino')
4
+
5
+ // Everything the gateway prints — Fastify's request lines and every plugin's
6
+ // logger — goes through one destination, so a plugin failure is formatted and
7
+ // ordered like the rest of the log instead of arriving as a bare console line.
8
+
9
+ let destination = null
10
+
11
+ function _destination () {
12
+ if (destination) return destination
13
+
14
+ // Human-readable in a terminal; NDJSON when stdout is piped, which is what
15
+ // Docker, systemd and log shippers expect to parse.
16
+ destination = process.stdout.isTTY
17
+ ? require('pino-pretty')({
18
+ colorize: true,
19
+ translateTime: 'HH:MM:ss',
20
+ ignore: 'pid,hostname',
21
+ singleLine: true
22
+ })
23
+ : pino.destination(1)
24
+
25
+ return destination
26
+ }
27
+
28
+ // `debug: 0` in the gateway's common.json is the default, so debug lines are
29
+ // opt-in the same way they are for the other facilities.
30
+ function _level (conf) {
31
+ return conf?.debug ? 'debug' : 'info'
32
+ }
33
+
34
+ // Fastify announces every address it binds, and logs an "incoming request" line
35
+ // paired with each "request completed". The CLI already prints the gateway URL,
36
+ // and one line per request is what an operator can actually read.
37
+ const hooks = {
38
+ logMethod (args, method) {
39
+ const msg = typeof args[0] === 'string' ? args[0] : typeof args[1] === 'string' ? args[1] : ''
40
+ if (msg.startsWith('Server listening at') || msg === 'incoming request') return
41
+ return method.apply(this, args)
42
+ }
43
+ }
44
+
45
+ function fastifyLoggerOptions (conf) {
46
+ return { level: _level(conf), stream: _destination(), hooks }
47
+ }
48
+
49
+ let root = null
50
+
51
+ /**
52
+ * The logger a plugin sees as `logger` on its ambient context: pino's
53
+ * error/warn/info/debug/trace/fatal plus child(), tagged with the plugin name so
54
+ * a line names who wrote it.
55
+ */
56
+ function pluginLogger (name, conf) {
57
+ if (!root) root = pino({ level: _level(conf) }, _destination())
58
+ return root.child({ name })
59
+ }
60
+
61
+ /**
62
+ * The same logger for the few things the gateway itself has to tell an operator
63
+ * — a plugin's `onReady` callback throwing, say. Tagged like a plugin's so the
64
+ * line names its source, and on the one stream, so it is ordered with the rest.
65
+ */
66
+ function gatewayLogger (conf) {
67
+ return pluginLogger('gateway', conf)
68
+ }
69
+
70
+ module.exports = { fastifyLoggerOptions, pluginLogger, gatewayLogger }
@@ -0,0 +1,189 @@
1
+ 'use strict'
2
+
3
+ const crypto = require('crypto')
4
+ const fs = require('fs')
5
+ const { STATUS_CODES } = require('http')
6
+ const { pipeToResponse } = require('@tetherto/mdk-app-runtime')
7
+ const { cachedRoute } = require('./server/lib/cachedRoute')
8
+ const { send200 } = require('./server/lib/send200')
9
+ const { createAuthHook, canOverwriteCache } = require('./security')
10
+
11
+ // Only ERR_* codes and Fastify's own 4xx errors (validation, content type,
12
+ // body size) describe the request; any other message can carry internals, so
13
+ // the client gets the status text and the log gets the error.
14
+ function errorResponse (err, log) {
15
+ const statusCode = Number.isInteger(err.statusCode) && err.statusCode >= 400 ? err.statusCode : 400
16
+ const error = STATUS_CODES[statusCode] || 'Bad Request'
17
+ const isSafe = /^ERR_/.test(err.message) || (/^FST_/.test(err.code) && statusCode < 500)
18
+ if (!isSafe && !err.remote) log?.error(err)
19
+ return { statusCode, error, message: isSafe ? err.message : error }
20
+ }
21
+
22
+ function _extractCacheKeyParts (fields, req) {
23
+ return fields.map(field => {
24
+ const parts = field.split('.')
25
+ return parts.reduce((obj, key) => (obj != null ? obj[key] : undefined), req)
26
+ })
27
+ }
28
+
29
+ // JSON with object keys sorted, so the same identity always hashes the same
30
+ // whatever order the verifier built it in.
31
+ function _stableStringify (value) {
32
+ if (Array.isArray(value)) return `[${value.map(v => _stableStringify(v ?? null)).join(',')}]`
33
+ if (value && typeof value === 'object' && typeof value.toJSON !== 'function') {
34
+ return `{${Object.keys(value).sort().filter(k => value[k] !== undefined).map(k => `${JSON.stringify(k)}:${_stableStringify(value[k])}`).join(',')}}`
35
+ }
36
+ return JSON.stringify(value) ?? 'null'
37
+ }
38
+
39
+ // A cache key component for the verified caller of an `auth: true` route, so
40
+ // two identities never share a cached response or an in-flight call.
41
+ function _identityCacheKey (req) {
42
+ return 'id:' + crypto.createHash('sha256').update(_stableStringify(req._info || {})).digest('hex')
43
+ }
44
+
45
+ // Bounds a unary call, and the wait for a stream route's response head, so a
46
+ // hung controller cannot hold the HTTP request open forever (common.json `appTimeoutMs`).
47
+ const APP_TIMEOUT_MS = 60000
48
+
49
+ // A malformed value must fail the boot: one that is not a positive number would
50
+ // silently drop the bound. Digit strings are accepted, as for bodyLimit.
51
+ function resolveAppTimeout (conf = {}) {
52
+ let ms = conf.appTimeoutMs
53
+ if (ms === undefined || ms === null) return APP_TIMEOUT_MS
54
+ if (typeof ms === 'string' && /^\d+$/.test(ms)) ms = Number(ms)
55
+ if (!Number.isInteger(ms) || ms <= 0) {
56
+ throw new Error('ERR_GATEWAY_APP_TIMEOUT_INVALID: appTimeoutMs must be a positive integer (milliseconds)')
57
+ }
58
+ return ms
59
+ }
60
+
61
+ // Re-read on every request, so a runtime may start, or restart under a new
62
+ // key, after the Gateway. Until it has published a key its routes answer 503.
63
+ async function _runtimeKey (app) {
64
+ const key = await fs.promises.readFile(app.keyFile, 'utf8').then(s => s.trim(), () => '')
65
+ if (/^[0-9a-f]{64}$/i.test(key)) return key
66
+ const err = new Error(`ERR_APP_RUNTIME_UNAVAILABLE: ${app.name}`)
67
+ err.statusCode = 503
68
+ throw err
69
+ }
70
+
71
+ // The caller's credentials stay at the Gateway: the authVerifier already turned
72
+ // them into the identity, and app code must not be able to replay them.
73
+ const CREDENTIAL_HEADERS = ['authorization', 'proxy-authorization', 'cookie']
74
+
75
+ function _appRequest (req) {
76
+ const headers = { ...req.headers }
77
+ for (const name of CREDENTIAL_HEADERS) delete headers[name]
78
+ return { params: req.params, query: req.query, body: req.body, headers }
79
+ }
80
+
81
+ // Aborts when the client goes away before its response finished, so a request
82
+ // nobody waits for is not forwarded.
83
+ function _disconnectSignal (rep) {
84
+ const ac = new AbortController()
85
+ rep.raw.once('close', () => { if (!rep.raw.writableFinished) ac.abort() })
86
+ return ac.signal
87
+ }
88
+
89
+ // One call to the app's runtime: `method` is 'invoke' (unary) or 'stream'. The
90
+ // runtime takes `{ params, query, body, headers }` from `req`; the identity the
91
+ // authVerifier set (none on an open route) travels beside it.
92
+ async function _forward (client, app, route, method, req, timeoutMs, signal) {
93
+ const key = await _runtimeKey(app)
94
+ return client[method](key, route.id, _appRequest(req), { identity: req._info || {}, timeoutMs, signal })
95
+ }
96
+
97
+ // Mounts an app's route table (`app.routes`) under `app.mount` (default
98
+ // `/apps/<name>`) and forwards every request to the app's runtime over HRPC.
99
+ //
100
+ // opts.authVerifier: async (request, route) => identity | false. Every
101
+ // `auth: true` route gets a route-level preParsing hook that runs it: after the
102
+ // Host check and rate limit (onRequest, so failed attempts are throttled too),
103
+ // before body parsing, validation, the cache and the forward. With no
104
+ // verifier those routes answer 401. Routes without `auth: true` stay open.
105
+ // The verified identity reaches the app runtime as the call's `identity`.
106
+ function buildFastifyRoutes (app, client, ctx, opts = {}) {
107
+ const mount = app.mount || `/apps/${app.name}`
108
+ const timeoutMs = resolveAppTimeout(ctx.conf)
109
+ const prefix = mount === '/' ? '' : mount
110
+
111
+ return app.routes.map(route => {
112
+ const fastifyRoute = {
113
+ method: route.method,
114
+ // A route at "/" answers at the mount itself (/apps/<name>), not only with a trailing slash.
115
+ url: prefix && route.path === '/' ? prefix : prefix + route.path
116
+ }
117
+
118
+ if (route.schema) {
119
+ fastifyRoute.schema = route.schema
120
+ }
121
+
122
+ if (route.auth === true) {
123
+ fastifyRoute.preParsing = createAuthHook(opts.authVerifier || null, Object.freeze({
124
+ id: route.id,
125
+ method: route.method,
126
+ path: route.path,
127
+ app: app.name,
128
+ permissions: Array.isArray(route.permissions) ? Object.freeze([...route.permissions]) : undefined
129
+ }))
130
+ }
131
+
132
+ // A stream route owns the raw ServerResponse. The reply is hijacked so
133
+ // Fastify never serializes; the boundary catch is load-bearing — a
134
+ // rejection after hijack() is otherwise swallowed (reply.sent is true)
135
+ // and the socket stays open forever.
136
+ if (route.stream === true) {
137
+ fastifyRoute.handler = async (req, rep) => {
138
+ const signal = _disconnectSignal(rep)
139
+ rep.hijack()
140
+ const raw = rep.raw
141
+ try {
142
+ await pipeToResponse(await _forward(client, app, route, 'stream', req, timeoutMs, signal), raw)
143
+ } catch (err) {
144
+ if (!raw.headersSent) {
145
+ const body = errorResponse(err, req.log)
146
+ raw.writeHead(body.statusCode, { 'content-type': 'application/json' })
147
+ raw.end(JSON.stringify(body))
148
+ } else if (!raw.writableEnded) {
149
+ raw.end()
150
+ }
151
+ }
152
+ }
153
+ return fastifyRoute
154
+ }
155
+
156
+ const cacheFields = route.cache
157
+
158
+ fastifyRoute.handler = async (req, rep) => {
159
+ // A cached call is shared by every request waiting on its key, so one
160
+ // client leaving must not abort it.
161
+ const signal = Array.isArray(cacheFields) ? undefined : _disconnectSignal(rep)
162
+ const forward = () => _forward(client, app, route, 'invoke', req, timeoutMs, signal)
163
+
164
+ let result
165
+ if (cacheFields && Array.isArray(cacheFields)) {
166
+ const keyParts = [app.name, route.id, ..._extractCacheKeyParts(cacheFields, req)]
167
+ // Per-caller on an `auth: true` route: the response may depend on who asked.
168
+ if (route.auth === true) keyParts.push(_identityCacheKey(req))
169
+ result = await cachedRoute(
170
+ ctx,
171
+ keyParts,
172
+ route.path,
173
+ forward,
174
+ // A forced refresh is honoured only for a caller the authVerifier
175
+ // vouched for and granted OVERWRITE_CACHE_PERM; a bare header is not proof.
176
+ String(req.query?.overwriteCache) === 'true' && canOverwriteCache(req)
177
+ )
178
+ } else {
179
+ result = await forward()
180
+ }
181
+
182
+ return send200(rep, result)
183
+ }
184
+
185
+ return fastifyRoute
186
+ })
187
+ }
188
+
189
+ module.exports = { buildFastifyRoutes, errorResponse, resolveAppTimeout, runtimeKey: _runtimeKey }
@@ -0,0 +1,67 @@
1
+ 'use strict'
2
+
3
+ const path = require('path')
4
+ const { pluginLogger } = require('./logger')
5
+
6
+ // Builds what a plugin sees as require('@tetherto/mdk-gateway/plugin') — the
7
+ // contract between a plugin and the gateway. There are no phases and no worker
8
+ // internals to learn; the one moment in the gateway's life a plugin can name is
9
+ // onReady, and only because a plugin cannot observe it any other way.
10
+ //
11
+ // The full surface:
12
+ // gw.config gateway conf ⊕ the plugin's own config block
13
+ // (spec.gateway.plugins[].config wins key-by-key)
14
+ // gw.logger the gateway's logger, tagged with this plugin's name:
15
+ // error/warn/info/debug/trace/fatal + child()
16
+ // gw.onReady(fn) fn runs once this gateway is serving
17
+ //
18
+ // Everything else a plugin needs it owns: kernel and worker data — live or
19
+ // historical — go through the plugin's own mdk client, built from
20
+ // config.kernelKey/kernelKeyPair/kernelBootstrap (see the telemetry plugin's
21
+ // lib/client.js). kernelKeyPair is the persistent keypair status.rpcClientKey
22
+ // is read from, so plugin clients dial the Kernel with the allowlisted key.
23
+
24
+ // What a plugin's log lines are tagged with. The manifest name is the plugin's
25
+ // own identity; the scope is dropped because the tag prefixes every line.
26
+ function _pluginName (pluginDir) {
27
+ try {
28
+ const { name } = require(path.join(pluginDir, 'mdk-plugin.json'))
29
+ if (typeof name === 'string' && name) return name.replace(/^@[^/]+\//, '')
30
+ } catch {}
31
+ return path.basename(pluginDir)
32
+ }
33
+
34
+ function buildPluginContext (wrk, pluginDir, pluginConf) {
35
+ const kernelKey = Buffer.isBuffer(wrk.ctx.kernelKey) ? wrk.ctx.kernelKey.toString('hex') : wrk.ctx.kernelKey
36
+
37
+ const context = Object.freeze({
38
+ // The plugin's own config block (spec.gateway.plugins[].config) wins over
39
+ // gateway-wide conf, so a plugin's settings live with the plugin.
40
+ config: Object.freeze({
41
+ kernelKey: kernelKey || null,
42
+ // resolved lazily: net_r0.dht does not exist yet when plugins load; a
43
+ // host without one (the MCP server's shim) dials with an ephemeral key
44
+ kernelKeyPair: () => wrk.net_r0?.dht?.defaultKeyPair,
45
+ kernelBootstrap: wrk.ctx.kernelBootstrap || null,
46
+ ...wrk.conf,
47
+ ...pluginConf
48
+ }),
49
+
50
+ // A plugin that prints with console has no level, no timestamp and no name;
51
+ // going through the gateway's logger puts its lines in the same stream and
52
+ // format as the requests it serves.
53
+ logger: pluginLogger(_pluginName(pluginDir), wrk.conf),
54
+
55
+ // Plugin modules are loaded from the worker's init(), which is strictly
56
+ // before _start() opens the listener, so a plugin doing anything at load
57
+ // time does it to a gateway that answers nothing yet — and a plugin whose
58
+ // work is a call to this same gateway fails for no reason but the order.
59
+ // onReady is that missing moment: fn runs once everything this gateway
60
+ // serves is up, and runs straight away for anything registered after that.
61
+ onReady: (fn) => wrk.onGatewayReady(fn)
62
+ })
63
+
64
+ return { context }
65
+ }
66
+
67
+ module.exports = { buildPluginContext }
@@ -0,0 +1,6 @@
1
+ 'use strict'
2
+
3
+ // The loader lives in @tetherto/mdk-app-runtime, which loads and runs app
4
+ // handlers; the Gateway only reads route tables with it. This path stays as a
5
+ // re-export for existing callers.
6
+ module.exports = require('@tetherto/mdk-app-runtime/lib/plugin-loader')