@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.
@@ -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-DeQPO4Sj.js"></script>
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 = 'asUrl' | 'caFile' | 'pinTls' | 'proxy' | 'noProxy' | 'simulation' | 'installMethod';
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
- const POLICY_KEYS = ['AsUrl', 'CaFile', 'PinTls', 'Proxy', 'NoProxy', 'Simulation', 'InstallMethod'];
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: [...] }. */
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanagencyp/hap-core",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
4
  "description": "Core types, cryptographic primitives, and verification logic for the Human Agency Protocol",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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.0",
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.18.0",
30
- "http-proxy-middleware": "^3.0.0",
29
+ "express": "^4.22.3",
30
+ "http-proxy-middleware": "^3.0.7",
31
31
  "undici": "^7.30.0",
32
- "@modelcontextprotocol/sdk": "^1.12.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 { homedir, tmpdir, constants as osConstants } from 'node:os';
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
- const DATA_DIR = process.env.SUVEREN_DATA_DIR ?? join(homedir(), '.suveren');
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 = process.env.SUVEREN_CP_PORT ?? '3400';
163
- const MCP_PORT = process.env.SUVEREN_MCP_PORT ?? '3430';
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