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