@suveren/gateway 0.19.0 → 0.19.2
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 +16 -1
- package/bin/suveren-gateway.js +203 -12
- package/content/integrations/erp.json +2 -1
- package/dist/control-plane/index.mjs +21 -2
- package/dist/mcp-server/http.mjs +148 -27
- package/dist/ui/assets/index-BE0Yn5xB.js +111 -0
- package/dist/ui/index.html +1 -1
- package/lib/install-settings.d.mts +41 -0
- package/lib/install-settings.mjs +318 -0
- package/lib/policy.d.mts +6 -1
- package/lib/policy.mjs +48 -4
- package/node_modules/@hap/core/dist/index.d.mts +13 -0
- package/node_modules/@hap/core/dist/index.d.ts +13 -0
- package/node_modules/@hap/core/package.json +1 -1
- package/node_modules/@hap/core/src/types.ts +13 -0
- package/package.json +4 -4
- package/server.js +20 -4
- package/dist/ui/assets/index-DeQPO4Sj.js +0 -111
package/dist/ui/index.html
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
<meta name="apple-mobile-web-app-title" content="Suveren" />
|
|
15
15
|
<meta name="theme-color" content="#111111" />
|
|
16
16
|
<title>Suveren</title>
|
|
17
|
-
<script type="module" crossorigin src="/assets/index-
|
|
17
|
+
<script type="module" crossorigin src="/assets/index-BE0Yn5xB.js"></script>
|
|
18
18
|
<link rel="stylesheet" crossorigin href="/assets/index-kObsFuwK.css">
|
|
19
19
|
</head>
|
|
20
20
|
<body>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the plain-ESM install-settings module (ports + data folder).
|
|
3
|
+
* See config.d.mts for why the .mjs modules carry declarations.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export declare const DEFAULT_CP_PORT: number;
|
|
7
|
+
export declare const DEFAULT_MCP_PORT: number;
|
|
8
|
+
export declare function defaultDataDir(): string;
|
|
9
|
+
|
|
10
|
+
export declare function validatePort(candidate: unknown): { ok: true; port: number } | { ok: false; error: string };
|
|
11
|
+
export declare function validateDataDir(candidate: unknown): { ok: true; path: string } | { ok: false; error: string };
|
|
12
|
+
|
|
13
|
+
export interface SavedInstallSettings {
|
|
14
|
+
cpPort?: string;
|
|
15
|
+
mcpPort?: string;
|
|
16
|
+
dataDir?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export declare function installSettingsFilePath(): string | null;
|
|
20
|
+
export declare function installSettingsLocation(): string;
|
|
21
|
+
export declare function installSettingsRegistryKey(): string;
|
|
22
|
+
export declare function parseInstallSettingsRegQuery(stdout: string): SavedInstallSettings;
|
|
23
|
+
export declare function readInstallSettings(): SavedInstallSettings;
|
|
24
|
+
export declare function writeInstallSettings(patch: {
|
|
25
|
+
cpPort?: number | null;
|
|
26
|
+
mcpPort?: number | null;
|
|
27
|
+
dataDir?: string | null;
|
|
28
|
+
}): void;
|
|
29
|
+
|
|
30
|
+
export type InstallSettingSource = 'policy' | 'env' | 'saved' | 'default';
|
|
31
|
+
|
|
32
|
+
export interface ResolvedInstallSettings {
|
|
33
|
+
cpPort: number;
|
|
34
|
+
mcpPort: number;
|
|
35
|
+
dataDir: string;
|
|
36
|
+
source: { cpPort: InstallSettingSource; mcpPort: InstallSettingSource; dataDir: InstallSettingSource };
|
|
37
|
+
errors: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export declare function resolveInstallSettings(opts?: { tolerant?: boolean }): ResolvedInstallSettings;
|
|
41
|
+
export declare function describeSource(source: InstallSettingSource, field: 'cpPort' | 'mcpPort' | 'dataDir'): string;
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install settings — the gateway's two ports and its data folder, saved per
|
|
3
|
+
* user so a chosen value survives a reboot and every command agrees on it.
|
|
4
|
+
*
|
|
5
|
+
* Before this, all three were environment-only (SUVEREN_CP_PORT,
|
|
6
|
+
* SUVEREN_MCP_PORT, SUVEREN_DATA_DIR). That never reached the login task on
|
|
7
|
+
* Windows (Task Scheduler carries no environment), so a custom port or
|
|
8
|
+
* folder was silently lost after a reboot; and `status`/`stop`/the Start
|
|
9
|
+
* Menu entry looked at 3400 unless the variable was set in that very shell.
|
|
10
|
+
*
|
|
11
|
+
* They cannot live in `<dataDir>/config.json` like the other settings — the
|
|
12
|
+
* gateway has to know the data folder before it can read anything in it.
|
|
13
|
+
* So they get their own place outside it:
|
|
14
|
+
*
|
|
15
|
+
* - Windows: `HKCU\Software\Suveren\Gateway`, values `Port`, `McpPort`,
|
|
16
|
+
* `DataDir` (REG_SZ). The installer writes them from its PORT /
|
|
17
|
+
* MCP_PORT / DATA_DIR properties (bundle/windows/wix/Product.wxs);
|
|
18
|
+
* `config set` writes the same values with `reg add`.
|
|
19
|
+
* - macOS: `~/Library/Application Support/Suveren/gateway.json`
|
|
20
|
+
* - Linux: `$XDG_CONFIG_HOME/suveren/gateway.json` (default ~/.config)
|
|
21
|
+
* - Any platform: `SUVEREN_INSTALL_SETTINGS_FILE` forces the JSON file at
|
|
22
|
+
* that path — tests set it (see each app's vitest.setup.ts) so they never
|
|
23
|
+
* touch the real registry or the real per-user file.
|
|
24
|
+
*
|
|
25
|
+
* Precedence, per setting: IT policy (`Port`, `McpPort`, `DataDir` — see
|
|
26
|
+
* policy.mjs; locked) > env var > saved install setting > default.
|
|
27
|
+
*
|
|
28
|
+
* Resolved ONLY here, by the CLI and by bundle/server.js. server.js passes
|
|
29
|
+
* the result to its children as SUVEREN_CP_PORT / SUVEREN_MCP_PORT /
|
|
30
|
+
* SUVEREN_DATA_DIR, so the two apps keep reading plain env vars and need no
|
|
31
|
+
* copy of this module (unlike config.mjs / policy.mjs, which they mirror).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
35
|
+
import { homedir } from 'node:os';
|
|
36
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
37
|
+
import { spawnSync } from 'node:child_process';
|
|
38
|
+
import { readPolicy } from './policy.mjs';
|
|
39
|
+
|
|
40
|
+
export const DEFAULT_CP_PORT = 3400;
|
|
41
|
+
export const DEFAULT_MCP_PORT = 3430;
|
|
42
|
+
|
|
43
|
+
export function defaultDataDir() {
|
|
44
|
+
return join(homedir(), '.suveren');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Saved-setting name ↔ registry value name ↔ env var. */
|
|
48
|
+
const FIELDS = {
|
|
49
|
+
cpPort: { reg: 'Port', env: 'SUVEREN_CP_PORT', label: 'port' },
|
|
50
|
+
mcpPort: { reg: 'McpPort', env: 'SUVEREN_MCP_PORT', label: 'mcp-port' },
|
|
51
|
+
dataDir: { reg: 'DataDir', env: 'SUVEREN_DATA_DIR', label: 'data-dir' },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// ─── Validation ─────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A port someone chose to SAVE (CLI, installer, IT policy): 1024–65535.
|
|
58
|
+
* Below 1024 needs root on macOS/Linux and is reserved for system services —
|
|
59
|
+
* a gateway that silently cannot bind there is worse than a refusal now.
|
|
60
|
+
*/
|
|
61
|
+
export function validatePort(candidate) {
|
|
62
|
+
const raw = String(candidate ?? '').trim();
|
|
63
|
+
if (!/^\d+$/.test(raw)) return { ok: false, error: `"${raw}" is not a port number.` };
|
|
64
|
+
const port = Number(raw);
|
|
65
|
+
if (port < 1024 || port > 65535) {
|
|
66
|
+
return { ok: false, error: `${port} is outside 1024–65535.` };
|
|
67
|
+
}
|
|
68
|
+
return { ok: true, port };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* An env-var port: any valid TCP port (1–65535). Looser than validatePort on
|
|
73
|
+
* purpose — the Docker image runs on 3000/3030 and dev setups have always
|
|
74
|
+
* been free to pick anything; only "not a number at all" is refused.
|
|
75
|
+
*/
|
|
76
|
+
function validateEnvPort(candidate) {
|
|
77
|
+
const raw = String(candidate).trim();
|
|
78
|
+
if (!/^\d+$/.test(raw)) return { ok: false, error: `"${raw}" is not a port number.` };
|
|
79
|
+
const port = Number(raw);
|
|
80
|
+
if (port < 1 || port > 65535) return { ok: false, error: `${port} is outside 1–65535.` };
|
|
81
|
+
return { ok: true, port };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A data folder someone chose to SAVE: must be an ABSOLUTE path (a relative
|
|
86
|
+
* one would resolve differently for the login service than for the shell
|
|
87
|
+
* that saved it), and must not be an existing file. It need not exist yet —
|
|
88
|
+
* the gateway creates it on start.
|
|
89
|
+
*/
|
|
90
|
+
export function validateDataDir(candidate) {
|
|
91
|
+
const raw = String(candidate ?? '').trim();
|
|
92
|
+
if (!raw) return { ok: false, error: 'The data folder path is empty.' };
|
|
93
|
+
if (!isAbsolute(raw)) return { ok: false, error: `"${raw}" is not a full path (it must start at a drive or at /).` };
|
|
94
|
+
const path = resolve(raw);
|
|
95
|
+
if (existsSync(path) && !statSync(path).isDirectory()) {
|
|
96
|
+
return { ok: false, error: `"${path}" is a file, not a folder.` };
|
|
97
|
+
}
|
|
98
|
+
return { ok: true, path };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Storage ────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
export function installSettingsFilePath() {
|
|
104
|
+
if (process.env.SUVEREN_INSTALL_SETTINGS_FILE) return process.env.SUVEREN_INSTALL_SETTINGS_FILE;
|
|
105
|
+
if (process.platform === 'win32') return null; // registry — see installSettingsRegistryKey
|
|
106
|
+
if (process.platform === 'darwin') {
|
|
107
|
+
return join(homedir(), 'Library', 'Application Support', 'Suveren', 'gateway.json');
|
|
108
|
+
}
|
|
109
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
110
|
+
return join(base, 'suveren', 'gateway.json');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Where the settings live, for messages. */
|
|
114
|
+
export function installSettingsLocation() {
|
|
115
|
+
const file = installSettingsFilePath();
|
|
116
|
+
return file ?? `the registry (${installSettingsRegistryKey()})`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function installSettingsRegistryKey() {
|
|
120
|
+
return 'HKCU\\Software\\Suveren\\Gateway';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Parse `reg query` output down to the three values this module owns. */
|
|
124
|
+
export function parseInstallSettingsRegQuery(stdout) {
|
|
125
|
+
const out = {};
|
|
126
|
+
const lineRe = /^ {4}(\S+)\s+(REG_SZ|REG_EXPAND_SZ|REG_DWORD)\s*(.*)$/;
|
|
127
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
128
|
+
const m = lineRe.exec(line);
|
|
129
|
+
if (!m) continue;
|
|
130
|
+
const [, name, type, rawValue] = m;
|
|
131
|
+
const field = Object.keys(FIELDS).find((k) => FIELDS[k].reg === name);
|
|
132
|
+
if (!field) continue;
|
|
133
|
+
const value = type === 'REG_DWORD' ? String(parseInt(rawValue, 16)) : rawValue.trim();
|
|
134
|
+
// The installer writes an empty value when a field was left blank —
|
|
135
|
+
// that means "not set", not "set to nothing".
|
|
136
|
+
if (value !== '') out[field] = value;
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Raw saved values (strings, unvalidated). Throws when the JSON file exists
|
|
143
|
+
* but cannot be parsed — fail closed and audibly, same rule as config.mjs's
|
|
144
|
+
* readConfigStrict: silently falling back to defaults would start a second,
|
|
145
|
+
* empty gateway on the default port next to the one the person configured.
|
|
146
|
+
*/
|
|
147
|
+
export function readInstallSettings() {
|
|
148
|
+
const file = installSettingsFilePath();
|
|
149
|
+
if (file) {
|
|
150
|
+
if (!existsSync(file)) return {};
|
|
151
|
+
let data;
|
|
152
|
+
try {
|
|
153
|
+
data = JSON.parse(readFileSync(file, 'utf8'));
|
|
154
|
+
} catch (err) {
|
|
155
|
+
throw new Error(`Could not parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
156
|
+
}
|
|
157
|
+
const out = {};
|
|
158
|
+
for (const key of Object.keys(FIELDS)) {
|
|
159
|
+
const v = data?.[key];
|
|
160
|
+
if (v !== undefined && v !== null && String(v).trim() !== '') out[key] = String(v);
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
let result;
|
|
165
|
+
try {
|
|
166
|
+
result = spawnSync('reg', ['query', installSettingsRegistryKey()], { encoding: 'utf8', windowsHide: true });
|
|
167
|
+
} catch {
|
|
168
|
+
return {};
|
|
169
|
+
}
|
|
170
|
+
// Exit 1 = key absent: the normal "nothing saved" case.
|
|
171
|
+
if (!result || result.status !== 0 || !result.stdout) return {};
|
|
172
|
+
return parseInstallSettingsRegQuery(result.stdout);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Save or clear settings. `patch` maps cpPort/mcpPort/dataDir to a value
|
|
177
|
+
* (saved) or null (cleared — back to the default). Values must already be
|
|
178
|
+
* validated by the caller.
|
|
179
|
+
*/
|
|
180
|
+
export function writeInstallSettings(patch) {
|
|
181
|
+
const file = installSettingsFilePath();
|
|
182
|
+
if (file) {
|
|
183
|
+
let current = {};
|
|
184
|
+
if (existsSync(file)) {
|
|
185
|
+
try { current = JSON.parse(readFileSync(file, 'utf8')) ?? {}; } catch { current = {}; }
|
|
186
|
+
}
|
|
187
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
188
|
+
if (!(key in FIELDS)) continue;
|
|
189
|
+
if (value === null || value === undefined) delete current[key];
|
|
190
|
+
else current[key] = key === 'dataDir' ? String(value) : Number(value);
|
|
191
|
+
}
|
|
192
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
193
|
+
writeFileSync(file, JSON.stringify(current, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const key = installSettingsRegistryKey();
|
|
197
|
+
for (const [field, value] of Object.entries(patch)) {
|
|
198
|
+
if (!(field in FIELDS)) continue;
|
|
199
|
+
const name = FIELDS[field].reg;
|
|
200
|
+
const clearing = value === null || value === undefined;
|
|
201
|
+
const args = clearing
|
|
202
|
+
? ['delete', key, '/v', name, '/f']
|
|
203
|
+
: ['add', key, '/v', name, '/t', 'REG_SZ', '/d', String(value), '/f'];
|
|
204
|
+
const r = spawnSync('reg', args, { encoding: 'utf8', windowsHide: true });
|
|
205
|
+
if (r.status === 0) continue;
|
|
206
|
+
// Clearing a value that was never there fails `reg delete` — that is
|
|
207
|
+
// the result we wanted. (Checked by reading back, not by matching the
|
|
208
|
+
// error text, which Windows localizes.)
|
|
209
|
+
if (clearing && readInstallSettings()[field] === undefined) continue;
|
|
210
|
+
throw new Error(`Could not save ${FIELDS[field].label} to ${key}: ${(r.stderr || r.stdout || '').trim()}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─── Resolution ─────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The effective ports + data folder, with where each one came from.
|
|
218
|
+
*
|
|
219
|
+
* Returns `{ cpPort, mcpPort, dataDir, source: { cpPort, mcpPort, dataDir },
|
|
220
|
+
* errors }`, source being 'policy' | 'env' | 'saved' | 'default'.
|
|
221
|
+
*
|
|
222
|
+
* Strict (the default): throws a named error on any invalid value, or when
|
|
223
|
+
* both ports end up equal — the gateway must not start on a guess.
|
|
224
|
+
* `{ tolerant: true }`: an invalid value falls through to the next tier and
|
|
225
|
+
* is reported in `errors` instead — used ONLY by `config`/`help`, so a bad
|
|
226
|
+
* saved value can still be inspected and fixed with the CLI.
|
|
227
|
+
*/
|
|
228
|
+
export function resolveInstallSettings({ tolerant = false } = {}) {
|
|
229
|
+
const errors = [];
|
|
230
|
+
const fail = (msg) => {
|
|
231
|
+
if (!tolerant) throw new Error(msg);
|
|
232
|
+
errors.push(msg);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
let policy = {};
|
|
236
|
+
try {
|
|
237
|
+
policy = readPolicy().policy;
|
|
238
|
+
} catch (err) {
|
|
239
|
+
if (!tolerant) throw err;
|
|
240
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let saved = {};
|
|
244
|
+
try {
|
|
245
|
+
saved = readInstallSettings();
|
|
246
|
+
} catch (err) {
|
|
247
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
248
|
+
}
|
|
249
|
+
const where = installSettingsLocation();
|
|
250
|
+
|
|
251
|
+
const out = { source: {}, errors };
|
|
252
|
+
|
|
253
|
+
// Ports.
|
|
254
|
+
for (const [field, policyKey, fallback] of [
|
|
255
|
+
['cpPort', 'port', DEFAULT_CP_PORT],
|
|
256
|
+
['mcpPort', 'mcpPort', DEFAULT_MCP_PORT],
|
|
257
|
+
]) {
|
|
258
|
+
const { env, label } = FIELDS[field];
|
|
259
|
+
if (policy[policyKey] !== undefined) {
|
|
260
|
+
out[field] = policy[policyKey];
|
|
261
|
+
out.source[field] = 'policy';
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const envValue = process.env[env];
|
|
265
|
+
if (envValue !== undefined && envValue !== '') {
|
|
266
|
+
const v = validateEnvPort(envValue);
|
|
267
|
+
if (v.ok) { out[field] = v.port; out.source[field] = 'env'; continue; }
|
|
268
|
+
fail(`Invalid ${env}: ${v.error}`);
|
|
269
|
+
}
|
|
270
|
+
if (saved[field] !== undefined) {
|
|
271
|
+
const v = validatePort(saved[field]);
|
|
272
|
+
if (v.ok) { out[field] = v.port; out.source[field] = 'saved'; continue; }
|
|
273
|
+
fail(`Invalid saved ${label} "${saved[field]}" in ${where}: ${v.error}`);
|
|
274
|
+
}
|
|
275
|
+
out[field] = fallback;
|
|
276
|
+
out.source[field] = 'default';
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Data folder.
|
|
280
|
+
if (policy.dataDir !== undefined) {
|
|
281
|
+
out.dataDir = policy.dataDir;
|
|
282
|
+
out.source.dataDir = 'policy';
|
|
283
|
+
} else if (process.env.SUVEREN_DATA_DIR) {
|
|
284
|
+
// Env keeps its long-standing meaning: taken as given (relative to the
|
|
285
|
+
// current folder if relative) — Docker and the dev scripts rely on it.
|
|
286
|
+
out.dataDir = resolve(process.env.SUVEREN_DATA_DIR);
|
|
287
|
+
out.source.dataDir = 'env';
|
|
288
|
+
} else if (saved.dataDir !== undefined) {
|
|
289
|
+
const v = validateDataDir(saved.dataDir);
|
|
290
|
+
if (v.ok) {
|
|
291
|
+
out.dataDir = v.path;
|
|
292
|
+
out.source.dataDir = 'saved';
|
|
293
|
+
} else {
|
|
294
|
+
fail(`Invalid saved data-dir "${saved.dataDir}" in ${where}: ${v.error}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (!out.dataDir) {
|
|
298
|
+
out.dataDir = defaultDataDir();
|
|
299
|
+
out.source.dataDir = 'default';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (out.cpPort === out.mcpPort) {
|
|
303
|
+
fail(
|
|
304
|
+
`The gateway port and the MCP port are both ${out.cpPort} — they must differ ` +
|
|
305
|
+
`(port from ${out.source.cpPort}, mcp-port from ${out.source.mcpPort}).`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Human wording for a source, used in `config get` and error hints. */
|
|
313
|
+
export function describeSource(source, field) {
|
|
314
|
+
if (source === 'policy') return 'set by your IT';
|
|
315
|
+
if (source === 'env') return `from ${FIELDS[field].env}`;
|
|
316
|
+
if (source === 'saved') return 'saved';
|
|
317
|
+
return 'default';
|
|
318
|
+
}
|
package/lib/policy.d.mts
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* (see config.d.mts for the established precedent).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
export type PolicyKey =
|
|
10
|
+
export type PolicyKey =
|
|
11
|
+
| 'asUrl' | 'caFile' | 'pinTls' | 'proxy' | 'noProxy' | 'simulation' | 'installMethod'
|
|
12
|
+
| 'port' | 'mcpPort' | 'dataDir';
|
|
11
13
|
|
|
12
14
|
export interface GatewayPolicy {
|
|
13
15
|
asUrl?: string;
|
|
@@ -17,6 +19,9 @@ export interface GatewayPolicy {
|
|
|
17
19
|
noProxy?: string;
|
|
18
20
|
simulation?: boolean;
|
|
19
21
|
installMethod?: 'managed';
|
|
22
|
+
port?: number;
|
|
23
|
+
mcpPort?: number;
|
|
24
|
+
dataDir?: string;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
export interface ResolvedPolicy {
|
package/lib/policy.mjs
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
*
|
|
32
32
|
* Registry value names = JSON file keys (PascalCase, matching what an IT
|
|
33
33
|
* admin would see in either place): AsUrl, CaFile, PinTls, Proxy, NoProxy,
|
|
34
|
-
* Simulation, InstallMethod.
|
|
34
|
+
* Simulation, InstallMethod, Port, McpPort, DataDir.
|
|
35
35
|
*
|
|
36
36
|
* Mirrored (same logic, duplicated — no shared runtime package between the
|
|
37
37
|
* CLI and the two apps, see config.mjs's doc comment for the established
|
|
@@ -39,11 +39,14 @@
|
|
|
39
39
|
* `apps/control-plane/src/lib/policy.ts`. Keep all three in step.
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
43
|
-
import { join } from 'node:path';
|
|
42
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
43
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
44
44
|
import { spawnSync } from 'node:child_process';
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
// Port, McpPort and DataDir exist ONLY here, not in the two apps' TS
|
|
47
|
+
// mirrors: they are resolved by the CLI and bundle/server.js alone (see
|
|
48
|
+
// install-settings.mjs), which hand the result to the apps as plain env vars.
|
|
49
|
+
const POLICY_KEYS = ['AsUrl', 'CaFile', 'PinTls', 'Proxy', 'NoProxy', 'Simulation', 'InstallMethod', 'Port', 'McpPort', 'DataDir'];
|
|
47
50
|
|
|
48
51
|
/** The documented production path, relative to the hive — never changes for
|
|
49
52
|
* a real install. Overridable ONLY via `SUVEREN_POLICY_REGISTRY_KEY`, which
|
|
@@ -122,6 +125,28 @@ function validateProxyUrl(candidate) {
|
|
|
122
125
|
return { ok: true, url: trimmed };
|
|
123
126
|
}
|
|
124
127
|
|
|
128
|
+
/** Same rule as install-settings.mjs's validatePort (duplicated, not
|
|
129
|
+
* imported — that module imports this one): 1024–65535. A REG_DWORD
|
|
130
|
+
* arrives as a number, a REG_SZ / JSON value as either. */
|
|
131
|
+
function validatePort(candidate) {
|
|
132
|
+
const raw = String(candidate ?? '').trim();
|
|
133
|
+
if (!/^\d+$/.test(raw)) return { ok: false, error: `"${raw}" is not a port number.` };
|
|
134
|
+
const port = Number(raw);
|
|
135
|
+
if (port < 1024 || port > 65535) return { ok: false, error: `${port} is outside 1024–65535.` };
|
|
136
|
+
return { ok: true, port };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Same rule as install-settings.mjs's validateDataDir: an absolute path
|
|
140
|
+
* that is not an existing file. It need not exist yet. */
|
|
141
|
+
function validateDataDir(candidate) {
|
|
142
|
+
const raw = String(candidate ?? '').trim();
|
|
143
|
+
if (!raw) return { ok: false, error: 'The data folder path is empty.' };
|
|
144
|
+
if (!isAbsolute(raw)) return { ok: false, error: `"${raw}" is not a full path.` };
|
|
145
|
+
const path = resolve(raw);
|
|
146
|
+
if (existsSync(path) && !statSync(path).isDirectory()) return { ok: false, error: `"${path}" is a file, not a folder.` };
|
|
147
|
+
return { ok: true, path };
|
|
148
|
+
}
|
|
149
|
+
|
|
125
150
|
/** Accepts a JSON boolean, or the DWORD-style 0/1 (as a number OR a string,
|
|
126
151
|
* since the registry parser below hands back numbers but a hand-edited
|
|
127
152
|
* JSON file might use either). Returns undefined — not a default — for
|
|
@@ -352,6 +377,25 @@ export function readPolicy() {
|
|
|
352
377
|
locked.add('installMethod');
|
|
353
378
|
}
|
|
354
379
|
|
|
380
|
+
for (const [rawKey, key] of [['Port', 'port'], ['McpPort', 'mcpPort']]) {
|
|
381
|
+
if (merged[rawKey] === undefined) continue;
|
|
382
|
+
const v = validatePort(merged[rawKey]);
|
|
383
|
+
if (!v.ok) {
|
|
384
|
+
throw new Error(`Invalid policy ${rawKey} ("${merged[rawKey]}") from ${sourceOf[rawKey]}: ${v.error}`);
|
|
385
|
+
}
|
|
386
|
+
policy[key] = v.port;
|
|
387
|
+
locked.add(key);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (merged.DataDir !== undefined) {
|
|
391
|
+
const v = validateDataDir(merged.DataDir);
|
|
392
|
+
if (!v.ok) {
|
|
393
|
+
throw new Error(`Invalid policy DataDir ("${merged.DataDir}") from ${sourceOf.DataDir}: ${v.error}`);
|
|
394
|
+
}
|
|
395
|
+
policy.dataDir = v.path;
|
|
396
|
+
locked.add('dataDir');
|
|
397
|
+
}
|
|
398
|
+
|
|
355
399
|
cached = { policy, locked };
|
|
356
400
|
return cached;
|
|
357
401
|
}
|
|
@@ -315,6 +315,19 @@ interface ProfileBoundsField {
|
|
|
315
315
|
* kinds, and the same boundType can carry different units.
|
|
316
316
|
*/
|
|
317
317
|
unit?: FieldUnit;
|
|
318
|
+
/**
|
|
319
|
+
* The largest value a mandate may set for this (numeric) bound — a ceiling
|
|
320
|
+
* on what the owner can grant, declared by the profile. E.g. reporting@0.2
|
|
321
|
+
* `read_max_age_days: { maximum: 366 }`: no reporting mandate may read back
|
|
322
|
+
* further than a year.
|
|
323
|
+
*
|
|
324
|
+
* Distinct from the bound itself: the bound limits each ACTION; `maximum`
|
|
325
|
+
* limits the MANDATE. A value above it is not a valid mandate for this
|
|
326
|
+
* profile — the mandate editor caps its input, the issuing Authority Server
|
|
327
|
+
* should refuse it, and an enforcement point that reads the bound applies
|
|
328
|
+
* min(value, maximum). Absent → no ceiling.
|
|
329
|
+
*/
|
|
330
|
+
maximum?: number;
|
|
318
331
|
/** @deprecated v0.4: use boundType instead. */
|
|
319
332
|
constraint?: FieldConstraint;
|
|
320
333
|
/** @deprecated v0.4: use boundType: { kind: 'enum', values: [...] }. */
|
|
@@ -315,6 +315,19 @@ interface ProfileBoundsField {
|
|
|
315
315
|
* kinds, and the same boundType can carry different units.
|
|
316
316
|
*/
|
|
317
317
|
unit?: FieldUnit;
|
|
318
|
+
/**
|
|
319
|
+
* The largest value a mandate may set for this (numeric) bound — a ceiling
|
|
320
|
+
* on what the owner can grant, declared by the profile. E.g. reporting@0.2
|
|
321
|
+
* `read_max_age_days: { maximum: 366 }`: no reporting mandate may read back
|
|
322
|
+
* further than a year.
|
|
323
|
+
*
|
|
324
|
+
* Distinct from the bound itself: the bound limits each ACTION; `maximum`
|
|
325
|
+
* limits the MANDATE. A value above it is not a valid mandate for this
|
|
326
|
+
* profile — the mandate editor caps its input, the issuing Authority Server
|
|
327
|
+
* should refuse it, and an enforcement point that reads the bound applies
|
|
328
|
+
* min(value, maximum). Absent → no ceiling.
|
|
329
|
+
*/
|
|
330
|
+
maximum?: number;
|
|
318
331
|
/** @deprecated v0.4: use boundType instead. */
|
|
319
332
|
constraint?: FieldConstraint;
|
|
320
333
|
/** @deprecated v0.4: use boundType: { kind: 'enum', values: [...] }. */
|
|
@@ -320,6 +320,19 @@ export interface ProfileBoundsField {
|
|
|
320
320
|
* kinds, and the same boundType can carry different units.
|
|
321
321
|
*/
|
|
322
322
|
unit?: FieldUnit;
|
|
323
|
+
/**
|
|
324
|
+
* The largest value a mandate may set for this (numeric) bound — a ceiling
|
|
325
|
+
* on what the owner can grant, declared by the profile. E.g. reporting@0.2
|
|
326
|
+
* `read_max_age_days: { maximum: 366 }`: no reporting mandate may read back
|
|
327
|
+
* further than a year.
|
|
328
|
+
*
|
|
329
|
+
* Distinct from the bound itself: the bound limits each ACTION; `maximum`
|
|
330
|
+
* limits the MANDATE. A value above it is not a valid mandate for this
|
|
331
|
+
* profile — the mandate editor caps its input, the issuing Authority Server
|
|
332
|
+
* should refuse it, and an enforcement point that reads the bound applies
|
|
333
|
+
* min(value, maximum). Absent → no ceiling.
|
|
334
|
+
*/
|
|
335
|
+
maximum?: number;
|
|
323
336
|
/** @deprecated v0.4: use boundType instead. */
|
|
324
337
|
constraint?: FieldConstraint;
|
|
325
338
|
/** @deprecated v0.4: use boundType: { kind: 'enum', values: [...] }. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suveren/gateway",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.2",
|
|
4
4
|
"description": "Suveren gateway — local agent gateway built in compliance with the Human Agency Protocol (HAP). Runs the UI, control plane, and MCP server in one Node process.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@hap/core": "npm:@humanagencyp/hap-core@^0.11.1",
|
|
28
28
|
"@hpke/core": "^1.9.0",
|
|
29
|
-
"express": "^4.
|
|
30
|
-
"http-proxy-middleware": "^3.0.
|
|
29
|
+
"express": "^4.22.3",
|
|
30
|
+
"http-proxy-middleware": "^3.0.7",
|
|
31
31
|
"undici": "^7.30.0",
|
|
32
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
32
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
33
33
|
"htmlparser2": "^12.0.0",
|
|
34
34
|
"sanitize-html": "^2.18.0",
|
|
35
35
|
"zod": "^3.22.0"
|
package/server.js
CHANGED
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
import { spawn } from 'node:child_process';
|
|
11
11
|
import { dirname, join } from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
|
-
import {
|
|
13
|
+
import { tmpdir, constants as osConstants } from 'node:os';
|
|
14
14
|
import { existsSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
|
15
15
|
import { resolveCaFile, resolveProxyUrl, resolveSimulation } from './lib/config.mjs';
|
|
16
16
|
import { readPolicy, isPolicyLocked } from './lib/policy.mjs';
|
|
17
17
|
import { unsupportedNodeReason } from './lib/node-version.mjs';
|
|
18
|
+
import { resolveInstallSettings } from './lib/install-settings.mjs';
|
|
18
19
|
|
|
19
20
|
// Docker and the login service start this file directly, not through the CLI,
|
|
20
21
|
// so it checks the Node version too (see lib/node-version.mjs).
|
|
@@ -63,7 +64,19 @@ function exitLikeChild(code, signal) {
|
|
|
63
64
|
// MERGED into one combined PEM file instead. Comparing against that combined
|
|
64
65
|
// path would never match the saved path alone, so the guard has to be a
|
|
65
66
|
// dedicated marker, not a value comparison (which would also re-exec forever).
|
|
66
|
-
|
|
67
|
+
// Ports + data folder: IT policy > env > saved install setting > default
|
|
68
|
+
// (see lib/install-settings.mjs). Resolved here, not just in the CLI,
|
|
69
|
+
// because the login service starts this file directly. Refuses to start on
|
|
70
|
+
// an invalid value rather than guessing a port or a folder.
|
|
71
|
+
let INSTALL;
|
|
72
|
+
try {
|
|
73
|
+
INSTALL = resolveInstallSettings();
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error(`[suveren-gateway] ${err instanceof Error ? err.message : String(err)}`);
|
|
76
|
+
console.error('[suveren-gateway] Fix it with `suveren-gateway config set …` (see `suveren-gateway config help`).');
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
const DATA_DIR = INSTALL.dataDir;
|
|
67
80
|
const savedCaFile = resolveCaFile(DATA_DIR);
|
|
68
81
|
if (savedCaFile && !process.env.SUVEREN_CA_REEXEC_DONE) {
|
|
69
82
|
let effectiveCaFile = savedCaFile;
|
|
@@ -159,8 +172,8 @@ if (policyNoProxy) {
|
|
|
159
172
|
process.env.NO_PROXY = policyNoProxy;
|
|
160
173
|
}
|
|
161
174
|
|
|
162
|
-
const CP_PORT =
|
|
163
|
-
const MCP_PORT =
|
|
175
|
+
const CP_PORT = String(INSTALL.cpPort);
|
|
176
|
+
const MCP_PORT = String(INSTALL.mcpPort);
|
|
164
177
|
const UI_DIST = process.env.HAP_UI_DIST ?? join(__dirname, 'dist', 'ui');
|
|
165
178
|
// Integration manifests + profile catalog ship inside the bundle so a
|
|
166
179
|
// fresh `npm install -g` install has working integrations and profiles
|
|
@@ -176,6 +189,9 @@ const env = {
|
|
|
176
189
|
NODE_ENV: 'production',
|
|
177
190
|
SUVEREN_CP_PORT: CP_PORT,
|
|
178
191
|
SUVEREN_MCP_PORT: MCP_PORT,
|
|
192
|
+
// Only when not the default: the control plane's one-time ~/.hap →
|
|
193
|
+
// ~/.suveren migration runs only while SUVEREN_DATA_DIR is unset.
|
|
194
|
+
...(INSTALL.source.dataDir !== 'default' ? { SUVEREN_DATA_DIR: DATA_DIR } : {}),
|
|
179
195
|
// The control plane talks to the MCP server over this URL. It defaults to
|
|
180
196
|
// 127.0.0.1:3430 in mcp-bridge.ts, so if the user overrode SUVEREN_MCP_PORT
|
|
181
197
|
// without also setting this, every CP→MCP internal call hit the wrong port
|