@serfab/cadre-host 0.10.0 → 0.12.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 (158) hide show
  1. package/README.md +406 -296
  2. package/dist/bin/host.d.ts +11 -7
  3. package/dist/bin/host.js +40 -12
  4. package/dist/bin/host.js.map +1 -1
  5. package/dist/installer/config.d.ts +4 -4
  6. package/dist/installer/config.js +5 -39
  7. package/dist/installer/config.js.map +1 -1
  8. package/dist/installer/identity.d.ts +8 -1
  9. package/dist/installer/identity.js +8 -1
  10. package/dist/installer/identity.js.map +1 -1
  11. package/dist/orchestrator/host-process-orchestrator.d.ts +2 -2
  12. package/dist/orchestrator/host-process-orchestrator.js +9 -4
  13. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  14. package/dist/server/settings-store.d.ts +1 -1
  15. package/dist/server/settings-store.js +5 -1
  16. package/dist/server/settings-store.js.map +1 -1
  17. package/dist/server/static.js +38 -38
  18. package/dist/ui/assets/index-C-iks41M.js.map +1 -1
  19. package/dist/ui/index.html +12 -12
  20. package/package.json +4 -4
  21. package/service/cadre-host.service.tmpl +16 -16
  22. package/service/com.serfab.cadre-host.plist.tmpl +35 -35
  23. package/service/install-service.ps1 +98 -98
  24. package/service/uninstall-service.ps1 +19 -19
  25. package/src/__tests__/build-targets.test.ts +26 -26
  26. package/src/__tests__/cli-invite.smoke.test.ts +192 -192
  27. package/src/__tests__/cli-nat.smoke.test.ts +212 -212
  28. package/src/__tests__/cli.smoke.test.ts +51 -46
  29. package/src/__tests__/global-setup.ts +56 -51
  30. package/src/__tests__/host-owner.smoke.test.ts +123 -123
  31. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -140
  32. package/src/__tests__/orchestrator-node-identity.test.ts +5 -5
  33. package/src/__tests__/orchestrator-owner.test.ts +198 -198
  34. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
  35. package/src/__tests__/orchestrator-push.test.ts +145 -145
  36. package/src/__tests__/orchestrator.test.ts +951 -951
  37. package/src/auth/__tests__/duration.test.ts +28 -28
  38. package/src/auth/__tests__/self-label.test.ts +85 -85
  39. package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
  40. package/src/auth/__tests__/trust-circle.test.ts +445 -445
  41. package/src/auth/duration.ts +33 -33
  42. package/src/auth/index.ts +21 -21
  43. package/src/auth/self-label.ts +74 -74
  44. package/src/auth/trust-circle-store.ts +155 -155
  45. package/src/auth/types.ts +99 -99
  46. package/src/bin/__tests__/cli-reference.test.ts +167 -0
  47. package/src/bin/host.ts +1326 -1299
  48. package/src/donation/__tests__/donation-service.test.ts +1068 -1068
  49. package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
  50. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
  51. package/src/donation/__tests__/fake-orchestrator.ts +225 -225
  52. package/src/donation/__tests__/grant-service.test.ts +213 -213
  53. package/src/donation/__tests__/grant-store.test.ts +113 -113
  54. package/src/donation/donation-service.ts +962 -962
  55. package/src/donation/donation-store.ts +171 -171
  56. package/src/donation/donation-supervisor.ts +388 -388
  57. package/src/donation/grant-service.ts +200 -200
  58. package/src/donation/grant-store.ts +152 -152
  59. package/src/donation/index.ts +64 -64
  60. package/src/donation/types.ts +243 -243
  61. package/src/installer/__tests__/config.test.ts +134 -112
  62. package/src/installer/__tests__/identity.test.ts +34 -34
  63. package/src/installer/__tests__/paths.test.ts +47 -47
  64. package/src/installer/__tests__/service-host.test.ts +62 -62
  65. package/src/installer/__tests__/wizard.test.ts +64 -64
  66. package/src/installer/config.ts +5 -43
  67. package/src/installer/identity.ts +56 -49
  68. package/src/installer/paths.ts +109 -109
  69. package/src/installer/platform.ts +27 -27
  70. package/src/installer/service-host/index.ts +21 -21
  71. package/src/installer/service-host/template.ts +21 -21
  72. package/src/installer/wizard.ts +103 -103
  73. package/src/nat/__tests__/address-resolver.test.ts +82 -82
  74. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
  75. package/src/nat/__tests__/ddns-updater.test.ts +253 -253
  76. package/src/nat/__tests__/external-ip.test.ts +124 -124
  77. package/src/nat/__tests__/nat-service.test.ts +18 -0
  78. package/src/nat/__tests__/nat-store.test.ts +93 -93
  79. package/src/nat/__tests__/port-mapper.test.ts +191 -191
  80. package/src/nat/__tests__/reachability.test.ts +24 -24
  81. package/src/nat/__tests__/secrets.test.ts +178 -178
  82. package/src/nat/address-resolver.ts +51 -51
  83. package/src/nat/ddns/duckdns.ts +76 -76
  84. package/src/nat/ddns/index.ts +55 -55
  85. package/src/nat/ddns/updater.ts +243 -243
  86. package/src/nat/external-ip.ts +154 -154
  87. package/src/nat/index.ts +71 -71
  88. package/src/nat/nat-service.ts +501 -501
  89. package/src/nat/nat-store.ts +154 -154
  90. package/src/nat/port-mapper.ts +329 -329
  91. package/src/nat/reachability.ts +34 -34
  92. package/src/nat/secrets/file-store.ts +130 -130
  93. package/src/nat/secrets/index.ts +112 -112
  94. package/src/nat/secrets/keytar-store.ts +67 -67
  95. package/src/nat/types.ts +159 -159
  96. package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
  97. package/src/orchestrator/host-process-orchestrator.ts +9 -4
  98. package/src/orchestrator/index.ts +10 -10
  99. package/src/orchestrator/log-rotator.ts +53 -53
  100. package/src/orchestrator/node-identity.ts +40 -40
  101. package/src/orchestrator/pid-liveness.ts +24 -24
  102. package/src/orchestrator/port-allocator.ts +91 -91
  103. package/src/orchestrator/state-store.ts +90 -90
  104. package/src/orchestrator/types.ts +141 -141
  105. package/src/owner/__tests__/owner-node-client.test.ts +194 -194
  106. package/src/owner/index.ts +12 -12
  107. package/src/owner/owner-node-client.ts +259 -259
  108. package/src/push/__tests__/push.test.ts +90 -90
  109. package/src/push/index.ts +146 -146
  110. package/src/server/__tests__/bus.test.ts +38 -38
  111. package/src/server/__tests__/error-handler.test.ts +118 -118
  112. package/src/server/__tests__/grants-admin-route.test.ts +73 -73
  113. package/src/server/__tests__/grants-route.test.ts +263 -263
  114. package/src/server/__tests__/nodes-route.test.ts +259 -259
  115. package/src/server/__tests__/origin-guard.test.ts +72 -72
  116. package/src/server/__tests__/publishers.test.ts +216 -216
  117. package/src/server/__tests__/server.smoke.test.ts +253 -253
  118. package/src/server/__tests__/settings-route.test.ts +195 -194
  119. package/src/server/__tests__/sse-route.test.ts +119 -119
  120. package/src/server/__tests__/static.test.ts +145 -145
  121. package/src/server/__tests__/status-route.test.ts +134 -134
  122. package/src/server/__tests__/strands-route.test.ts +233 -233
  123. package/src/server/error-handler.ts +178 -178
  124. package/src/server/events/bus.ts +42 -42
  125. package/src/server/events/sse-route.ts +81 -81
  126. package/src/server/events/types.ts +23 -23
  127. package/src/server/index.ts +267 -267
  128. package/src/server/origin-guard.ts +84 -84
  129. package/src/server/routes/grants-admin.ts +41 -41
  130. package/src/server/routes/grants.ts +210 -210
  131. package/src/server/routes/nat.ts +63 -63
  132. package/src/server/routes/nodes.ts +170 -170
  133. package/src/server/routes/settings.ts +123 -123
  134. package/src/server/routes/status.ts +118 -118
  135. package/src/server/routes/strands.ts +54 -54
  136. package/src/server/routes/trust-circle.ts +49 -49
  137. package/src/server/routes/update.ts +43 -43
  138. package/src/server/server.ts +122 -122
  139. package/src/server/settings-store.ts +5 -1
  140. package/src/server/static.ts +207 -207
  141. package/src/strands/__tests__/strand-service.test.ts +165 -165
  142. package/src/strands/index.ts +14 -14
  143. package/src/strands/strand-service.ts +135 -135
  144. package/src/strands/types.ts +102 -102
  145. package/src/update/__tests__/apply.test.ts +158 -158
  146. package/src/update/__tests__/manifest.test.ts +128 -128
  147. package/src/update/__tests__/release-key.test.ts +42 -42
  148. package/src/update/__tests__/sign.test.ts +78 -78
  149. package/src/update/__tests__/store.test.ts +81 -81
  150. package/src/update/__tests__/update-service.integration.test.ts +256 -256
  151. package/src/update/__tests__/version.test.ts +31 -31
  152. package/src/update/apply.ts +190 -190
  153. package/src/update/index.ts +341 -341
  154. package/src/update/release-key.ts +71 -71
  155. package/src/update/sign.ts +74 -74
  156. package/src/update/store.ts +101 -101
  157. package/src/update/types.ts +109 -109
  158. 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
+ }