@serfab/cadre-host 0.8.1 → 0.10.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 (198) 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 +36 -9
  8. package/dist/auth/trust-circle.js +40 -20
  9. package/dist/auth/trust-circle.js.map +1 -1
  10. package/dist/auth/types.d.ts +2 -2
  11. package/dist/bin/host.js +291 -54
  12. package/dist/bin/host.js.map +1 -1
  13. package/dist/donation/__tests__/fake-orchestrator.d.ts +103 -0
  14. package/dist/donation/__tests__/fake-orchestrator.js +180 -0
  15. package/dist/donation/__tests__/fake-orchestrator.js.map +1 -0
  16. package/dist/donation/donation-service.d.ts +361 -0
  17. package/dist/donation/donation-service.js +763 -0
  18. package/dist/donation/donation-service.js.map +1 -0
  19. package/dist/donation/donation-store.d.ts +49 -0
  20. package/dist/donation/donation-store.js +148 -0
  21. package/dist/donation/donation-store.js.map +1 -0
  22. package/dist/donation/donation-supervisor.d.ts +167 -0
  23. package/dist/donation/donation-supervisor.js +341 -0
  24. package/dist/donation/donation-supervisor.js.map +1 -0
  25. package/dist/donation/grant-service.d.ts +71 -0
  26. package/dist/donation/grant-service.js +163 -0
  27. package/dist/donation/grant-service.js.map +1 -0
  28. package/dist/donation/grant-store.d.ts +38 -0
  29. package/dist/donation/grant-store.js +135 -0
  30. package/dist/donation/grant-store.js.map +1 -0
  31. package/dist/donation/index.d.ts +20 -0
  32. package/dist/donation/index.js +17 -0
  33. package/dist/donation/index.js.map +1 -0
  34. package/dist/donation/types.d.ts +201 -0
  35. package/dist/donation/types.js +40 -0
  36. package/dist/donation/types.js.map +1 -0
  37. package/dist/index.d.ts +9 -5
  38. package/dist/index.js +7 -3
  39. package/dist/index.js.map +1 -1
  40. package/dist/installer/config.d.ts +26 -0
  41. package/dist/installer/config.js +13 -0
  42. package/dist/installer/config.js.map +1 -1
  43. package/dist/installer/index.d.ts +2 -0
  44. package/dist/installer/index.js +3 -0
  45. package/dist/installer/index.js.map +1 -1
  46. package/dist/installer/wizard.d.ts +4 -0
  47. package/dist/installer/wizard.js +5 -1
  48. package/dist/installer/wizard.js.map +1 -1
  49. package/dist/nat/nat-service.d.ts +5 -5
  50. package/dist/nat/nat-service.js +9 -9
  51. package/dist/nat/nat-service.js.map +1 -1
  52. package/dist/nat/types.d.ts +1 -1
  53. package/dist/orchestrator/host-process-orchestrator.d.ts +144 -32
  54. package/dist/orchestrator/host-process-orchestrator.js +397 -128
  55. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  56. package/dist/orchestrator/index.d.ts +3 -3
  57. package/dist/orchestrator/index.js +1 -1
  58. package/dist/orchestrator/index.js.map +1 -1
  59. package/dist/orchestrator/node-identity.d.ts +26 -0
  60. package/dist/orchestrator/node-identity.js +36 -0
  61. package/dist/orchestrator/node-identity.js.map +1 -0
  62. package/dist/orchestrator/port-allocator.d.ts +14 -0
  63. package/dist/orchestrator/port-allocator.js +45 -0
  64. package/dist/orchestrator/port-allocator.js.map +1 -1
  65. package/dist/orchestrator/state-store.d.ts +5 -5
  66. package/dist/orchestrator/state-store.js +2 -2
  67. package/dist/orchestrator/state-store.js.map +1 -1
  68. package/dist/orchestrator/types.d.ts +8 -8
  69. package/dist/owner/index.d.ts +11 -0
  70. package/dist/owner/index.js +11 -0
  71. package/dist/owner/index.js.map +1 -0
  72. package/dist/owner/owner-node-client.d.ts +110 -0
  73. package/dist/{authority/authority-node-client.js → owner/owner-node-client.js} +58 -17
  74. package/dist/owner/owner-node-client.js.map +1 -0
  75. package/dist/server/error-handler.d.ts +22 -1
  76. package/dist/server/error-handler.js +64 -1
  77. package/dist/server/error-handler.js.map +1 -1
  78. package/dist/server/events/types.d.ts +3 -0
  79. package/dist/server/index.d.ts +33 -2
  80. package/dist/server/index.js +43 -16
  81. package/dist/server/index.js.map +1 -1
  82. package/dist/server/routes/grants-admin.d.ts +17 -0
  83. package/dist/server/routes/grants-admin.js +32 -0
  84. package/dist/server/routes/grants-admin.js.map +1 -0
  85. package/dist/server/routes/grants.d.ts +28 -0
  86. package/dist/server/routes/grants.js +182 -0
  87. package/dist/server/routes/grants.js.map +1 -0
  88. package/dist/server/routes/nodes.d.ts +5 -4
  89. package/dist/server/routes/nodes.js +19 -18
  90. package/dist/server/routes/nodes.js.map +1 -1
  91. package/dist/server/routes/settings.d.ts +6 -1
  92. package/dist/server/routes/settings.js +5 -2
  93. package/dist/server/routes/settings.js.map +1 -1
  94. package/dist/server/routes/status.d.ts +8 -4
  95. package/dist/server/routes/status.js +9 -4
  96. package/dist/server/routes/status.js.map +1 -1
  97. package/dist/server/routes/strands.d.ts +20 -0
  98. package/dist/server/routes/strands.js +38 -0
  99. package/dist/server/routes/strands.js.map +1 -0
  100. package/dist/strands/index.d.ts +7 -0
  101. package/dist/strands/index.js +6 -0
  102. package/dist/strands/index.js.map +1 -0
  103. package/dist/strands/strand-service.d.ts +50 -0
  104. package/dist/strands/strand-service.js +103 -0
  105. package/dist/strands/strand-service.js.map +1 -0
  106. package/dist/strands/types.d.ts +93 -0
  107. package/dist/strands/types.js +23 -0
  108. package/dist/strands/types.js.map +1 -0
  109. package/dist/ui/assets/index-BVoqLjyX.css +1 -0
  110. package/dist/ui/assets/index-C-iks41M.js +17 -0
  111. package/dist/ui/assets/index-C-iks41M.js.map +1 -0
  112. package/dist/ui/index.html +2 -2
  113. package/dist/update/release-key.d.ts +1 -1
  114. package/dist/update/release-key.js +8 -6
  115. package/dist/update/release-key.js.map +1 -1
  116. package/package.json +5 -5
  117. package/src/__tests__/build-targets.test.ts +26 -0
  118. package/src/__tests__/global-setup.ts +51 -0
  119. package/src/__tests__/{host-authority.smoke.test.ts → host-owner.smoke.test.ts} +7 -7
  120. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -0
  121. package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
  122. package/src/__tests__/{orchestrator-authority.test.ts → orchestrator-owner.test.ts} +33 -33
  123. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
  124. package/src/__tests__/orchestrator-push.test.ts +10 -10
  125. package/src/__tests__/orchestrator.test.ts +387 -6
  126. package/src/auth/__tests__/self-label.test.ts +85 -0
  127. package/src/auth/__tests__/trust-circle-integration.test.ts +152 -147
  128. package/src/auth/__tests__/trust-circle.test.ts +55 -11
  129. package/src/auth/index.ts +2 -0
  130. package/src/auth/self-label.ts +74 -0
  131. package/src/auth/trust-circle.ts +54 -22
  132. package/src/auth/types.ts +2 -2
  133. package/src/bin/host.ts +1299 -1038
  134. package/src/donation/__tests__/donation-service.test.ts +1068 -0
  135. package/src/donation/__tests__/donation-supervisor.test.ts +510 -0
  136. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -0
  137. package/src/donation/__tests__/fake-orchestrator.ts +225 -0
  138. package/src/donation/__tests__/grant-service.test.ts +213 -0
  139. package/src/donation/__tests__/grant-store.test.ts +113 -0
  140. package/src/donation/donation-service.ts +962 -0
  141. package/src/donation/donation-store.ts +171 -0
  142. package/src/donation/donation-supervisor.ts +388 -0
  143. package/src/donation/grant-service.ts +200 -0
  144. package/src/donation/grant-store.ts +152 -0
  145. package/src/donation/index.ts +64 -0
  146. package/src/donation/types.ts +243 -0
  147. package/src/index.ts +51 -5
  148. package/src/installer/__tests__/config.test.ts +27 -1
  149. package/src/installer/__tests__/installer.smoke.test.ts +31 -1
  150. package/src/installer/__tests__/wizard.test.ts +5 -1
  151. package/src/installer/config.ts +34 -0
  152. package/src/installer/index.ts +5 -0
  153. package/src/installer/wizard.ts +12 -1
  154. package/src/nat/__tests__/nat-service.test.ts +5 -5
  155. package/src/nat/nat-service.ts +10 -10
  156. package/src/nat/types.ts +1 -1
  157. package/src/orchestrator/__tests__/node-identity.test.ts +62 -0
  158. package/src/orchestrator/host-process-orchestrator.ts +401 -134
  159. package/src/orchestrator/index.ts +3 -3
  160. package/src/orchestrator/node-identity.ts +40 -0
  161. package/src/orchestrator/port-allocator.ts +48 -0
  162. package/src/orchestrator/state-store.ts +7 -7
  163. package/src/orchestrator/types.ts +8 -8
  164. package/src/{authority/__tests__/authority-node-client.test.ts → owner/__tests__/owner-node-client.test.ts} +45 -17
  165. package/src/owner/index.ts +12 -0
  166. package/src/{authority/authority-node-client.ts → owner/owner-node-client.ts} +83 -23
  167. package/src/server/__tests__/error-handler.test.ts +22 -0
  168. package/src/server/__tests__/grants-admin-route.test.ts +73 -0
  169. package/src/server/__tests__/grants-route.test.ts +263 -0
  170. package/src/server/__tests__/nodes-route.test.ts +25 -25
  171. package/src/server/__tests__/server.smoke.test.ts +75 -0
  172. package/src/server/__tests__/settings-route.test.ts +17 -0
  173. package/src/server/__tests__/status-route.test.ts +21 -0
  174. package/src/server/__tests__/strands-route.test.ts +233 -0
  175. package/src/server/error-handler.ts +67 -1
  176. package/src/server/events/types.ts +1 -0
  177. package/src/server/index.ts +75 -17
  178. package/src/server/routes/grants-admin.ts +41 -0
  179. package/src/server/routes/grants.ts +210 -0
  180. package/src/server/routes/nodes.ts +19 -18
  181. package/src/server/routes/settings.ts +11 -3
  182. package/src/server/routes/status.ts +18 -8
  183. package/src/server/routes/strands.ts +54 -0
  184. package/src/strands/__tests__/strand-service.test.ts +165 -0
  185. package/src/strands/index.ts +14 -0
  186. package/src/strands/strand-service.ts +135 -0
  187. package/src/strands/types.ts +102 -0
  188. package/src/update/__tests__/release-key.test.ts +16 -3
  189. package/src/update/release-key.ts +8 -6
  190. package/dist/authority/authority-node-client.d.ts +0 -77
  191. package/dist/authority/authority-node-client.js.map +0 -1
  192. package/dist/authority/index.d.ts +0 -11
  193. package/dist/authority/index.js +0 -11
  194. package/dist/authority/index.js.map +0 -1
  195. package/dist/ui/assets/index-BlMXQPSw.js +0 -14
  196. package/dist/ui/assets/index-BlMXQPSw.js.map +0 -1
  197. package/dist/ui/assets/index-BwU5mJGF.css +0 -1
  198. package/src/authority/index.ts +0 -12
@@ -0,0 +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
+ }
@@ -0,0 +1,152 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import debug from 'debug';
4
+
5
+ import type { Grant, GrantFile } from './types.js';
6
+ import { GrantError } from './types.js';
7
+
8
+ const log = debug('cadre:host:grant-store');
9
+
10
+ const FILE_VERSION = 1;
11
+
12
+ /**
13
+ * Atomic JSON store for `grants.json`. Modelled on `auth/trust-circle-store.ts`:
14
+ * a single file, written to `<path>.tmp` then renamed, with an in-memory cache
15
+ * keyed by token.
16
+ *
17
+ * Unlike the trust-circle pending rows, grants are **not** self-reaping: a
18
+ * grant may legitimately outlive many nodes, so expiry is evaluated at
19
+ * validate time (in `GrantService`) and the admin removes stale grants
20
+ * explicitly. This store keeps no clock.
21
+ *
22
+ * Concurrency assumption: only one cadre-host process owns a given rootDir
23
+ * (the orchestrator already enforces this). No file-locking primitives.
24
+ */
25
+ export class GrantStore {
26
+ private readonly path: string;
27
+ private cache: GrantFile | null = null;
28
+
29
+ constructor(rootDir: string) {
30
+ mkdirSync(rootDir, { recursive: true });
31
+ this.path = join(rootDir, 'grants.json');
32
+ }
33
+
34
+ filePath(): string {
35
+ return this.path;
36
+ }
37
+
38
+ /**
39
+ * Load (with caching). A missing file returns an empty store (fresh
40
+ * install). A present-but-malformed file throws — silently wiping it would
41
+ * revoke every grantee at once.
42
+ */
43
+ load(): GrantFile {
44
+ if (this.cache) return this.cache;
45
+ if (!existsSync(this.path)) {
46
+ this.cache = emptyFile();
47
+ return this.cache;
48
+ }
49
+ let raw: string;
50
+ try {
51
+ raw = readFileSync(this.path, 'utf8');
52
+ } catch (err) {
53
+ throw new GrantError(
54
+ 'storage_error',
55
+ `failed to read grants file at ${this.path}: ${(err as Error).message}`,
56
+ );
57
+ }
58
+ let parsed: unknown;
59
+ try {
60
+ parsed = JSON.parse(raw);
61
+ } catch (err) {
62
+ throw new GrantError(
63
+ 'storage_error',
64
+ `grants file at ${this.path} is not valid JSON: ${(err as Error).message}`,
65
+ );
66
+ }
67
+ if (!isGrantFile(parsed)) {
68
+ throw new GrantError(
69
+ 'storage_error',
70
+ `grants file at ${this.path} has unexpected shape`,
71
+ );
72
+ }
73
+ this.cache = parsed;
74
+ return this.cache;
75
+ }
76
+
77
+ /** Persist the current state atomically (write then rename). */
78
+ save(state: GrantFile): void {
79
+ // NOTE: rewrites the whole grants.json on every mutation. Fine at household
80
+ // scale (a handful of grantees); if grant counts ever grow large, switch to
81
+ // an append/compact log or per-token files.
82
+ mkdirSync(dirname(this.path), { recursive: true });
83
+ const payload = JSON.stringify({ ...state, version: FILE_VERSION }, null, 2);
84
+ const tmp = `${this.path}.tmp`;
85
+ try {
86
+ writeFileSync(tmp, payload, { encoding: 'utf8' });
87
+ renameSync(tmp, this.path);
88
+ } catch (err) {
89
+ // Surface as storage_error (→ 500) like load(), not a bare Error (→
90
+ // "internal"); a write failure on issue/revoke should read consistently.
91
+ throw new GrantError(
92
+ 'storage_error',
93
+ `failed to write grants file at ${this.path}: ${(err as Error).message}`,
94
+ );
95
+ }
96
+ this.cache = { ...state, version: FILE_VERSION };
97
+ log('saved grants (%d) to %s', Object.keys(state.grants).length, this.path);
98
+ }
99
+
100
+ add(grant: Grant): void {
101
+ const state = this.load();
102
+ const { token, ...rest } = grant;
103
+ state.grants[token] = rest;
104
+ this.save(state);
105
+ }
106
+
107
+ get(token: string): Grant | undefined {
108
+ const state = this.load();
109
+ const row = state.grants[token];
110
+ if (!row) return undefined;
111
+ return { token, ...row };
112
+ }
113
+
114
+ list(): Grant[] {
115
+ const state = this.load();
116
+ return Object.entries(state.grants).map(([token, row]) => ({ token, ...row }));
117
+ }
118
+
119
+ remove(token: string): boolean {
120
+ const state = this.load();
121
+ if (!(token in state.grants)) return false;
122
+ delete state.grants[token];
123
+ this.save(state);
124
+ return true;
125
+ }
126
+
127
+ /**
128
+ * Mark a grant revoked at the given ISO instant. Idempotent: an
129
+ * already-revoked grant keeps its original `revokedAt`. Returns false when
130
+ * the token is unknown.
131
+ */
132
+ markRevoked(token: string, at: string): boolean {
133
+ const state = this.load();
134
+ const row = state.grants[token];
135
+ if (!row) return false;
136
+ if (!row.revokedAt) row.revokedAt = at;
137
+ this.save(state);
138
+ return true;
139
+ }
140
+ }
141
+
142
+ function emptyFile(): GrantFile {
143
+ return { version: FILE_VERSION, grants: {} };
144
+ }
145
+
146
+ function isGrantFile(v: unknown): v is GrantFile {
147
+ if (!v || typeof v !== 'object') return false;
148
+ const obj = v as Record<string, unknown>;
149
+ if (obj.version !== FILE_VERSION) return false;
150
+ if (!obj.grants || typeof obj.grants !== 'object') return false;
151
+ return true;
152
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Donation grant-token layer for cadre-host. See ./types.ts, ./grant-store.ts,
3
+ * and ./grant-service.ts.
4
+ *
5
+ * This layer gates *who may ask this host to donate a node*. The lifecycle
6
+ * service that consumes a validated grant to actually provision a node lands in
7
+ * the `2-donation-service` ticket, which imports the `GrantValidator` interface
8
+ * defined here.
9
+ */
10
+
11
+ export { GrantStore } from './grant-store.js';
12
+ export {
13
+ GrantService,
14
+ createGrantAdminHandlers,
15
+ DEFAULT_MAX_NODES,
16
+ } from './grant-service.js';
17
+ export type { GrantServiceOptions } from './grant-service.js';
18
+ export { GrantError, DonationError } from './types.js';
19
+ export type {
20
+ Grant,
21
+ GrantDenyReason,
22
+ GrantValidation,
23
+ GrantValidator,
24
+ GrantFile,
25
+ GrantAdminHandlers,
26
+ GrantErrorCode,
27
+ } from './types.js';
28
+
29
+ /* ──────────────── donation lifecycle (2-donation-service) ──────────────── */
30
+
31
+ export { DonationStore } from './donation-store.js';
32
+ export {
33
+ DonationService,
34
+ DONATION_AWAITING_SEED_TTL_MS,
35
+ DONATION_PROVISIONING_TTL_MS,
36
+ DONATION_REAP_SWEEP_MS,
37
+ } from './donation-service.js';
38
+ export type {
39
+ DonationOrchestrator,
40
+ DonationProvisionRequest,
41
+ DonationSeedResult,
42
+ DonationPeerInfo,
43
+ DonationServiceOptions,
44
+ RespawnResult,
45
+ } from './donation-service.js';
46
+ export {
47
+ DonationSupervisor,
48
+ DONATION_RESPAWN_BACKOFF_BASE_MS,
49
+ DONATION_RESPAWN_BACKOFF_MAX_MS,
50
+ DONATION_RESPAWN_MAX_ATTEMPTS,
51
+ DONATION_RESPAWN_HEALTHY_MS,
52
+ DONATION_RESPAWN_SWEEP_MS,
53
+ } from './donation-supervisor.js';
54
+ export type {
55
+ DonationSupervisorOptions,
56
+ SupervisedOrchestrator,
57
+ } from './donation-supervisor.js';
58
+ export type {
59
+ Donation,
60
+ DonationView,
61
+ DonationStatus,
62
+ DonationFile,
63
+ DonationErrorCode,
64
+ } from './types.js';
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Donation grant-token types for cadre-host.
3
+ *
4
+ * A **grant token** is a high-entropy base64url secret the host admin issues,
5
+ * out-of-band (QR / copy-paste), to one *grantee* — a friend or family member
6
+ * whose cadre authority (their phone) may then ask this host to spawn donated
7
+ * nodes. The grantee presents the token as `Authorization: Bearer <grant-token>`
8
+ * on every donation request (the grantee-facing surface lands in the
9
+ * `2-donation-service` ticket).
10
+ *
11
+ * This module owns the grant's identity, expiry, and per-grantee quota. The
12
+ * *live-node tally* it checks the quota against is owned by the donation
13
+ * service, not by the grant layer — see `GrantValidator.validateForProvision`.
14
+ *
15
+ * **Grant ≠ trust-circle membership.** A trust-circle invite (`auth/`)
16
+ * authorises a device to join *the host's own* cadre. A donation grant
17
+ * authorises an external cadre authority to ask the host to spawn a node that
18
+ * joins *the grantee's* cadre. The two token flows are deliberately separate
19
+ * modules; this one reuses the *shape* of the trust-circle store (atomic
20
+ * write-then-rename JSON, token-keyed rows) but with grant — not invite —
21
+ * semantics: a grant is long-lived and reusable up to a quota, never
22
+ * one-time-redeemed.
23
+ */
24
+
25
+ /** A grant the host admin issued to one grantee (friend/family). */
26
+ export interface Grant {
27
+ /** base64url secret; also the bearer credential. The store key. */
28
+ token: string;
29
+ /** Human label chosen by the admin, e.g. "Alice's cadre". Display-only. */
30
+ label: string;
31
+ /** Max concurrently-live donated nodes this grant may hold. */
32
+ maxNodes: number;
33
+ /** ISO timestamp when the grant was issued. */
34
+ createdAt: string;
35
+ /** ISO expiry; absent = no expiry. */
36
+ expiresAt?: string;
37
+ /** Set when the admin revokes; a revoked grant validates as denied. */
38
+ revokedAt?: string;
39
+ }
40
+
41
+ /** Why a presented grant token was denied. */
42
+ export type GrantDenyReason =
43
+ | 'unknown_token' // no such grant
44
+ | 'expired'
45
+ | 'revoked'
46
+ | 'quota_exceeded'; // live node count already at maxNodes
47
+
48
+ /** Result of validating a presented bearer token. */
49
+ export interface GrantValidation {
50
+ ok: boolean;
51
+ /** The matched grant, when the token names one (present even on some denials). */
52
+ grant?: Grant;
53
+ /** Set when `ok` is false. */
54
+ reason?: GrantDenyReason;
55
+ }
56
+
57
+ /**
58
+ * The slice `2-donation-service` depends on. Keeps that ticket decoupled from
59
+ * the store implementation: it holds a `GrantValidator`, not a `GrantStore`.
60
+ *
61
+ * Both methods are pure functions of the store state (and, for provision, the
62
+ * caller-supplied count) — they never throw for a bad token, they return a
63
+ * denial. Issuance/revocation (which mutate) live on `GrantService`.
64
+ */
65
+ export interface GrantValidator {
66
+ /**
67
+ * Validate a presented bearer token for a *new* provision request.
68
+ *
69
+ * `liveNodeCount(token)` is supplied by the donation service — the grant
70
+ * layer owns identity/expiry/revocation; the donation service owns the
71
+ * authoritative live-node tally (it holds the grant→node records). The count
72
+ * is only consulted once identity/expiry/revocation have passed, so an
73
+ * unknown or dead token never invokes it.
74
+ */
75
+ validateForProvision(
76
+ token: string,
77
+ liveNodeCount: (token: string) => number,
78
+ ): GrantValidation;
79
+ /**
80
+ * Validate for a non-provisioning op (peer/seed/terminate): identity + not
81
+ * expired/revoked. No quota check.
82
+ */
83
+ validate(token: string): GrantValidation;
84
+ }
85
+
86
+ /** On-disk shape of `grants.json`. */
87
+ export interface GrantFile {
88
+ version: 1;
89
+ /** keyed by token. */
90
+ grants: Record<string, Omit<Grant, 'token'>>;
91
+ }
92
+
93
+ /**
94
+ * Typed handlers exposed to the loopback management server for the **admin**
95
+ * grant surface (`/grants-admin`). Distinct from the grantee-facing `/grants`
96
+ * provisioning surface (`2-donation-service`): the admin surface is loopback,
97
+ * no bearer — same-machine admin, matching cadre-host's local-UI "no login"
98
+ * posture.
99
+ *
100
+ * Handlers throw a `GrantError` whose `.code` the server maps to an HTTP
101
+ * status (see `server/error-handler.ts`).
102
+ */
103
+ export interface GrantAdminHandlers {
104
+ postGrant(body: { label: string; maxNodes?: number; ttlMs?: number }): Promise<{ grant: Grant }>;
105
+ listGrants(): Promise<{ grants: Grant[] }>;
106
+ deleteGrant(token: string): Promise<void>;
107
+ }
108
+
109
+ /* ──────────────── donation lifecycle (2-donation-service) ──────────────── */
110
+
111
+ /**
112
+ * Lifecycle of one donated node:
113
+ * provisioning → the child process is being spawned.
114
+ * awaiting_seed → spawned + reachable; waiting for the requester to present
115
+ * its phone-signed seed (`PUT /grants/:id/seed`).
116
+ * seeded → the seed was applied; the node has joined the requester's cadre.
117
+ * error → provisioning failed (orchestrator resources reclaimed).
118
+ * terminated → stopped + removed (kept in the store for audit).
119
+ *
120
+ * A donation is "live" (counts against a grant's `maxNodes` quota) while it is
121
+ * `provisioning`, `awaiting_seed`, or `seeded` — see `DonationStore.liveNodeCount`.
122
+ */
123
+ export type DonationStatus =
124
+ | 'provisioning'
125
+ | 'awaiting_seed'
126
+ | 'seeded'
127
+ | 'error'
128
+ | 'terminated';
129
+
130
+ /**
131
+ * One donated node, tracked host-side and persisted to `donations.json`.
132
+ *
133
+ * The host contributes capacity only: `partyId` is the REQUESTER's cadre (a
134
+ * foreign party), and `seedToken` gates the node's own `POST /seed` — it is
135
+ * minted host-side, persisted here (so a host restart in the request→seed gap
136
+ * can still present the seed), and NEVER returned to the grantee.
137
+ *
138
+ * The record also carries every input the original spawn consumed
139
+ * (`partyId` + `profile` + `bootstrapNodes` + `ownerKeys`), so a crashed node can
140
+ * be re-spawned from the record alone — see `DonationService.respawn`. Records
141
+ * written before those two fields existed have neither; such a record is NOT
142
+ * respawnable and is skipped (never crashes a sweep).
143
+ */
144
+ export interface Donation {
145
+ /** "grn_<nanoid>" — also the donated node's orchestrator containerId. */
146
+ id: string;
147
+ /** The grant token that authorized this donation (the quota key). */
148
+ grantToken: string;
149
+ /** The REQUESTER's cadre (a foreign party this host merely hosts a node for). */
150
+ partyId: string;
151
+ /**
152
+ * Requester control-network bootstrap multiaddrs — replayed on respawn.
153
+ * Absent on records written before respawn support; those are not respawnable.
154
+ */
155
+ bootstrapNodes?: string[];
156
+ /**
157
+ * Requester owner public key(s), base64url — replayed as the node's pinned
158
+ * seed-trust anchors (`CADRE_OWNER_KEYS`) on respawn. Absent on records written
159
+ * before respawn support; those are not respawnable.
160
+ */
161
+ ownerKeys?: string[];
162
+ /**
163
+ * Respawn bookkeeping, owned by the supervisor's backoff. Absent until the
164
+ * first respawn attempt. Not secret — it rides along in `DonationView` so the
165
+ * borrower's `GET /grants/:id` shows the counters for free.
166
+ */
167
+ respawn?: { attempts: number; lastAttemptAt: string };
168
+ /** Node profile. Defaults to `storage` so the node participates and is dialable. */
169
+ profile: 'storage' | 'transaction';
170
+ status: DonationStatus;
171
+ /** Orchestrator handle (opaque `pid:token`), set once the child is spawned. */
172
+ dockerId?: string;
173
+ /**
174
+ * Reserved: a donation's live peer identity is read fresh from the node's
175
+ * `/status` on each `getPeer` call and is NOT persisted here — this field is
176
+ * currently never written. Kept for a future cache if `/status` fan-out ever
177
+ * shows up as hot.
178
+ */
179
+ peerId?: string;
180
+ /** The node's loopback `POST /seed` URL. Host-internal — stripped from the wire view. */
181
+ seedEndpoint?: string;
182
+ /** Host↔node bearer gating `POST /seed`. Persisted; NEVER leaves the host. */
183
+ seedToken?: string;
184
+ createdAt: string;
185
+ updatedAt: string;
186
+ /** Failure detail when `status === 'error'`. */
187
+ error?: string;
188
+ }
189
+
190
+ /**
191
+ * Redacted wire shape returned to the grantee: the host-only secrets
192
+ * (`seedToken`) and host-internal loopback URL (`seedEndpoint`) are stripped.
193
+ */
194
+ export type DonationView = Omit<Donation, 'seedToken' | 'seedEndpoint'>;
195
+
196
+ /** On-disk shape of `donations.json`. Keyed by donation id. */
197
+ export interface DonationFile {
198
+ version: 1;
199
+ donations: Record<string, Omit<Donation, 'id'>>;
200
+ }
201
+
202
+ /** Error codes thrown by the donation service / grantee-facing routes. */
203
+ export type DonationErrorCode =
204
+ | 'unauthorized' // 401 — missing / unknown grant bearer
205
+ | 'forbidden' // 403 — revoked or expired grant
206
+ | 'quota_exceeded' // 429 — grant already at its maxNodes cap
207
+ | 'invalid_request' // 400 — malformed body
208
+ | 'not_found' // 404 — no such donation (or not owned by this grant)
209
+ | 'invalid_state' // 409 — op invalid for the donation's current status
210
+ | 'seed_failed' // 502 — the node rejected the presented seed
211
+ | 'peer_unavailable' // 503 — the node has no peer identity yet
212
+ | 'orchestrator_error' // 500 — spawn / stop failure
213
+ | 'storage_error'; // 500 — donations.json read/write failure
214
+
215
+ /** Typed donation error carrying a stable `code` for HTTP mapping. */
216
+ export class DonationError extends Error {
217
+ readonly code: DonationErrorCode;
218
+
219
+ constructor(code: DonationErrorCode, message: string) {
220
+ super(message);
221
+ this.name = 'DonationError';
222
+ this.code = code;
223
+ }
224
+ }
225
+
226
+ /** Error codes thrown by GrantStore / GrantService. */
227
+ export type GrantErrorCode =
228
+ | 'not_found' // unknown token on revoke
229
+ | 'invalid_label'
230
+ | 'invalid_max_nodes'
231
+ | 'invalid_ttl'
232
+ | 'storage_error';
233
+
234
+ /** Typed error carrying a stable `code` for HTTP mapping. */
235
+ export class GrantError extends Error {
236
+ readonly code: GrantErrorCode;
237
+
238
+ constructor(code: GrantErrorCode, message: string) {
239
+ super(message);
240
+ this.name = 'GrantError';
241
+ this.code = code;
242
+ }
243
+ }