@serfab/cadre-host 0.10.0 → 0.11.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 (138) hide show
  1. package/dist/server/static.js +38 -38
  2. package/dist/ui/assets/index-C-iks41M.js.map +1 -1
  3. package/dist/ui/index.html +12 -12
  4. package/package.json +4 -4
  5. package/service/cadre-host.service.tmpl +16 -16
  6. package/service/com.serfab.cadre-host.plist.tmpl +35 -35
  7. package/service/install-service.ps1 +98 -98
  8. package/service/uninstall-service.ps1 +19 -19
  9. package/src/__tests__/build-targets.test.ts +26 -26
  10. package/src/__tests__/cli-invite.smoke.test.ts +192 -192
  11. package/src/__tests__/cli-nat.smoke.test.ts +212 -212
  12. package/src/__tests__/cli.smoke.test.ts +46 -46
  13. package/src/__tests__/global-setup.ts +51 -51
  14. package/src/__tests__/host-owner.smoke.test.ts +123 -123
  15. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -140
  16. package/src/__tests__/orchestrator-node-identity.test.ts +204 -204
  17. package/src/__tests__/orchestrator-owner.test.ts +198 -198
  18. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
  19. package/src/__tests__/orchestrator-push.test.ts +145 -145
  20. package/src/__tests__/orchestrator.test.ts +951 -951
  21. package/src/auth/__tests__/duration.test.ts +28 -28
  22. package/src/auth/__tests__/self-label.test.ts +85 -85
  23. package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
  24. package/src/auth/__tests__/trust-circle.test.ts +445 -445
  25. package/src/auth/duration.ts +33 -33
  26. package/src/auth/index.ts +21 -21
  27. package/src/auth/self-label.ts +74 -74
  28. package/src/auth/trust-circle-store.ts +155 -155
  29. package/src/auth/types.ts +99 -99
  30. package/src/donation/__tests__/donation-service.test.ts +1068 -1068
  31. package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
  32. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
  33. package/src/donation/__tests__/fake-orchestrator.ts +225 -225
  34. package/src/donation/__tests__/grant-service.test.ts +213 -213
  35. package/src/donation/__tests__/grant-store.test.ts +113 -113
  36. package/src/donation/donation-service.ts +962 -962
  37. package/src/donation/donation-store.ts +171 -171
  38. package/src/donation/donation-supervisor.ts +388 -388
  39. package/src/donation/grant-service.ts +200 -200
  40. package/src/donation/grant-store.ts +152 -152
  41. package/src/donation/index.ts +64 -64
  42. package/src/donation/types.ts +243 -243
  43. package/src/installer/__tests__/identity.test.ts +34 -34
  44. package/src/installer/__tests__/paths.test.ts +47 -47
  45. package/src/installer/__tests__/service-host.test.ts +62 -62
  46. package/src/installer/__tests__/wizard.test.ts +64 -64
  47. package/src/installer/identity.ts +49 -49
  48. package/src/installer/paths.ts +109 -109
  49. package/src/installer/platform.ts +27 -27
  50. package/src/installer/service-host/index.ts +21 -21
  51. package/src/installer/service-host/template.ts +21 -21
  52. package/src/installer/wizard.ts +103 -103
  53. package/src/nat/__tests__/address-resolver.test.ts +82 -82
  54. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
  55. package/src/nat/__tests__/ddns-updater.test.ts +253 -253
  56. package/src/nat/__tests__/external-ip.test.ts +124 -124
  57. package/src/nat/__tests__/nat-service.test.ts +497 -497
  58. package/src/nat/__tests__/nat-store.test.ts +93 -93
  59. package/src/nat/__tests__/port-mapper.test.ts +191 -191
  60. package/src/nat/__tests__/reachability.test.ts +24 -24
  61. package/src/nat/__tests__/secrets.test.ts +178 -178
  62. package/src/nat/address-resolver.ts +51 -51
  63. package/src/nat/ddns/duckdns.ts +76 -76
  64. package/src/nat/ddns/index.ts +55 -55
  65. package/src/nat/ddns/updater.ts +243 -243
  66. package/src/nat/external-ip.ts +154 -154
  67. package/src/nat/index.ts +71 -71
  68. package/src/nat/nat-service.ts +501 -501
  69. package/src/nat/nat-store.ts +154 -154
  70. package/src/nat/port-mapper.ts +329 -329
  71. package/src/nat/reachability.ts +34 -34
  72. package/src/nat/secrets/file-store.ts +130 -130
  73. package/src/nat/secrets/index.ts +112 -112
  74. package/src/nat/secrets/keytar-store.ts +67 -67
  75. package/src/nat/types.ts +159 -159
  76. package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
  77. package/src/orchestrator/host-process-orchestrator.ts +1133 -1133
  78. package/src/orchestrator/index.ts +10 -10
  79. package/src/orchestrator/log-rotator.ts +53 -53
  80. package/src/orchestrator/node-identity.ts +40 -40
  81. package/src/orchestrator/pid-liveness.ts +24 -24
  82. package/src/orchestrator/port-allocator.ts +91 -91
  83. package/src/orchestrator/state-store.ts +90 -90
  84. package/src/orchestrator/types.ts +141 -141
  85. package/src/owner/__tests__/owner-node-client.test.ts +194 -194
  86. package/src/owner/index.ts +12 -12
  87. package/src/owner/owner-node-client.ts +259 -259
  88. package/src/push/__tests__/push.test.ts +90 -90
  89. package/src/push/index.ts +146 -146
  90. package/src/server/__tests__/bus.test.ts +38 -38
  91. package/src/server/__tests__/error-handler.test.ts +118 -118
  92. package/src/server/__tests__/grants-admin-route.test.ts +73 -73
  93. package/src/server/__tests__/grants-route.test.ts +263 -263
  94. package/src/server/__tests__/nodes-route.test.ts +259 -259
  95. package/src/server/__tests__/origin-guard.test.ts +72 -72
  96. package/src/server/__tests__/publishers.test.ts +216 -216
  97. package/src/server/__tests__/server.smoke.test.ts +253 -253
  98. package/src/server/__tests__/settings-route.test.ts +194 -194
  99. package/src/server/__tests__/sse-route.test.ts +119 -119
  100. package/src/server/__tests__/static.test.ts +145 -145
  101. package/src/server/__tests__/status-route.test.ts +134 -134
  102. package/src/server/__tests__/strands-route.test.ts +233 -233
  103. package/src/server/error-handler.ts +178 -178
  104. package/src/server/events/bus.ts +42 -42
  105. package/src/server/events/sse-route.ts +81 -81
  106. package/src/server/events/types.ts +23 -23
  107. package/src/server/index.ts +267 -267
  108. package/src/server/origin-guard.ts +84 -84
  109. package/src/server/routes/grants-admin.ts +41 -41
  110. package/src/server/routes/grants.ts +210 -210
  111. package/src/server/routes/nat.ts +63 -63
  112. package/src/server/routes/nodes.ts +170 -170
  113. package/src/server/routes/settings.ts +123 -123
  114. package/src/server/routes/status.ts +118 -118
  115. package/src/server/routes/strands.ts +54 -54
  116. package/src/server/routes/trust-circle.ts +49 -49
  117. package/src/server/routes/update.ts +43 -43
  118. package/src/server/server.ts +122 -122
  119. package/src/server/settings-store.ts +37 -37
  120. package/src/server/static.ts +207 -207
  121. package/src/strands/__tests__/strand-service.test.ts +165 -165
  122. package/src/strands/index.ts +14 -14
  123. package/src/strands/strand-service.ts +135 -135
  124. package/src/strands/types.ts +102 -102
  125. package/src/update/__tests__/apply.test.ts +158 -158
  126. package/src/update/__tests__/manifest.test.ts +128 -128
  127. package/src/update/__tests__/release-key.test.ts +42 -42
  128. package/src/update/__tests__/sign.test.ts +78 -78
  129. package/src/update/__tests__/store.test.ts +81 -81
  130. package/src/update/__tests__/update-service.integration.test.ts +256 -256
  131. package/src/update/__tests__/version.test.ts +31 -31
  132. package/src/update/apply.ts +190 -190
  133. package/src/update/index.ts +341 -341
  134. package/src/update/release-key.ts +71 -71
  135. package/src/update/sign.ts +74 -74
  136. package/src/update/store.ts +101 -101
  137. package/src/update/types.ts +109 -109
  138. package/src/update/version.ts +80 -80
@@ -1,200 +1,200 @@
1
- import { randomBytes } from 'node:crypto';
2
- import debug from 'debug';
3
-
4
- import type { GrantStore } from './grant-store.js';
5
- import type {
6
- Grant,
7
- GrantAdminHandlers,
8
- GrantValidation,
9
- GrantValidator,
10
- } from './types.js';
11
- import { GrantError } from './types.js';
12
-
13
- const log = debug('cadre:host:grant-service');
14
-
15
- /** Token byte length (base64url-encoded; ~43 chars). Matches trust-circle. */
16
- const TOKEN_BYTES = 32;
17
-
18
- /** Default node cap when the admin doesn't specify one. */
19
- export const DEFAULT_MAX_NODES = 1;
20
-
21
- /** Constructor options. */
22
- export interface GrantServiceOptions {
23
- /** Persistent grant store (`grants.json`). */
24
- store: GrantStore;
25
- /** Clock override for tests. */
26
- now?: () => Date;
27
- }
28
-
29
- /**
30
- * GrantService — issues, validates, lists, and revokes donation grant tokens.
31
- *
32
- * It is the authority for a grant's *identity, expiry, and revocation*, and
33
- * for the per-grantee *node cap* (`maxNodes`). It is deliberately **not** the
34
- * authority for how many nodes are currently live under a grant — that tally
35
- * belongs to the donation service (`2-donation-service`), which persists the
36
- * grant→node records and can see terminations. `validateForProvision` takes
37
- * the count as a parameter so the two never drift.
38
- *
39
- * All methods are synchronous: issuance/validation/revocation are pure local
40
- * store operations with no node round-trip (unlike the trust-circle flow,
41
- * which delegates to the owner node).
42
- */
43
- export class GrantService implements GrantValidator {
44
- private readonly store: GrantStore;
45
- private readonly now: () => Date;
46
-
47
- constructor(opts: GrantServiceOptions) {
48
- this.store = opts.store;
49
- this.now = opts.now ?? (() => new Date());
50
- }
51
-
52
- /**
53
- * Issue a new grant. Generates a high-entropy base64url token, persists it,
54
- * and returns the whole `Grant` (the CLI/UI prints/QR-encodes the token).
55
- */
56
- issue(opts: { label: string; maxNodes?: number; ttlMs?: number }): Grant {
57
- const label = normaliseLabel(opts.label);
58
- const maxNodes = normaliseMaxNodes(opts.maxNodes);
59
-
60
- const created = this.now();
61
- const grant: Grant = {
62
- token: generateToken(),
63
- label,
64
- maxNodes,
65
- createdAt: created.toISOString(),
66
- };
67
-
68
- if (opts.ttlMs !== undefined) {
69
- if (!Number.isFinite(opts.ttlMs) || opts.ttlMs <= 0) {
70
- throw new GrantError('invalid_ttl', `Invalid ttlMs: ${opts.ttlMs}`);
71
- }
72
- grant.expiresAt = new Date(created.getTime() + opts.ttlMs).toISOString();
73
- }
74
-
75
- this.store.add(grant);
76
- log('issued grant "%s" (token=%s, maxNodes=%d, expires=%s)',
77
- label, grant.token, maxNodes, grant.expiresAt ?? 'never');
78
- return grant;
79
- }
80
-
81
- /**
82
- * Validate a presented bearer token for a non-provisioning op: it must name a
83
- * known grant that is neither revoked nor expired. No quota check. Never
84
- * throws for a bad token — returns a denial.
85
- */
86
- validate(token: string): GrantValidation {
87
- if (!token) return { ok: false, reason: 'unknown_token' };
88
- const grant = this.store.get(token);
89
- if (!grant) return { ok: false, reason: 'unknown_token' };
90
-
91
- // Revocation is an explicit admin action; check it before expiry so a
92
- // grant that is both revoked and expired reports the stronger signal.
93
- if (grant.revokedAt) return { ok: false, grant, reason: 'revoked' };
94
-
95
- if (grant.expiresAt) {
96
- const expiresAt = new Date(grant.expiresAt);
97
- if (Number.isFinite(expiresAt.getTime()) && this.now() > expiresAt) {
98
- return { ok: false, grant, reason: 'expired' };
99
- }
100
- }
101
-
102
- return { ok: true, grant };
103
- }
104
-
105
- /**
106
- * Validate for a *new* provision request. Runs the identity/expiry/revocation
107
- * checks of `validate`, then — only if those pass — consults the
108
- * caller-supplied live-node tally against the grant's `maxNodes`.
109
- *
110
- * Concurrency boundary: this is a pure function of the passed count. Two
111
- * provision requests racing at the quota edge (both seeing count = N-1)
112
- * would both pass here; serialising them so only one wins is the donation
113
- * service's responsibility (`2-donation-service` owns the grant→node lock).
114
- */
115
- validateForProvision(
116
- token: string,
117
- liveNodeCount: (token: string) => number,
118
- ): GrantValidation {
119
- const base = this.validate(token);
120
- if (!base.ok || !base.grant) return base;
121
-
122
- const live = liveNodeCount(token);
123
- if (live >= base.grant.maxNodes) {
124
- return { ok: false, grant: base.grant, reason: 'quota_exceeded' };
125
- }
126
- return { ok: true, grant: base.grant };
127
- }
128
-
129
- /**
130
- * Revoke a grant by token: mark it revoked (denies future requests). Existing
131
- * live nodes are **not** torn down here — that is a separate admin action via
132
- * the donation service's terminate (`2-donation-service`). Throws not_found
133
- * when the token is unknown.
134
- */
135
- revoke(token: string): void {
136
- if (!this.store.markRevoked(token, this.now().toISOString())) {
137
- throw new GrantError('not_found', `Unknown grant: ${token}`);
138
- }
139
- log('revoked grant (token=%s)', token);
140
- }
141
-
142
- /** Enumerate all grants (for the CLI / UI). */
143
- list(): Grant[] {
144
- return this.store.list();
145
- }
146
- }
147
-
148
- /**
149
- * Wrap a GrantService into the typed handler shape consumed by the loopback
150
- * management server's `/grants-admin` routes. Errors propagate as GrantError;
151
- * the server maps `.code` → HTTP status.
152
- */
153
- export function createGrantAdminHandlers(service: GrantService): GrantAdminHandlers {
154
- return {
155
- async postGrant(body) {
156
- if (!body || typeof body.label !== 'string') {
157
- throw new GrantError('invalid_label', 'label is required');
158
- }
159
- const grant = service.issue({
160
- label: body.label,
161
- ...(body.maxNodes !== undefined ? { maxNodes: body.maxNodes } : {}),
162
- ...(body.ttlMs !== undefined ? { ttlMs: body.ttlMs } : {}),
163
- });
164
- return { grant };
165
- },
166
- async listGrants() {
167
- return { grants: service.list() };
168
- },
169
- async deleteGrant(token) {
170
- service.revoke(token);
171
- },
172
- };
173
- }
174
-
175
- function generateToken(): string {
176
- // base64url so it survives in URLs, QR codes, and shell arguments.
177
- return randomBytes(TOKEN_BYTES).toString('base64url');
178
- }
179
-
180
- function normaliseLabel(label: unknown): string {
181
- if (typeof label !== 'string') {
182
- throw new GrantError('invalid_label', 'label must be a string');
183
- }
184
- const trimmed = label.trim();
185
- if (trimmed.length === 0) {
186
- throw new GrantError('invalid_label', 'label must not be empty');
187
- }
188
- if (trimmed.length > 200) {
189
- throw new GrantError('invalid_label', 'label must be 200 characters or fewer');
190
- }
191
- return trimmed;
192
- }
193
-
194
- function normaliseMaxNodes(maxNodes: unknown): number {
195
- if (maxNodes === undefined) return DEFAULT_MAX_NODES;
196
- if (typeof maxNodes !== 'number' || !Number.isInteger(maxNodes) || maxNodes < 1) {
197
- throw new GrantError('invalid_max_nodes', `maxNodes must be a positive integer (got ${JSON.stringify(maxNodes)})`);
198
- }
199
- return maxNodes;
200
- }
1
+ import { randomBytes } from 'node:crypto';
2
+ import debug from 'debug';
3
+
4
+ import type { GrantStore } from './grant-store.js';
5
+ import type {
6
+ Grant,
7
+ GrantAdminHandlers,
8
+ GrantValidation,
9
+ GrantValidator,
10
+ } from './types.js';
11
+ import { GrantError } from './types.js';
12
+
13
+ const log = debug('cadre:host:grant-service');
14
+
15
+ /** Token byte length (base64url-encoded; ~43 chars). Matches trust-circle. */
16
+ const TOKEN_BYTES = 32;
17
+
18
+ /** Default node cap when the admin doesn't specify one. */
19
+ export const DEFAULT_MAX_NODES = 1;
20
+
21
+ /** Constructor options. */
22
+ export interface GrantServiceOptions {
23
+ /** Persistent grant store (`grants.json`). */
24
+ store: GrantStore;
25
+ /** Clock override for tests. */
26
+ now?: () => Date;
27
+ }
28
+
29
+ /**
30
+ * GrantService — issues, validates, lists, and revokes donation grant tokens.
31
+ *
32
+ * It is the authority for a grant's *identity, expiry, and revocation*, and
33
+ * for the per-grantee *node cap* (`maxNodes`). It is deliberately **not** the
34
+ * authority for how many nodes are currently live under a grant — that tally
35
+ * belongs to the donation service (`2-donation-service`), which persists the
36
+ * grant→node records and can see terminations. `validateForProvision` takes
37
+ * the count as a parameter so the two never drift.
38
+ *
39
+ * All methods are synchronous: issuance/validation/revocation are pure local
40
+ * store operations with no node round-trip (unlike the trust-circle flow,
41
+ * which delegates to the owner node).
42
+ */
43
+ export class GrantService implements GrantValidator {
44
+ private readonly store: GrantStore;
45
+ private readonly now: () => Date;
46
+
47
+ constructor(opts: GrantServiceOptions) {
48
+ this.store = opts.store;
49
+ this.now = opts.now ?? (() => new Date());
50
+ }
51
+
52
+ /**
53
+ * Issue a new grant. Generates a high-entropy base64url token, persists it,
54
+ * and returns the whole `Grant` (the CLI/UI prints/QR-encodes the token).
55
+ */
56
+ issue(opts: { label: string; maxNodes?: number; ttlMs?: number }): Grant {
57
+ const label = normaliseLabel(opts.label);
58
+ const maxNodes = normaliseMaxNodes(opts.maxNodes);
59
+
60
+ const created = this.now();
61
+ const grant: Grant = {
62
+ token: generateToken(),
63
+ label,
64
+ maxNodes,
65
+ createdAt: created.toISOString(),
66
+ };
67
+
68
+ if (opts.ttlMs !== undefined) {
69
+ if (!Number.isFinite(opts.ttlMs) || opts.ttlMs <= 0) {
70
+ throw new GrantError('invalid_ttl', `Invalid ttlMs: ${opts.ttlMs}`);
71
+ }
72
+ grant.expiresAt = new Date(created.getTime() + opts.ttlMs).toISOString();
73
+ }
74
+
75
+ this.store.add(grant);
76
+ log('issued grant "%s" (token=%s, maxNodes=%d, expires=%s)',
77
+ label, grant.token, maxNodes, grant.expiresAt ?? 'never');
78
+ return grant;
79
+ }
80
+
81
+ /**
82
+ * Validate a presented bearer token for a non-provisioning op: it must name a
83
+ * known grant that is neither revoked nor expired. No quota check. Never
84
+ * throws for a bad token — returns a denial.
85
+ */
86
+ validate(token: string): GrantValidation {
87
+ if (!token) return { ok: false, reason: 'unknown_token' };
88
+ const grant = this.store.get(token);
89
+ if (!grant) return { ok: false, reason: 'unknown_token' };
90
+
91
+ // Revocation is an explicit admin action; check it before expiry so a
92
+ // grant that is both revoked and expired reports the stronger signal.
93
+ if (grant.revokedAt) return { ok: false, grant, reason: 'revoked' };
94
+
95
+ if (grant.expiresAt) {
96
+ const expiresAt = new Date(grant.expiresAt);
97
+ if (Number.isFinite(expiresAt.getTime()) && this.now() > expiresAt) {
98
+ return { ok: false, grant, reason: 'expired' };
99
+ }
100
+ }
101
+
102
+ return { ok: true, grant };
103
+ }
104
+
105
+ /**
106
+ * Validate for a *new* provision request. Runs the identity/expiry/revocation
107
+ * checks of `validate`, then — only if those pass — consults the
108
+ * caller-supplied live-node tally against the grant's `maxNodes`.
109
+ *
110
+ * Concurrency boundary: this is a pure function of the passed count. Two
111
+ * provision requests racing at the quota edge (both seeing count = N-1)
112
+ * would both pass here; serialising them so only one wins is the donation
113
+ * service's responsibility (`2-donation-service` owns the grant→node lock).
114
+ */
115
+ validateForProvision(
116
+ token: string,
117
+ liveNodeCount: (token: string) => number,
118
+ ): GrantValidation {
119
+ const base = this.validate(token);
120
+ if (!base.ok || !base.grant) return base;
121
+
122
+ const live = liveNodeCount(token);
123
+ if (live >= base.grant.maxNodes) {
124
+ return { ok: false, grant: base.grant, reason: 'quota_exceeded' };
125
+ }
126
+ return { ok: true, grant: base.grant };
127
+ }
128
+
129
+ /**
130
+ * Revoke a grant by token: mark it revoked (denies future requests). Existing
131
+ * live nodes are **not** torn down here — that is a separate admin action via
132
+ * the donation service's terminate (`2-donation-service`). Throws not_found
133
+ * when the token is unknown.
134
+ */
135
+ revoke(token: string): void {
136
+ if (!this.store.markRevoked(token, this.now().toISOString())) {
137
+ throw new GrantError('not_found', `Unknown grant: ${token}`);
138
+ }
139
+ log('revoked grant (token=%s)', token);
140
+ }
141
+
142
+ /** Enumerate all grants (for the CLI / UI). */
143
+ list(): Grant[] {
144
+ return this.store.list();
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Wrap a GrantService into the typed handler shape consumed by the loopback
150
+ * management server's `/grants-admin` routes. Errors propagate as GrantError;
151
+ * the server maps `.code` → HTTP status.
152
+ */
153
+ export function createGrantAdminHandlers(service: GrantService): GrantAdminHandlers {
154
+ return {
155
+ async postGrant(body) {
156
+ if (!body || typeof body.label !== 'string') {
157
+ throw new GrantError('invalid_label', 'label is required');
158
+ }
159
+ const grant = service.issue({
160
+ label: body.label,
161
+ ...(body.maxNodes !== undefined ? { maxNodes: body.maxNodes } : {}),
162
+ ...(body.ttlMs !== undefined ? { ttlMs: body.ttlMs } : {}),
163
+ });
164
+ return { grant };
165
+ },
166
+ async listGrants() {
167
+ return { grants: service.list() };
168
+ },
169
+ async deleteGrant(token) {
170
+ service.revoke(token);
171
+ },
172
+ };
173
+ }
174
+
175
+ function generateToken(): string {
176
+ // base64url so it survives in URLs, QR codes, and shell arguments.
177
+ return randomBytes(TOKEN_BYTES).toString('base64url');
178
+ }
179
+
180
+ function normaliseLabel(label: unknown): string {
181
+ if (typeof label !== 'string') {
182
+ throw new GrantError('invalid_label', 'label must be a string');
183
+ }
184
+ const trimmed = label.trim();
185
+ if (trimmed.length === 0) {
186
+ throw new GrantError('invalid_label', 'label must not be empty');
187
+ }
188
+ if (trimmed.length > 200) {
189
+ throw new GrantError('invalid_label', 'label must be 200 characters or fewer');
190
+ }
191
+ return trimmed;
192
+ }
193
+
194
+ function normaliseMaxNodes(maxNodes: unknown): number {
195
+ if (maxNodes === undefined) return DEFAULT_MAX_NODES;
196
+ if (typeof maxNodes !== 'number' || !Number.isInteger(maxNodes) || maxNodes < 1) {
197
+ throw new GrantError('invalid_max_nodes', `maxNodes must be a positive integer (got ${JSON.stringify(maxNodes)})`);
198
+ }
199
+ return maxNodes;
200
+ }