toolfunnel 0.3.0

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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/bin/toolfunnel.js +112 -0
  4. package/docs/admin-ui.md +404 -0
  5. package/docs/configuration.md +402 -0
  6. package/docs/hooks-and-gating.md +428 -0
  7. package/docs/logging.md +223 -0
  8. package/docs/management.md +429 -0
  9. package/docs/oauth.md +103 -0
  10. package/docs/setup.md +352 -0
  11. package/docs/tools-and-upstreams.md +469 -0
  12. package/hooks/hooks.manifest.json +27 -0
  13. package/hooks/scripts/.gitkeep +3 -0
  14. package/hooks/scripts/example-audit.js +21 -0
  15. package/hooks/scripts/example-deny-dangerous.js +31 -0
  16. package/mcp/expose.example.json +30 -0
  17. package/mcp/servers/mock-upstream/server.js +170 -0
  18. package/package.json +63 -0
  19. package/src/auth/config.js +172 -0
  20. package/src/auth/install.js +181 -0
  21. package/src/auth/resource-server.js +319 -0
  22. package/src/core/events.js +105 -0
  23. package/src/core/hook-engine.js +275 -0
  24. package/src/core/hook-loader.js +791 -0
  25. package/src/core/hook-runner.js +346 -0
  26. package/src/core/logger.js +169 -0
  27. package/src/core/matcher.js +51 -0
  28. package/src/core/metrics.js +75 -0
  29. package/src/core/tool-registry.js +180 -0
  30. package/src/extend/add-hook.md +142 -0
  31. package/src/extend/add-mcp.md +121 -0
  32. package/src/extend/create-tool.md +132 -0
  33. package/src/extend/howto.js +98 -0
  34. package/src/extend/package.md +154 -0
  35. package/src/mcp/aggregator.js +802 -0
  36. package/src/mcp/expose-store.js +430 -0
  37. package/src/mcp/gated-run.js +194 -0
  38. package/src/mcp/http-transport.js +962 -0
  39. package/src/mcp/mcp-client.js +524 -0
  40. package/src/mcp/protocol.js +466 -0
  41. package/src/mcp/server.js +1240 -0
  42. package/src/packages/packager.js +713 -0
  43. package/src/tools/drift.js +138 -0
  44. package/src/tools/registry.js +468 -0
  45. package/src/tools/tool-state.js +104 -0
  46. package/src/ui/public/app.js +1055 -0
  47. package/src/ui/public/index.html +249 -0
  48. package/src/ui/public/logo.jpg +0 -0
  49. package/src/ui/public/styles.css +535 -0
  50. package/src/ui/server.js +1144 -0
  51. package/tools/scripts/base64.js +139 -0
  52. package/tools/scripts/danger.js +61 -0
  53. package/tools/scripts/echo.js +39 -0
  54. package/tools/scripts/hash.js +101 -0
  55. package/tools/scripts/json.js +109 -0
  56. package/tools/scripts/text-stats.js +131 -0
  57. package/tools/scripts/tf-hook-add.js +152 -0
  58. package/tools/scripts/tf-hook-set.js +113 -0
  59. package/tools/scripts/tf-list.js +195 -0
  60. package/tools/scripts/tf-log.js +83 -0
  61. package/tools/scripts/tf-mcp-add.js +119 -0
  62. package/tools/scripts/tf-mcp-set.js +104 -0
  63. package/tools/scripts/tf-tool-add.js +129 -0
  64. package/tools/scripts/tf-tool-set.js +148 -0
  65. package/tools/scripts/uuid.js +102 -0
  66. package/tools/tools.register.json +171 -0
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * mcp/servers/mock-upstream/server.js
6
+ *
7
+ * A tiny, ZERO-DEPENDENCY stdio MCP server bundled inside ToolFunnel's own `mcp/` tree so it
8
+ * travels with the install (the "toolbox" stays self-contained and portable). It exists to
9
+ * demonstrate ToolFunnel attaching and forwarding (proxying) an upstream MCP: attach it as an
10
+ * upstream, expose its tools, and watch ToolFunnel forward calls to it through the policy gate.
11
+ *
12
+ * Attach it with a RELATIVE arg path (the aggregator resolves it against the gateway root and
13
+ * the isolation guard requires it to stay inside the tree):
14
+ * upstream: { id: "mock", command: "node", args: ["mcp/servers/mock-upstream/server.js"] }
15
+ *
16
+ * Protocol: JSON-RPC 2.0 over stdio with NEWLINE-DELIMITED framing — one compact JSON object per
17
+ * line, "\n"-terminated, the MCP stdio transport every real SDK-based server speaks. We read lines
18
+ * on stdin and answer in kind on stdout. (This fixture used to parse LSP Content-Length framing,
19
+ * matching an old client bug; both were corrected together so the mock represents a REAL server.)
20
+ *
21
+ * Tools exposed (real upstream names):
22
+ * - ping -> "pong"
23
+ * - add { a, b } -> a + b
24
+ * - echo { text } -> text
25
+ *
26
+ * Replies to `initialize`, ignores `notifications/initialized`, answers `tools/list` and
27
+ * `tools/call`, never crashes on a bad frame, and exits cleanly on stdin EOF.
28
+ *
29
+ * Node built-ins only. CommonJS.
30
+ */
31
+
32
+ const PROTOCOL_VERSION = '2024-11-05';
33
+ const SERVER_INFO = { name: 'mock-upstream', version: '1.0.0' };
34
+
35
+ const TOOLS = [
36
+ {
37
+ name: 'ping',
38
+ description: 'Health check — returns the string "pong".',
39
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
40
+ },
41
+ {
42
+ name: 'add',
43
+ description: 'Add two numbers and return the sum.',
44
+ inputSchema: {
45
+ type: 'object',
46
+ properties: { a: { type: 'number' }, b: { type: 'number' } },
47
+ required: ['a', 'b'],
48
+ additionalProperties: false,
49
+ },
50
+ },
51
+ {
52
+ name: 'echo',
53
+ description: 'Echo back the supplied text.',
54
+ inputSchema: {
55
+ type: 'object',
56
+ properties: { text: { type: 'string' } },
57
+ required: ['text'],
58
+ additionalProperties: false,
59
+ },
60
+ },
61
+ {
62
+ name: 'crash',
63
+ description: 'Exit the process immediately (simulates an upstream crash — for reconnect testing).',
64
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
65
+ },
66
+ ];
67
+
68
+ /** Write one JSON-RPC message as a newline-delimited line — the MCP stdio framing. */
69
+ function writeMessage(msg) {
70
+ process.stdout.write(JSON.stringify(msg) + '\n');
71
+ }
72
+
73
+ /** Standard MCP tools/call result envelope. */
74
+ function textResult(text, isError) {
75
+ return { content: [{ type: 'text', text: String(text) }], isError: isError === true };
76
+ }
77
+
78
+ /** Dispatch one parsed JSON-RPC message. Notifications (no id) get no reply. */
79
+ function handleMessage(msg) {
80
+ if (!msg || typeof msg !== 'object') return;
81
+ const id = msg.id;
82
+ const method = msg.method;
83
+ const params = msg.params || {};
84
+
85
+ // Notifications carry no id and must never be answered.
86
+ if (id === undefined || id === null) return;
87
+
88
+ switch (method) {
89
+ case 'initialize':
90
+ return writeMessage({
91
+ jsonrpc: '2.0',
92
+ id,
93
+ result: {
94
+ protocolVersion: PROTOCOL_VERSION,
95
+ serverInfo: SERVER_INFO,
96
+ capabilities: { tools: {} },
97
+ },
98
+ });
99
+
100
+ case 'tools/list':
101
+ return writeMessage({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
102
+
103
+ case 'tools/call': {
104
+ const name = params.name;
105
+ const args = params.arguments || {};
106
+ try {
107
+ if (name === 'ping') {
108
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult('pong') });
109
+ }
110
+ if (name === 'add') {
111
+ const a = Number(args.a);
112
+ const b = Number(args.b);
113
+ if (!isFinite(a) || !isFinite(b)) {
114
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult('add requires numeric a and b', true) });
115
+ }
116
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult(String(a + b)) });
117
+ }
118
+ if (name === 'echo') {
119
+ const text = typeof args.text === 'string' ? args.text : JSON.stringify(args.text);
120
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult(text) });
121
+ }
122
+ if (name === 'crash') {
123
+ // Simulate an upstream crash: exit WITHOUT replying. The gateway's in-flight request
124
+ // rejects and its McpClient.onClose fires → the aggregator schedules a background
125
+ // reconnect. Exercised by reconnect.test.js.
126
+ process.exit(1);
127
+ }
128
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult('unknown tool: ' + name, true) });
129
+ } catch (err) {
130
+ return writeMessage({ jsonrpc: '2.0', id, result: textResult('error: ' + ((err && err.message) || err), true) });
131
+ }
132
+ }
133
+
134
+ default:
135
+ // Unknown method -> a well-formed JSON-RPC method-not-found error.
136
+ return writeMessage({
137
+ jsonrpc: '2.0',
138
+ id,
139
+ error: { code: -32601, message: 'Method not found: ' + method },
140
+ });
141
+ }
142
+ }
143
+
144
+ // ── Newline-delimited JSON reader over stdin (the MCP stdio framing) ─────────────────
145
+ let buffer = '';
146
+
147
+ process.stdin.on('data', (chunk) => {
148
+ buffer += chunk.toString('utf8');
149
+ // Drain every complete line currently buffered (one JSON-RPC message per line).
150
+ let nl;
151
+ while ((nl = buffer.indexOf('\n')) !== -1) {
152
+ const line = buffer.slice(0, nl).replace(/\r$/, '').trim();
153
+ buffer = buffer.slice(nl + 1);
154
+ if (line.length === 0) continue; // blank separator line — ignore
155
+ let msg;
156
+ try {
157
+ msg = JSON.parse(line);
158
+ } catch (_e) {
159
+ continue; // skip an unparseable line rather than crash
160
+ }
161
+ try {
162
+ handleMessage(msg);
163
+ } catch (_e) {
164
+ /* a handler must never take the server down */
165
+ }
166
+ }
167
+ });
168
+
169
+ process.stdin.on('end', () => process.exit(0));
170
+ process.stdin.resume();
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "toolfunnel",
3
+ "version": "0.3.0",
4
+ "description": "A zero-dependency MCP gateway: host your own tools, forward and curate tools from other MCP servers, expose them leanly to cut agent token cost, and gate every call through your own policy hooks before it runs.",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "mcp-gateway",
9
+ "mcp-proxy",
10
+ "mcp-server",
11
+ "ai",
12
+ "agents",
13
+ "llm",
14
+ "tools",
15
+ "claude",
16
+ "anthropic",
17
+ "aggregator",
18
+ "token-efficiency"
19
+ ],
20
+ "license": "MIT",
21
+ "type": "commonjs",
22
+ "main": "src/mcp/server.js",
23
+ "bin": {
24
+ "toolfunnel": "bin/toolfunnel.js"
25
+ },
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "scripts": {
30
+ "start": "node bin/toolfunnel.js",
31
+ "http": "node bin/toolfunnel.js --http",
32
+ "install-oauth": "node bin/toolfunnel.js install-oauth",
33
+ "test": "node test/run-all.js",
34
+ "test:integration": "node test/integration-real-mcp.test.js && node test/integration-http-client.test.js"
35
+ },
36
+ "dependencies": {},
37
+ "author": "rendeverance",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/Rendeverance/toolfunnel.git"
41
+ },
42
+ "homepage": "https://github.com/Rendeverance/toolfunnel#readme",
43
+ "bugs": {
44
+ "url": "https://github.com/Rendeverance/toolfunnel/issues"
45
+ },
46
+ "files": [
47
+ "bin/",
48
+ "src/",
49
+ "tools/tools.register.json",
50
+ "tools/scripts/",
51
+ "mcp/expose.example.json",
52
+ "mcp/servers/",
53
+ "hooks/hooks.manifest.json",
54
+ "hooks/scripts/",
55
+ "docs/",
56
+ "README.md",
57
+ "LICENSE"
58
+ ],
59
+ "devDependencies": {
60
+ "@modelcontextprotocol/sdk": "^1.0.0",
61
+ "jose": "^5.10.0"
62
+ }
63
+ }
@@ -0,0 +1,172 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * auth/config.js — the toggle + settings for OPTIONAL OAuth 2.1 resource-server validation.
5
+ *
6
+ * DEFAULT OFF (the gateway is loopback-only and unauthenticated until you turn this on). Nothing
7
+ * is read or required unless auth has been explicitly enabled via setConfig(). A MISSING config
8
+ * file means disabled — the safe default — so a fresh checkout authenticates nothing and pulls in
9
+ * no dependency.
10
+ *
11
+ * Design rules (mirrors src/core/logger.js — the proven config pattern):
12
+ * - Zero RUNTIME dependencies. Node built-ins only (fs, path). The jose library is pulled in ONLY
13
+ * by the validator (src/auth/resource-server.js), ONLY when auth is enabled, and is installed
14
+ * on demand (CLI `install-oauth` / the UI button). This module never touches jose.
15
+ * - Config is read FRESH on every call (no caching), so a toggle/edit takes effect for the very
16
+ * next request without a reconnect or restart — exactly like the logger + tool-state overlays.
17
+ * - Nothing here throws. A bad/unreadable/malformed config degrades to the safe default (disabled)
18
+ * so a misconfiguration can never crash the transport.
19
+ *
20
+ * Config file: <root>/auth/auth.config.json (NOT created until setConfig() writes it)
21
+ * {
22
+ * "enabled": false,
23
+ * "issuer": "https://auth.example.com", // the OAuth 2.1 / OIDC authorization server
24
+ * "audience": "https://gateway.example.com", // THIS gateway's resource URI — RFC 8707 audience
25
+ * // binding; the confused-deputy defence
26
+ * "jwksUri": "https://auth.example.com/.well-known/jwks.json", // explicit JWKS; else derived
27
+ * // from the issuer via OIDC discovery
28
+ * "algorithms": ["RS256", "ES256"], // the PINNED signature-algorithm allowlist
29
+ * "requiredScopes": [], // optional — every listed scope must be present
30
+ * "clockToleranceSec": 30 // exp/nbf skew tolerance (seconds)
31
+ * }
32
+ *
33
+ * CommonJS only.
34
+ */
35
+
36
+ const fs = require('node:fs');
37
+ const path = require('node:path');
38
+
39
+ /** Repo root: <root>/src/auth/config.js -> two dirs up. */
40
+ const ROOT = path.resolve(__dirname, '..', '..');
41
+
42
+ /** The toggle/config file. NOT created until setConfig() writes it. */
43
+ const CONFIG_PATH = path.join(ROOT, 'auth', 'auth.config.json');
44
+
45
+ /** Safe defaults — used whenever the config file is absent or unreadable. */
46
+ const DEFAULTS = Object.freeze({
47
+ enabled: false,
48
+ issuer: '',
49
+ audience: '',
50
+ jwksUri: '',
51
+ algorithms: ['RS256', 'ES256'],
52
+ requiredScopes: [],
53
+ clockToleranceSec: 30,
54
+ });
55
+
56
+ /** A string field, defaulting to '' when not a non-empty string. */
57
+ function str(v, dflt) {
58
+ return typeof v === 'string' && v.length > 0 ? v : dflt;
59
+ }
60
+
61
+ /** A string[] field — keep only non-empty strings; fall back to `dflt` when not a usable array. */
62
+ function strArray(v, dflt) {
63
+ if (!Array.isArray(v)) return dflt.slice();
64
+ const out = v.filter((x) => typeof x === 'string' && x.length > 0);
65
+ return out.length > 0 ? out : dflt.slice();
66
+ }
67
+
68
+ /**
69
+ * getConfig — the resolved auth config. Reads the file fresh; a missing/unreadable/malformed file
70
+ * resolves to the safe defaults (disabled). NEVER throws.
71
+ *
72
+ * @returns {{ enabled:boolean, issuer:string, audience:string, jwksUri:string,
73
+ * algorithms:string[], requiredScopes:string[], clockToleranceSec:number }}
74
+ */
75
+ function getConfig() {
76
+ try {
77
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
78
+ const parsed = JSON.parse(raw);
79
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
80
+ return cloneDefaults();
81
+ }
82
+ const clock = Number(parsed.clockToleranceSec);
83
+ return {
84
+ enabled: parsed.enabled === true,
85
+ issuer: str(parsed.issuer, DEFAULTS.issuer),
86
+ audience: str(parsed.audience, DEFAULTS.audience),
87
+ jwksUri: str(parsed.jwksUri, DEFAULTS.jwksUri),
88
+ algorithms: strArray(parsed.algorithms, DEFAULTS.algorithms),
89
+ requiredScopes: strArray(parsed.requiredScopes, DEFAULTS.requiredScopes),
90
+ // Clamp to a sane, non-negative tolerance; a >5min skew signals a clock problem, not validity.
91
+ clockToleranceSec:
92
+ Number.isFinite(clock) && clock >= 0 ? Math.min(Math.floor(clock), 300) : DEFAULTS.clockToleranceSec,
93
+ };
94
+ } catch (_err) {
95
+ // Missing file = disabled (the safe default); also covers unreadable/malformed.
96
+ return cloneDefaults();
97
+ }
98
+ }
99
+
100
+ /** A fresh, mutation-safe copy of the defaults (arrays cloned so a caller can't poison the frozen one). */
101
+ function cloneDefaults() {
102
+ return {
103
+ enabled: DEFAULTS.enabled,
104
+ issuer: DEFAULTS.issuer,
105
+ audience: DEFAULTS.audience,
106
+ jwksUri: DEFAULTS.jwksUri,
107
+ algorithms: DEFAULTS.algorithms.slice(),
108
+ requiredScopes: DEFAULTS.requiredScopes.slice(),
109
+ clockToleranceSec: DEFAULTS.clockToleranceSec,
110
+ };
111
+ }
112
+
113
+ /**
114
+ * configError — a human-readable reason the CURRENT config could not gate requests, or null if it
115
+ * is coherent enough to enforce. Used by the transport + UI to fail CLOSED with a clear message
116
+ * rather than silently mis-validating. Only meaningful when `enabled` is true.
117
+ *
118
+ * Enforcement requires: an issuer, an audience (the RFC 8707 confused-deputy defence — a gateway
119
+ * that does not bind tokens to its own resource URI is exploitable), at least one pinned algorithm,
120
+ * and a key source (explicit jwksUri OR an issuer to derive one from via OIDC discovery).
121
+ *
122
+ * @param {object} [cfg] defaults to getConfig()
123
+ * @returns {string|null}
124
+ */
125
+ function configError(cfg) {
126
+ const c = cfg || getConfig();
127
+ if (!c.enabled) return null; // disabled → nothing to enforce, no error
128
+ if (!c.issuer) return 'auth enabled but "issuer" is not set';
129
+ if (!c.audience) return 'auth enabled but "audience" (this gateway\'s resource URI) is not set — required to bind tokens (RFC 8707)';
130
+ if (!Array.isArray(c.algorithms) || c.algorithms.length === 0) return 'auth enabled but no signature "algorithms" are pinned';
131
+ // (A key source is always available: issuer is mandatory above, and jwksUri is derived from it
132
+ // via OIDC discovery when not given — so no separate jwksUri-or-issuer check is needed here.)
133
+ return null;
134
+ }
135
+
136
+ /**
137
+ * setConfig — atomically merge a patch into auth/auth.config.json (temp + rename). Merges with the
138
+ * current resolved config so a partial patch (e.g. { enabled: true }) preserves the other fields.
139
+ * Creates the auth/ dir and the file if absent — this is the ONLY function that creates it.
140
+ *
141
+ * @param {object} patch any subset of the config fields
142
+ * @returns {object} the merged, written config
143
+ */
144
+ function setConfig(patch) {
145
+ const current = getConfig();
146
+ const p = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {};
147
+
148
+ const next = {
149
+ enabled: typeof p.enabled === 'boolean' ? p.enabled : current.enabled,
150
+ issuer: typeof p.issuer === 'string' ? p.issuer : current.issuer,
151
+ audience: typeof p.audience === 'string' ? p.audience : current.audience,
152
+ jwksUri: typeof p.jwksUri === 'string' ? p.jwksUri : current.jwksUri,
153
+ algorithms: Array.isArray(p.algorithms) ? strArray(p.algorithms, current.algorithms) : current.algorithms,
154
+ requiredScopes: Array.isArray(p.requiredScopes)
155
+ ? p.requiredScopes.filter((x) => typeof x === 'string' && x.length > 0)
156
+ : current.requiredScopes,
157
+ clockToleranceSec:
158
+ Number.isFinite(Number(p.clockToleranceSec)) && Number(p.clockToleranceSec) >= 0
159
+ ? Math.min(Math.floor(Number(p.clockToleranceSec)), 300)
160
+ : current.clockToleranceSec,
161
+ };
162
+
163
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
164
+ // Atomic write: unique temp file in the same dir, then rename over the target.
165
+ const tmp = CONFIG_PATH + '.' + process.pid + '.' + Date.now() + '.tmp';
166
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
167
+ fs.renameSync(tmp, CONFIG_PATH);
168
+
169
+ return next;
170
+ }
171
+
172
+ module.exports = { getConfig, setConfig, configError, CONFIG_PATH, DEFAULTS };
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * auth/install.js — install the OPTIONAL OAuth dependency (`jose`) on demand.
5
+ *
6
+ * The whole point of ToolFunnel's zero-dependency stance is that a default install pulls NOTHING.
7
+ * OAuth is opt-in, so its one library is installed only when a user decides they need it — via the
8
+ * CLI (`toolfunnel install-oauth`) or the admin-UI button, both of which call installJose() here.
9
+ *
10
+ * It shells out to the user's own `npm` to install the PINNED jose version into the gateway
11
+ * package's own node_modules (resolved from this file's location, NOT process.cwd(), so it lands
12
+ * beside the engine wherever ToolFunnel is installed). It uses `--no-save` — the goal is simply to
13
+ * make `require('jose')` resolvable; there is no manifest to update, and this keeps ToolFunnel's
14
+ * own `dependencies: {}` untouched in every install shape.
15
+ *
16
+ * WINDOWS NOTE: do NOT spawn `npm.cmd`. Since the CVE-2024-27980 mitigation, spawning a `.cmd`
17
+ * without a shell throws `EINVAL`; and spawning it WITH a shell is unreliable on portable Node
18
+ * installs — the `npm.cmd` shim misresolves its own path under cmd.exe ("Cannot find module
19
+ * …\node_modules\npm\bin\npm-cli.js"). The robust, cross-platform approach is to run npm's JS entry
20
+ * (`npm-cli.js`, located beside the running node binary) with `process.execPath` directly —
21
+ * `shell:false`, no shim, no PATH lookup, no cwd sensitivity. We fall back to a shell-spawned `npm`
22
+ * only if `npm-cli.js` can't be located; the spec is allowlist-validated so that fallback can't inject.
23
+ *
24
+ * NEVER throws — it resolves to a structured { ok, code, stdout, stderr, message } so the CLI/UI can
25
+ * report the outcome cleanly even when npm is missing or the network is down.
26
+ *
27
+ * CommonJS only; Node built-ins (child_process, path, fs) + the pin from resource-server.js.
28
+ */
29
+
30
+ const path = require('node:path');
31
+ const fs = require('node:fs');
32
+ const { spawn } = require('node:child_process');
33
+
34
+ const { JOSE_PIN, isJoseInstalled, _resetJoseCache } = require('./resource-server');
35
+
36
+ /** The gateway package root: <root>/src/auth/install.js -> two dirs up. npm runs here so jose lands
37
+ * in the engine's own node_modules regardless of the caller's cwd. */
38
+ const PKG_ROOT = path.resolve(__dirname, '..', '..');
39
+
40
+ /** npm is `npm.cmd` on Windows, `npm` elsewhere. We spawn it as a child rather than require()-ing. */
41
+ const IS_WIN = process.platform === 'win32';
42
+ const NPM_BIN = IS_WIN ? 'npm.cmd' : 'npm';
43
+
44
+ /** A strict allowlist for an npm version spec. Covers what a single-package install actually uses —
45
+ * `name@<exact|caret|tilde|x-range|dist-tag>` and scoped names — and NOTHING a shell could act on, so
46
+ * the fallback shell path can never inject. Comparator/whitespace ranges (`>=1 <2`, `1 || 2`) and the
47
+ * `*` wildcard are deliberately OUT: `> < = | *` and spaces are shell-meaningful. This is fine — the
48
+ * gateway only ever installs the caret-pinned `jose@^5.10.0`. Anything outside the set is refused
49
+ * before npm is ever invoked. */
50
+ const SPEC_RE = /^[A-Za-z0-9@._/^~+-]+$/;
51
+
52
+ /**
53
+ * Locate npm's JS entry (`npm-cli.js`) so it can be run with `node` directly, bypassing the
54
+ * `npm.cmd`/`npm` shim. Checks the layouts beside the running node binary (Windows/portable, then
55
+ * POSIX), then the Windows per-user global. Returns the absolute path, or null if not found.
56
+ * @returns {string|null}
57
+ */
58
+ function findNpmCli() {
59
+ const dir = path.dirname(process.execPath);
60
+ const candidates = [
61
+ path.join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js'), // Windows / portable
62
+ path.join(dir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), // POSIX (/usr/bin -> /usr/lib)
63
+ ];
64
+ if (process.env.APPDATA) {
65
+ candidates.push(path.join(process.env.APPDATA, 'npm', 'node_modules', 'npm', 'bin', 'npm-cli.js'));
66
+ }
67
+ for (const c of candidates) {
68
+ try { if (fs.existsSync(c)) return c; } catch (_e) { /* ignore */ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * installJose — run `npm install jose@<PIN>` in the gateway package root. Resolves a structured
75
+ * result; never rejects. A 5-minute hard timeout guards against a hung registry.
76
+ *
77
+ * @param {object} [opts]
78
+ * @param {number} [opts.timeoutMs] default 300000 (5 min).
79
+ * @param {string} [opts.spec] override the install spec (tests). Default `jose@<JOSE_PIN>`.
80
+ * @returns {Promise<{ ok:boolean, code:(number|null), stdout:string, stderr:string, message:string }>}
81
+ */
82
+ function installJose(opts) {
83
+ const o = opts || {};
84
+ const spec = typeof o.spec === 'string' && o.spec.length > 0 ? o.spec : `jose@${JOSE_PIN}`;
85
+ const timeoutMs = Number.isFinite(o.timeoutMs) && o.timeoutMs > 0 ? o.timeoutMs : 300000;
86
+
87
+ if (isJoseInstalled()) {
88
+ return Promise.resolve({ ok: true, code: 0, stdout: '', stderr: '', message: `jose already installed (${spec})` });
89
+ }
90
+
91
+ // Refuse a spec carrying anything outside the strict allowlist BEFORE any shell is involved.
92
+ if (!SPEC_RE.test(spec)) {
93
+ return Promise.resolve({ ok: false, code: null, stdout: '', stderr: '', message: `refusing to install: invalid package spec "${spec}"` });
94
+ }
95
+
96
+ return new Promise((resolve) => {
97
+ let child;
98
+ let stdout = '';
99
+ let stderr = '';
100
+ let settled = false;
101
+ const done = (result) => {
102
+ if (settled) return;
103
+ settled = true;
104
+ clearTimeout(timer);
105
+ // On ANY failure, attach the guaranteed-correct manual fallback. Auto-install can't be made
106
+ // universal — npm-cli.js sits in different places relative to `node` across install methods
107
+ // (Homebrew/Volta/Docker/odd nvm). But the user definitely has a working npm (they used it to
108
+ // install/run the gateway), so a copyable command they run in their OWN shell always works.
109
+ // The UI + CLI surface result.message, so a failed auto-install becomes an actionable next
110
+ // step rather than a dead end.
111
+ if (result && result.ok !== true) {
112
+ result.manualCommand = 'npm install jose@' + JOSE_PIN;
113
+ result.cwd = PKG_ROOT;
114
+ if (typeof result.message === 'string' && result.message.indexOf('manually') === -1) {
115
+ result.message += ` — or install it manually: run \`${result.manualCommand}\` in ${PKG_ROOT}`;
116
+ }
117
+ }
118
+ resolve(result);
119
+ };
120
+
121
+ const timer = setTimeout(() => {
122
+ try { if (child) child.kill('SIGKILL'); } catch (_e) { /* ignore */ }
123
+ done({ ok: false, code: null, stdout, stderr, message: `npm install timed out after ${timeoutMs}ms` });
124
+ }, timeoutMs);
125
+ if (typeof timer.unref === 'function') timer.unref();
126
+
127
+ try {
128
+ const npmCli = findNpmCli();
129
+ if (npmCli) {
130
+ // PREFERRED: run npm's JS entry with the current node binary — no shell, no .cmd shim, no
131
+ // PATH/cwd sensitivity. This is what actually works on a portable/Windows Node install.
132
+ child = spawn(process.execPath, [npmCli, 'install', spec, '--no-save'], {
133
+ cwd: PKG_ROOT,
134
+ env: process.env,
135
+ windowsHide: true,
136
+ shell: false,
137
+ });
138
+ } else if (IS_WIN) {
139
+ // Fallback (npm-cli.js not found): shell out to npm.cmd. The spec is allowlist-validated
140
+ // above and double-quoted so cmd.exe can't act on the `^` in a caret range or inject.
141
+ child = spawn(`"${NPM_BIN}" install "${spec}" --no-save`, {
142
+ cwd: PKG_ROOT,
143
+ env: process.env,
144
+ windowsHide: true,
145
+ shell: true,
146
+ });
147
+ } else {
148
+ child = spawn(NPM_BIN, ['install', spec, '--no-save'], {
149
+ cwd: PKG_ROOT,
150
+ env: process.env,
151
+ windowsHide: true,
152
+ shell: false,
153
+ });
154
+ }
155
+ } catch (e) {
156
+ return done({ ok: false, code: null, stdout, stderr, message: `failed to spawn npm: ${(e && e.message) || e}` });
157
+ }
158
+
159
+ if (child.stdout) child.stdout.on('data', (d) => { stdout += d.toString(); });
160
+ if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString(); });
161
+ child.on('error', (e) => {
162
+ done({ ok: false, code: null, stdout, stderr, message: `npm not runnable: ${(e && e.message) || e}` });
163
+ });
164
+ child.on('close', (code) => {
165
+ // Clear the lazy cache so a freshly-installed jose is picked up WITHOUT a process restart.
166
+ try { _resetJoseCache(); } catch (_e) { /* ignore */ }
167
+ const ok = code === 0 && isJoseInstalled();
168
+ done({
169
+ ok,
170
+ code,
171
+ stdout,
172
+ stderr,
173
+ message: ok
174
+ ? `installed ${spec}`
175
+ : `npm install exited ${code}${isJoseInstalled() ? '' : ' (jose still not loadable)'}`,
176
+ });
177
+ });
178
+ });
179
+ }
180
+
181
+ module.exports = { installJose, findNpmCli, SPEC_RE, PKG_ROOT, NPM_BIN };