@serfab/cadre-host 0.8.1 → 0.9.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.
- package/dist/auth/trust-circle.d.ts +5 -5
- package/dist/auth/trust-circle.js +10 -10
- package/dist/auth/trust-circle.js.map +1 -1
- package/dist/auth/types.d.ts +2 -2
- package/dist/bin/host.js +255 -54
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/donation-service.d.ts +136 -0
- package/dist/donation/donation-service.js +306 -0
- package/dist/donation/donation-service.js.map +1 -0
- package/dist/donation/donation-store.d.ts +42 -0
- package/dist/donation/donation-store.js +141 -0
- package/dist/donation/donation-store.js.map +1 -0
- package/dist/donation/grant-service.d.ts +71 -0
- package/dist/donation/grant-service.js +163 -0
- package/dist/donation/grant-service.js.map +1 -0
- package/dist/donation/grant-store.d.ts +38 -0
- package/dist/donation/grant-store.js +135 -0
- package/dist/donation/grant-store.js.map +1 -0
- package/dist/donation/index.d.ts +18 -0
- package/dist/donation/index.js +16 -0
- package/dist/donation/index.js.map +1 -0
- package/dist/donation/types.d.ts +175 -0
- package/dist/donation/types.js +40 -0
- package/dist/donation/types.js.map +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/installer/config.d.ts +26 -0
- package/dist/installer/config.js +13 -0
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +2 -0
- package/dist/installer/index.js +3 -0
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/wizard.d.ts +4 -0
- package/dist/installer/wizard.js +5 -1
- package/dist/installer/wizard.js.map +1 -1
- package/dist/nat/nat-service.d.ts +5 -5
- package/dist/nat/nat-service.js +9 -9
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/types.d.ts +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +35 -27
- package/dist/orchestrator/host-process-orchestrator.js +79 -53
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/orchestrator/index.d.ts +3 -3
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/state-store.d.ts +5 -5
- package/dist/orchestrator/state-store.js +2 -2
- package/dist/orchestrator/state-store.js.map +1 -1
- package/dist/orchestrator/types.d.ts +8 -8
- package/dist/owner/index.d.ts +11 -0
- package/dist/owner/index.js +11 -0
- package/dist/owner/index.js.map +1 -0
- package/dist/{authority/authority-node-client.d.ts → owner/owner-node-client.d.ts} +32 -14
- package/dist/{authority/authority-node-client.js → owner/owner-node-client.js} +39 -15
- package/dist/owner/owner-node-client.js.map +1 -0
- package/dist/server/error-handler.d.ts +15 -0
- package/dist/server/error-handler.js +41 -0
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/index.d.ts +26 -2
- package/dist/server/index.js +37 -16
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/grants-admin.d.ts +17 -0
- package/dist/server/routes/grants-admin.js +32 -0
- package/dist/server/routes/grants-admin.js.map +1 -0
- package/dist/server/routes/grants.d.ts +28 -0
- package/dist/server/routes/grants.js +163 -0
- package/dist/server/routes/grants.js.map +1 -0
- package/dist/server/routes/nodes.d.ts +5 -4
- package/dist/server/routes/nodes.js +19 -18
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/routes/settings.d.ts +6 -1
- package/dist/server/routes/settings.js +5 -2
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/status.d.ts +8 -4
- package/dist/server/routes/status.js +9 -4
- package/dist/server/routes/status.js.map +1 -1
- package/dist/update/release-key.js +7 -5
- package/dist/update/release-key.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/{host-authority.smoke.test.ts → host-owner.smoke.test.ts} +7 -7
- package/src/__tests__/{orchestrator-authority.test.ts → orchestrator-owner.test.ts} +33 -33
- package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
- package/src/__tests__/orchestrator-push.test.ts +10 -10
- package/src/auth/__tests__/trust-circle-integration.test.ts +11 -11
- package/src/auth/__tests__/trust-circle.test.ts +10 -10
- package/src/auth/trust-circle.ts +10 -10
- package/src/auth/types.ts +2 -2
- package/src/bin/host.ts +273 -53
- package/src/donation/__tests__/donation-service.test.ts +290 -0
- package/src/donation/__tests__/grant-service.test.ts +213 -0
- package/src/donation/__tests__/grant-store.test.ts +113 -0
- package/src/donation/donation-service.ts +392 -0
- package/src/donation/donation-store.ts +164 -0
- package/src/donation/grant-service.ts +200 -0
- package/src/donation/grant-store.ts +152 -0
- package/src/donation/index.ts +49 -0
- package/src/donation/types.ts +220 -0
- package/src/index.ts +37 -5
- package/src/installer/__tests__/config.test.ts +27 -1
- package/src/installer/__tests__/installer.smoke.test.ts +31 -1
- package/src/installer/__tests__/wizard.test.ts +5 -1
- package/src/installer/config.ts +34 -0
- package/src/installer/index.ts +5 -0
- package/src/installer/wizard.ts +12 -1
- package/src/nat/__tests__/nat-service.test.ts +5 -5
- package/src/nat/nat-service.ts +10 -10
- package/src/nat/types.ts +1 -1
- package/src/orchestrator/host-process-orchestrator.ts +88 -60
- package/src/orchestrator/index.ts +3 -3
- package/src/orchestrator/state-store.ts +7 -7
- package/src/orchestrator/types.ts +8 -8
- package/src/{authority/__tests__/authority-node-client.test.ts → owner/__tests__/owner-node-client.test.ts} +45 -17
- package/src/owner/index.ts +12 -0
- package/src/{authority/authority-node-client.ts → owner/owner-node-client.ts} +55 -21
- package/src/server/__tests__/error-handler.test.ts +22 -0
- package/src/server/__tests__/grants-admin-route.test.ts +73 -0
- package/src/server/__tests__/grants-route.test.ts +189 -0
- package/src/server/__tests__/nodes-route.test.ts +25 -25
- package/src/server/__tests__/server.smoke.test.ts +75 -0
- package/src/server/__tests__/settings-route.test.ts +17 -0
- package/src/server/__tests__/status-route.test.ts +21 -0
- package/src/server/error-handler.ts +43 -0
- package/src/server/index.ts +62 -17
- package/src/server/routes/grants-admin.ts +41 -0
- package/src/server/routes/grants.ts +186 -0
- package/src/server/routes/nodes.ts +19 -18
- package/src/server/routes/settings.ts +11 -3
- package/src/server/routes/status.ts +18 -8
- package/src/update/__tests__/release-key.test.ts +16 -3
- package/src/update/release-key.ts +7 -5
- package/dist/authority/authority-node-client.js.map +0 -1
- package/dist/authority/index.d.ts +0 -11
- package/dist/authority/index.js +0 -11
- package/dist/authority/index.js.map +0 -1
- package/src/authority/index.ts +0 -12
|
@@ -9,6 +9,7 @@ import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
|
|
|
9
9
|
import type { TrustCircleService } from '../../auth/index.js';
|
|
10
10
|
import type { NatService } from '../../nat/index.js';
|
|
11
11
|
import type { NatStatusSnapshot } from '../../nat/types.js';
|
|
12
|
+
import { GrantService, GrantStore } from '../../donation/index.js';
|
|
12
13
|
|
|
13
14
|
function fakeOrchestrator(): HostProcessOrchestrator {
|
|
14
15
|
return {
|
|
@@ -176,3 +177,77 @@ describe('createLocalUiServer smoke', () => {
|
|
|
176
177
|
expect(body.error.code).toBe('not_found');
|
|
177
178
|
});
|
|
178
179
|
});
|
|
180
|
+
|
|
181
|
+
// Donor-only mode: the common case (ownCadre.enabled=false). No owner node, so
|
|
182
|
+
// no trustCircle / NAT are wired. The donor surface (/grants-admin) is up, and
|
|
183
|
+
// the founder-only surfaces (/auth, /nat) 404 to keep the surface honest.
|
|
184
|
+
describe('createLocalUiServer smoke — donor-only (no owner node)', () => {
|
|
185
|
+
let dataDir: string;
|
|
186
|
+
let server: ReturnType<typeof createLocalUiServer>;
|
|
187
|
+
let baseUrl: string;
|
|
188
|
+
|
|
189
|
+
beforeEach(async () => {
|
|
190
|
+
dataDir = mkdtempSync(join(tmpdir(), 'cadre-host-donor-'));
|
|
191
|
+
writeConfig(dataDir);
|
|
192
|
+
server = createLocalUiServer({
|
|
193
|
+
uiPort: 8765,
|
|
194
|
+
dataDir,
|
|
195
|
+
orchestrator: fakeOrchestrator(),
|
|
196
|
+
grants: new GrantService({ store: new GrantStore(dataDir) }),
|
|
197
|
+
forcePort: 0,
|
|
198
|
+
});
|
|
199
|
+
const { url } = await server.start();
|
|
200
|
+
baseUrl = url;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
afterEach(async () => {
|
|
204
|
+
await server.stop();
|
|
205
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('serves /api/status with no trustCircle / connectivity and no nodes', async () => {
|
|
209
|
+
const res = await fetch(`${baseUrl}/api/status`);
|
|
210
|
+
expect(res.status).toBe(200);
|
|
211
|
+
const body = await res.json() as {
|
|
212
|
+
service: { name: string };
|
|
213
|
+
nodes: unknown[];
|
|
214
|
+
trustCircle?: unknown;
|
|
215
|
+
connectivity?: unknown;
|
|
216
|
+
};
|
|
217
|
+
expect(body.service.name).toBe('cadre-host');
|
|
218
|
+
expect(body.nodes).toEqual([]);
|
|
219
|
+
expect(body.trustCircle).toBeUndefined();
|
|
220
|
+
expect(body.connectivity).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('serves the donor surface: POST /grants-admin issues a grant', async () => {
|
|
224
|
+
const res = await fetch(`${baseUrl}/grants-admin`, {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
headers: { 'content-type': 'application/json' },
|
|
227
|
+
body: JSON.stringify({ label: "Alice's cadre", maxNodes: 2 }),
|
|
228
|
+
});
|
|
229
|
+
expect(res.status).toBe(200);
|
|
230
|
+
const body = await res.json() as { grant: { token: string; maxNodes: number } };
|
|
231
|
+
expect(body.grant.token).toBeTruthy();
|
|
232
|
+
expect(body.grant.maxNodes).toBe(2);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('404s the founder-only NAT surface (GET /nat/status)', async () => {
|
|
236
|
+
const res = await fetch(`${baseUrl}/nat/status`);
|
|
237
|
+
expect(res.status).toBe(404);
|
|
238
|
+
const body = await res.json() as { error: { code: string } };
|
|
239
|
+
expect(body.error.code).toBe('not_found');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('404s the founder-only trust-circle surface (/auth/*)', async () => {
|
|
243
|
+
const list = await fetch(`${baseUrl}/auth/trust-circle`);
|
|
244
|
+
expect(list.status).toBe(404);
|
|
245
|
+
|
|
246
|
+
const invite = await fetch(`${baseUrl}/auth/invites`, {
|
|
247
|
+
method: 'POST',
|
|
248
|
+
headers: { 'content-type': 'application/json' },
|
|
249
|
+
body: JSON.stringify({ label: 'Mom' }),
|
|
250
|
+
});
|
|
251
|
+
expect(invite.status).toBe(404);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -149,6 +149,23 @@ describe('/api/settings routes', () => {
|
|
|
149
149
|
expect(body.error.message).toMatch(/uiPort/);
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
+
it('PUT ownCadre returns 400 invalid_setting (install-time only)', async () => {
|
|
153
|
+
writeV2Config(dataDir);
|
|
154
|
+
app = Fastify();
|
|
155
|
+
registerErrorHandler(app);
|
|
156
|
+
registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
|
|
157
|
+
const res = await app.inject({
|
|
158
|
+
method: 'PUT',
|
|
159
|
+
url: '/api/settings',
|
|
160
|
+
headers: { 'content-type': 'application/json' },
|
|
161
|
+
payload: JSON.stringify({ ownCadre: { enabled: true } }),
|
|
162
|
+
});
|
|
163
|
+
expect(res.statusCode).toBe(400);
|
|
164
|
+
const body = res.json() as { error: { code: string; message: string } };
|
|
165
|
+
expect(body.error.code).toBe('invalid_setting');
|
|
166
|
+
expect(body.error.message).toMatch(/ownCadre/);
|
|
167
|
+
});
|
|
168
|
+
|
|
152
169
|
it('PUT unknown setting returns 400', async () => {
|
|
153
170
|
writeV2Config(dataDir);
|
|
154
171
|
app = Fastify();
|
|
@@ -91,6 +91,27 @@ describe('GET /api/status', () => {
|
|
|
91
91
|
expect(body.update).toBeUndefined();
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
it('omits trustCircle + connectivity in donor-only mode (no trust circle / NAT wired)', async () => {
|
|
95
|
+
app = Fastify();
|
|
96
|
+
registerErrorHandler(app);
|
|
97
|
+
registerStatusRoute(app, {
|
|
98
|
+
orchestrator: fakeOrchestrator([]),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const res = await app.inject({ method: 'GET', url: '/api/status' });
|
|
102
|
+
expect(res.statusCode).toBe(200);
|
|
103
|
+
const body = res.json() as {
|
|
104
|
+
service: { name: string };
|
|
105
|
+
nodes: unknown[];
|
|
106
|
+
trustCircle?: unknown;
|
|
107
|
+
connectivity?: unknown;
|
|
108
|
+
};
|
|
109
|
+
expect(body.service.name).toBe('cadre-host');
|
|
110
|
+
expect(body.nodes).toEqual([]);
|
|
111
|
+
expect(body.trustCircle).toBeUndefined();
|
|
112
|
+
expect(body.connectivity).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
94
115
|
it('includes update info when service is present', async () => {
|
|
95
116
|
app = Fastify();
|
|
96
117
|
registerErrorHandler(app);
|
|
@@ -22,6 +22,21 @@
|
|
|
22
22
|
* apply_in_progress → 409
|
|
23
23
|
* signature_invalid, unsupported_state_version,
|
|
24
24
|
* apply_failed, rollback_failed, storage_error → 500
|
|
25
|
+
* - GrantError
|
|
26
|
+
* invalid_label, invalid_max_nodes,
|
|
27
|
+
* invalid_ttl → 400
|
|
28
|
+
* not_found → 404
|
|
29
|
+
* storage_error → 500
|
|
30
|
+
* - DonationError
|
|
31
|
+
* invalid_request → 400
|
|
32
|
+
* unauthorized → 401
|
|
33
|
+
* forbidden → 403
|
|
34
|
+
* not_found → 404
|
|
35
|
+
* invalid_state → 409
|
|
36
|
+
* quota_exceeded → 429
|
|
37
|
+
* orchestrator_error, storage_error → 500
|
|
38
|
+
* seed_failed → 502
|
|
39
|
+
* peer_unavailable → 503
|
|
25
40
|
* - everything else → 500 (code "internal")
|
|
26
41
|
*/
|
|
27
42
|
|
|
@@ -31,6 +46,7 @@ import debug from 'debug';
|
|
|
31
46
|
import { TrustCircleError, type TrustCircleErrorCode } from '../auth/types.js';
|
|
32
47
|
import { NatError, type NatErrorCode } from '../nat/types.js';
|
|
33
48
|
import { UpdateErrorException, type UpdateErrorCode } from '../update/types.js';
|
|
49
|
+
import { GrantError, type GrantErrorCode, DonationError, type DonationErrorCode } from '../donation/types.js';
|
|
34
50
|
|
|
35
51
|
const log = debug('cadre:host:error-handler');
|
|
36
52
|
|
|
@@ -57,6 +73,27 @@ const NAT_STATUS: Record<NatErrorCode, number> = {
|
|
|
57
73
|
node_unavailable: 503,
|
|
58
74
|
};
|
|
59
75
|
|
|
76
|
+
const GRANT_STATUS: Record<GrantErrorCode, number> = {
|
|
77
|
+
invalid_label: 400,
|
|
78
|
+
invalid_max_nodes: 400,
|
|
79
|
+
invalid_ttl: 400,
|
|
80
|
+
not_found: 404,
|
|
81
|
+
storage_error: 500,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const DONATION_STATUS: Record<DonationErrorCode, number> = {
|
|
85
|
+
unauthorized: 401,
|
|
86
|
+
forbidden: 403,
|
|
87
|
+
quota_exceeded: 429,
|
|
88
|
+
invalid_request: 400,
|
|
89
|
+
not_found: 404,
|
|
90
|
+
invalid_state: 409,
|
|
91
|
+
seed_failed: 502,
|
|
92
|
+
peer_unavailable: 503,
|
|
93
|
+
orchestrator_error: 500,
|
|
94
|
+
storage_error: 500,
|
|
95
|
+
};
|
|
96
|
+
|
|
60
97
|
const UPDATE_STATUS: Record<UpdateErrorCode, number> = {
|
|
61
98
|
manifest_invalid: 400,
|
|
62
99
|
no_update_available: 400,
|
|
@@ -100,6 +137,12 @@ function classify(err: FastifyError): Classified {
|
|
|
100
137
|
if (err instanceof UpdateErrorException) {
|
|
101
138
|
return { status: UPDATE_STATUS[err.code] ?? 500, code: err.code, message: err.message };
|
|
102
139
|
}
|
|
140
|
+
if (err instanceof GrantError) {
|
|
141
|
+
return { status: GRANT_STATUS[err.code] ?? 500, code: err.code, message: err.message };
|
|
142
|
+
}
|
|
143
|
+
if (err instanceof DonationError) {
|
|
144
|
+
return { status: DONATION_STATUS[err.code] ?? 500, code: err.code, message: err.message };
|
|
145
|
+
}
|
|
103
146
|
// Fastify body-parsing / validation errors have a statusCode we should honour.
|
|
104
147
|
if (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 600) {
|
|
105
148
|
return {
|
package/src/server/index.ts
CHANGED
|
@@ -22,6 +22,8 @@ import type { NatService } from '../nat/index.js';
|
|
|
22
22
|
import { createNatHandlers } from '../nat/index.js';
|
|
23
23
|
import type { UpdateService } from '../update/index.js';
|
|
24
24
|
import { createUpdateHandlers } from '../update/index.js';
|
|
25
|
+
import type { GrantService, DonationService } from '../donation/index.js';
|
|
26
|
+
import { createGrantAdminHandlers } from '../donation/index.js';
|
|
25
27
|
|
|
26
28
|
import { EventBus } from './events/bus.js';
|
|
27
29
|
import { registerSseRoute } from './events/sse-route.js';
|
|
@@ -35,6 +37,8 @@ import { registerUpdateRoutes } from './routes/update.js';
|
|
|
35
37
|
import { registerStatusRoute } from './routes/status.js';
|
|
36
38
|
import { registerNodesRoutes } from './routes/nodes.js';
|
|
37
39
|
import { registerSettingsRoutes } from './routes/settings.js';
|
|
40
|
+
import { registerGrantsAdminRoutes } from './routes/grants-admin.js';
|
|
41
|
+
import { registerGrantsRoutes } from './routes/grants.js';
|
|
38
42
|
import { HostSettingsStore } from './settings-store.js';
|
|
39
43
|
|
|
40
44
|
export interface LocalUiServerOptions {
|
|
@@ -44,10 +48,33 @@ export interface LocalUiServerOptions {
|
|
|
44
48
|
dataDir: string;
|
|
45
49
|
/** Wired dependencies — all owned by the caller. */
|
|
46
50
|
orchestrator: HostProcessOrchestrator;
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Trust-circle service — present only when the host runs its own personal
|
|
53
|
+
* cadre (`ownCadre.enabled`). Absent in donor-only mode, where `/auth/*`
|
|
54
|
+
* stays unmounted and 404s.
|
|
55
|
+
*/
|
|
56
|
+
trustCircle?: TrustCircleService;
|
|
57
|
+
/**
|
|
58
|
+
* NAT service — present only when the host runs its own personal cadre.
|
|
59
|
+
* Absent in donor-only mode (loopback-only in v1), where `/nat/*` stays
|
|
60
|
+
* unmounted and 404s.
|
|
61
|
+
*/
|
|
62
|
+
nat?: NatService;
|
|
49
63
|
/** Optional — 6.4.2 lands this; nullable while still iterating. */
|
|
50
64
|
update?: UpdateService;
|
|
65
|
+
/**
|
|
66
|
+
* Donation grant layer. When present, mounts the loopback admin surface at
|
|
67
|
+
* `/grants-admin` (issue/list/revoke). Optional so existing callers/tests
|
|
68
|
+
* that don't exercise donations need not wire it.
|
|
69
|
+
*/
|
|
70
|
+
grants?: GrantService;
|
|
71
|
+
/**
|
|
72
|
+
* Donation lifecycle service. When present (alongside `grants`), mounts the
|
|
73
|
+
* bearer-gated grantee-facing provisioning surface at `/grants`
|
|
74
|
+
* (provision / peer / seed / terminate). Optional so callers/tests that don't
|
|
75
|
+
* exercise the donor path need not wire it.
|
|
76
|
+
*/
|
|
77
|
+
donations?: DonationService;
|
|
51
78
|
/** Settings I/O facade. Defaults to a new one rooted at `dataDir`. */
|
|
52
79
|
settingsStore?: HostSettingsStore;
|
|
53
80
|
/** Bus instance — pass the same one to subsystems that publish events. */
|
|
@@ -99,18 +126,33 @@ export function createLocalUiServer(opts: LocalUiServerOptions): LocalUiServer {
|
|
|
99
126
|
|
|
100
127
|
registerStatusRoute(app, {
|
|
101
128
|
orchestrator: opts.orchestrator,
|
|
102
|
-
trustCircle: opts.trustCircle,
|
|
103
|
-
nat: opts.nat,
|
|
129
|
+
...(opts.trustCircle ? { trustCircle: opts.trustCircle } : {}),
|
|
130
|
+
...(opts.nat ? { nat: opts.nat } : {}),
|
|
104
131
|
...(opts.update ? { update: opts.update } : {}),
|
|
105
132
|
});
|
|
106
133
|
registerNodesRoutes(app, { orchestrator: opts.orchestrator });
|
|
107
|
-
registerSettingsRoutes(app, { settingsStore, nat: opts.nat, ...(opts.update ? { update: opts.update } : {}) });
|
|
134
|
+
registerSettingsRoutes(app, { settingsStore, ...(opts.nat ? { nat: opts.nat } : {}), ...(opts.update ? { update: opts.update } : {}) });
|
|
108
135
|
|
|
109
|
-
|
|
110
|
-
|
|
136
|
+
// Trust-circle + NAT surfaces exist only when the host runs its own personal
|
|
137
|
+
// cadre. In donor-only mode they're left unmounted, so `/auth/*` and `/nat/*`
|
|
138
|
+
// fall through to the static not-found handler and 404 (see static.ts).
|
|
139
|
+
if (opts.trustCircle) {
|
|
140
|
+
registerTrustCircleRoutes(app, { handlers: createTrustCircleHandlers(opts.trustCircle), events });
|
|
141
|
+
}
|
|
142
|
+
if (opts.nat) {
|
|
143
|
+
registerNatRoutes(app, { handlers: createNatHandlers(opts.nat), events });
|
|
144
|
+
}
|
|
111
145
|
if (opts.update) {
|
|
112
146
|
registerUpdateRoutes(app, { handlers: createUpdateHandlers(opts.update), events });
|
|
113
147
|
}
|
|
148
|
+
if (opts.grants) {
|
|
149
|
+
registerGrantsAdminRoutes(app, { handlers: createGrantAdminHandlers(opts.grants) });
|
|
150
|
+
// Grantee-facing provisioning surface needs both the donation service and a
|
|
151
|
+
// grant validator (the GrantService doubles as the validator).
|
|
152
|
+
if (opts.donations) {
|
|
153
|
+
registerGrantsRoutes(app, { donations: opts.donations, grants: opts.grants });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
114
156
|
|
|
115
157
|
// Static mount registers as the not-found handler — declared last so the
|
|
116
158
|
// API paths above win.
|
|
@@ -147,16 +189,19 @@ export function createLocalUiServer(opts: LocalUiServerOptions): LocalUiServer {
|
|
|
147
189
|
);
|
|
148
190
|
|
|
149
191
|
// One-shot connectivity publish — the SPA will get a sane initial
|
|
150
|
-
// signal even if no settings have changed yet this session.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
192
|
+
// signal even if no settings have changed yet this session. Skipped in
|
|
193
|
+
// donor-only mode, where there is no NatService.
|
|
194
|
+
if (opts.nat) {
|
|
195
|
+
try {
|
|
196
|
+
const snap = opts.nat.getStatus();
|
|
197
|
+
events.publish({
|
|
198
|
+
type: 'connectivity-changed',
|
|
199
|
+
portMode: snap.portMode,
|
|
200
|
+
directReachability: snap.directReachability,
|
|
201
|
+
});
|
|
202
|
+
} catch {
|
|
203
|
+
// NatService.start may not have completed; harmless to skip.
|
|
204
|
+
}
|
|
160
205
|
}
|
|
161
206
|
|
|
162
207
|
// Update observer: poll UpdateService.getState() every 60 s and emit
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Donation grant **admin** HTTP routes — thin adapters around
|
|
3
|
+
* `GrantAdminHandlers` (`createGrantAdminHandlers(service)`).
|
|
4
|
+
*
|
|
5
|
+
* Mount path: `/grants-admin` — the admin surface the `cadre-host grant` CLI
|
|
6
|
+
* targets. It is **loopback, no bearer**: same-machine admin, matching
|
|
7
|
+
* cadre-host's local-UI "no login" posture (see docs/cadre-host.md §
|
|
8
|
+
* Security posture). This is distinct from the grantee-facing `/grants`
|
|
9
|
+
* provisioning surface that carries the bearer gate — that arrives in the
|
|
10
|
+
* `2-donation-service` ticket.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { FastifyInstance } from 'fastify';
|
|
14
|
+
|
|
15
|
+
import type { GrantAdminHandlers } from '../../donation/types.js';
|
|
16
|
+
|
|
17
|
+
export interface GrantsAdminRoutesOptions {
|
|
18
|
+
handlers: GrantAdminHandlers;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function registerGrantsAdminRoutes(app: FastifyInstance, opts: GrantsAdminRoutesOptions): void {
|
|
22
|
+
const { handlers } = opts;
|
|
23
|
+
|
|
24
|
+
app.get('/grants-admin', async () => {
|
|
25
|
+
return handlers.listGrants();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
app.post('/grants-admin', async (request) => {
|
|
29
|
+
const body = (request.body ?? {}) as { label?: unknown; maxNodes?: unknown; ttlMs?: unknown };
|
|
30
|
+
const label = typeof body.label === 'string' ? body.label : '';
|
|
31
|
+
const args: { label: string; maxNodes?: number; ttlMs?: number } = { label };
|
|
32
|
+
if (typeof body.maxNodes === 'number') args.maxNodes = body.maxNodes;
|
|
33
|
+
if (typeof body.ttlMs === 'number') args.ttlMs = body.ttlMs;
|
|
34
|
+
return handlers.postGrant(args);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
app.delete<{ Params: { token: string } }>('/grants-admin/:token', async (request) => {
|
|
38
|
+
await handlers.deleteGrant(request.params.token);
|
|
39
|
+
return { ok: true };
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grantee-facing donation routes — the HTTP surface an external cadre authority
|
|
3
|
+
* (a friend's phone) drives to have this host donate a node into *its* cadre.
|
|
4
|
+
*
|
|
5
|
+
* Mount path: `/grants`. Distinct from the loopback-only `/grants-admin` surface
|
|
6
|
+
* (issue/list/revoke, no bearer): this one is **bearer-gated** — every request
|
|
7
|
+
* carries `Authorization: Bearer <grant-token>`, and a grantee sees only the
|
|
8
|
+
* donations its own grant authorized (the single cross-grantee boundary,
|
|
9
|
+
* analogous to cadre-provider's per-customer ownership check).
|
|
10
|
+
*
|
|
11
|
+
* Reachability is out of scope here: in v1 this mounts on the loopback
|
|
12
|
+
* management server, same as the trust-circle / NAT surfaces. Physically exposing
|
|
13
|
+
* it to a remote phone is `backlog/feat-cadre-host-wan-grant-reachability`.
|
|
14
|
+
*
|
|
15
|
+
* Errors: handlers either send a `{ ok:false, error:{code,message} }` envelope
|
|
16
|
+
* inline (auth / body validation) or let the `DonationService` throw a
|
|
17
|
+
* `DonationError`, which `server/error-handler.ts` maps to the matching HTTP
|
|
18
|
+
* status and the same envelope.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
22
|
+
|
|
23
|
+
import type { DonationService, DonationProvisionRequest } from '../../donation/donation-service.js';
|
|
24
|
+
import type { DonationView, GrantValidator } from '../../donation/types.js';
|
|
25
|
+
|
|
26
|
+
export interface GrantsRoutesOptions {
|
|
27
|
+
donations: DonationService;
|
|
28
|
+
/** Validates the presented bearer for non-provisioning ops (provision revalidates internally). */
|
|
29
|
+
grants: GrantValidator;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Uniform error envelope (matches `error-handler.ts` and the provider API). */
|
|
33
|
+
function errorResponse(reply: FastifyReply, code: string, message: string, status: number) {
|
|
34
|
+
return reply.status(status).send({ ok: false, error: { code, message } });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Pull the `Bearer <token>` value out of the Authorization header, or undefined. */
|
|
38
|
+
function parseBearer(header: string | undefined): string | undefined {
|
|
39
|
+
if (!header) return undefined;
|
|
40
|
+
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
|
41
|
+
return match ? match[1].trim() : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Map a grant denial reason to an HTTP status for the bearer gate. */
|
|
45
|
+
function denyStatus(reason: string | undefined): number {
|
|
46
|
+
if (reason === 'expired' || reason === 'revoked') return 403;
|
|
47
|
+
return 401; // unknown_token / missing
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Authenticate the grant bearer for a non-provisioning op (identity + not
|
|
52
|
+
* expired/revoked; no quota). Returns the token, or null after sending the
|
|
53
|
+
* failure response. Provision does its own quota-aware validation inside
|
|
54
|
+
* `DonationService.provision`.
|
|
55
|
+
*/
|
|
56
|
+
function authenticate(
|
|
57
|
+
request: FastifyRequest,
|
|
58
|
+
reply: FastifyReply,
|
|
59
|
+
grants: GrantValidator,
|
|
60
|
+
): string | null {
|
|
61
|
+
const token = parseBearer(request.headers.authorization);
|
|
62
|
+
if (!token) {
|
|
63
|
+
errorResponse(reply, 'unauthorized', 'Missing bearer grant token', 401);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const validation = grants.validate(token);
|
|
67
|
+
if (!validation.ok) {
|
|
68
|
+
const status = denyStatus(validation.reason);
|
|
69
|
+
// Keep the envelope code aligned with the status (and with the POST path,
|
|
70
|
+
// which surfaces an unknown token as `unauthorized`): 401 → unauthorized,
|
|
71
|
+
// 403 → forbidden. A 401 labelled `forbidden` is self-contradictory.
|
|
72
|
+
const code = status === 403 ? 'forbidden' : 'unauthorized';
|
|
73
|
+
errorResponse(reply, code, `Grant is ${validation.reason ?? 'invalid'}`, status);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return token;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve a donation the presented grant owns. Returns the redacted view, or
|
|
81
|
+
* null after sending a 404 — an unknown id and one owned by a *different* grant
|
|
82
|
+
* are deliberately indistinguishable (a grantee never learns another grantee's
|
|
83
|
+
* donations exist).
|
|
84
|
+
*/
|
|
85
|
+
function requireOwned(
|
|
86
|
+
donations: DonationService,
|
|
87
|
+
id: string,
|
|
88
|
+
token: string,
|
|
89
|
+
reply: FastifyReply,
|
|
90
|
+
): DonationView | null {
|
|
91
|
+
const donation = donations.get(id);
|
|
92
|
+
if (!donation || donation.grantToken !== token) {
|
|
93
|
+
errorResponse(reply, 'not_found', `No such donation: ${id}`, 404);
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return donation;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function registerGrantsRoutes(app: FastifyInstance, opts: GrantsRoutesOptions): void {
|
|
100
|
+
const { donations, grants } = opts;
|
|
101
|
+
|
|
102
|
+
// POST /grants — provision a donated node. The bearer's identity/expiry/
|
|
103
|
+
// revocation AND quota are validated inside provision(), which throws a
|
|
104
|
+
// DonationError (unauthorized / forbidden / quota_exceeded) the error handler
|
|
105
|
+
// maps. We only parse the bearer and validate the request body here.
|
|
106
|
+
app.post('/grants', async (request, reply) => {
|
|
107
|
+
const token = parseBearer(request.headers.authorization);
|
|
108
|
+
if (!token) {
|
|
109
|
+
return errorResponse(reply, 'unauthorized', 'Missing bearer grant token', 401);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const body = (request.body ?? {}) as Partial<DonationProvisionRequest>;
|
|
113
|
+
if (typeof body.partyId !== 'string' || body.partyId.length === 0) {
|
|
114
|
+
return errorResponse(reply, 'invalid_request', 'partyId is required', 400);
|
|
115
|
+
}
|
|
116
|
+
if (!Array.isArray(body.bootstrapNodes) || body.bootstrapNodes.length === 0) {
|
|
117
|
+
return errorResponse(reply, 'invalid_request', 'bootstrapNodes is required', 400);
|
|
118
|
+
}
|
|
119
|
+
if (!Array.isArray(body.ownerKeys) || body.ownerKeys.length === 0) {
|
|
120
|
+
return errorResponse(reply, 'invalid_request', 'ownerKeys is required', 400);
|
|
121
|
+
}
|
|
122
|
+
if (body.profile !== undefined && body.profile !== 'storage' && body.profile !== 'transaction') {
|
|
123
|
+
return errorResponse(reply, 'invalid_request', 'profile must be "storage" or "transaction"', 400);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const donation = await donations.provision({
|
|
127
|
+
grantToken: token,
|
|
128
|
+
partyId: body.partyId,
|
|
129
|
+
bootstrapNodes: body.bootstrapNodes,
|
|
130
|
+
ownerKeys: body.ownerKeys,
|
|
131
|
+
...(body.profile ? { profile: body.profile } : {}),
|
|
132
|
+
});
|
|
133
|
+
return reply.status(201).send({ ok: true, data: { donation } });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// GET /grants — list only the donations this grant authorized.
|
|
137
|
+
app.get('/grants', async (request, reply) => {
|
|
138
|
+
const token = authenticate(request, reply, grants);
|
|
139
|
+
if (!token) return reply;
|
|
140
|
+
return reply.send({ ok: true, data: { donations: donations.list(token) } });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// GET /grants/:id — one donation (redacted), owned by this grant.
|
|
144
|
+
app.get<{ Params: { id: string } }>('/grants/:id', async (request, reply) => {
|
|
145
|
+
const token = authenticate(request, reply, grants);
|
|
146
|
+
if (!token) return reply;
|
|
147
|
+
const donation = requireOwned(donations, request.params.id, token, reply);
|
|
148
|
+
if (!donation) return reply;
|
|
149
|
+
return reply.send({ ok: true, data: { donation } });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// GET /grants/:id/peer — live { peerId, multiaddrs } for the requester's addDrone.
|
|
153
|
+
app.get<{ Params: { id: string } }>('/grants/:id/peer', async (request, reply) => {
|
|
154
|
+
const token = authenticate(request, reply, grants);
|
|
155
|
+
if (!token) return reply;
|
|
156
|
+
if (!requireOwned(donations, request.params.id, token, reply)) return reply;
|
|
157
|
+
const peer = await donations.getPeer(request.params.id);
|
|
158
|
+
return reply.send({ ok: true, data: peer });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// PUT /grants/:id/seed — apply the requester's phone-signed seed.
|
|
162
|
+
app.put<{ Params: { id: string } }>('/grants/:id/seed', async (request, reply) => {
|
|
163
|
+
const token = authenticate(request, reply, grants);
|
|
164
|
+
if (!token) return reply;
|
|
165
|
+
if (!requireOwned(donations, request.params.id, token, reply)) return reply;
|
|
166
|
+
|
|
167
|
+
const body = (request.body ?? {}) as { seed?: unknown };
|
|
168
|
+
if (typeof body.seed !== 'string' || body.seed.length === 0) {
|
|
169
|
+
return errorResponse(reply, 'invalid_request', 'seed is required', 400);
|
|
170
|
+
}
|
|
171
|
+
const result = await donations.applySeed(request.params.id, body.seed);
|
|
172
|
+
if (!result.success) {
|
|
173
|
+
return errorResponse(reply, 'seed_failed', result.error ?? 'Node rejected the seed', 502);
|
|
174
|
+
}
|
|
175
|
+
return reply.send({ ok: true, data: { peersAdded: result.peersAdded ?? 0 } });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// DELETE /grants/:id — terminate the donated node.
|
|
179
|
+
app.delete<{ Params: { id: string } }>('/grants/:id', async (request, reply) => {
|
|
180
|
+
const token = authenticate(request, reply, grants);
|
|
181
|
+
if (!token) return reply;
|
|
182
|
+
if (!requireOwned(donations, request.params.id, token, reply)) return reply;
|
|
183
|
+
await donations.terminate(request.params.id);
|
|
184
|
+
return reply.send({ ok: true });
|
|
185
|
+
});
|
|
186
|
+
}
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* `onStateChange` (forwarded by `createLocalUiServer.start`) — the route
|
|
7
7
|
* handlers don't re-publish.
|
|
8
8
|
*
|
|
9
|
-
* start/restart are real for the admin's
|
|
10
|
-
* persisted `
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* start/restart are real for the admin's owner node (re-spawned from the
|
|
10
|
+
* persisted `OwnerSpawnConfig`). Generic node lifecycle is owned by the
|
|
11
|
+
* grantee-facing donation surface (`/grants`) — this route no longer spawns
|
|
12
|
+
* generic nodes; start/restart of a known non-owner node points the caller
|
|
13
|
+
* there. Stop works for any running node.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import { existsSync, openSync, readSync, closeSync, statSync } from 'node:fs';
|
|
@@ -83,17 +84,17 @@ export function registerNodesRoutes(app: FastifyInstance, opts: NodesRoutesOptio
|
|
|
83
84
|
return { ok: true };
|
|
84
85
|
});
|
|
85
86
|
|
|
86
|
-
// start / restart: real for the admin's
|
|
87
|
-
// persisted
|
|
88
|
-
//
|
|
89
|
-
// ids 404.
|
|
87
|
+
// start / restart: real for the admin's owner node (spawned from the
|
|
88
|
+
// persisted OwnerSpawnConfig). Generic node lifecycle now belongs to the
|
|
89
|
+
// donation surface (/grants) — those ids return a clear not_implemented that
|
|
90
|
+
// points there; unknown ids 404.
|
|
90
91
|
app.post<{ Params: { id: string } }>('/api/nodes/:id/start', async (request, reply) => {
|
|
91
92
|
const { id } = request.params;
|
|
92
|
-
if (orchestrator.
|
|
93
|
-
if (!orchestrator.
|
|
94
|
-
return notImplemented(reply, `start ${id}:
|
|
93
|
+
if (orchestrator.isOwnerNode(id)) {
|
|
94
|
+
if (!orchestrator.hasOwnerConfig()) {
|
|
95
|
+
return notImplemented(reply, `start ${id}: owner node has no saved spawn config.`);
|
|
95
96
|
}
|
|
96
|
-
const node = await orchestrator.
|
|
97
|
+
const node = await orchestrator.ensureOwnerNode();
|
|
97
98
|
return { ok: true, data: { node } };
|
|
98
99
|
}
|
|
99
100
|
return startRestartFallback(reply, orchestrator, id, 'start');
|
|
@@ -101,18 +102,18 @@ export function registerNodesRoutes(app: FastifyInstance, opts: NodesRoutesOptio
|
|
|
101
102
|
|
|
102
103
|
app.post<{ Params: { id: string } }>('/api/nodes/:id/restart', async (request, reply) => {
|
|
103
104
|
const { id } = request.params;
|
|
104
|
-
if (orchestrator.
|
|
105
|
-
if (!orchestrator.
|
|
106
|
-
return notImplemented(reply, `restart ${id}:
|
|
105
|
+
if (orchestrator.isOwnerNode(id)) {
|
|
106
|
+
if (!orchestrator.hasOwnerConfig()) {
|
|
107
|
+
return notImplemented(reply, `restart ${id}: owner node has no saved spawn config.`);
|
|
107
108
|
}
|
|
108
|
-
const node = await orchestrator.
|
|
109
|
+
const node = await orchestrator.restartOwnerNode();
|
|
109
110
|
return { ok: true, data: { node } };
|
|
110
111
|
}
|
|
111
112
|
return startRestartFallback(reply, orchestrator, id, 'restart');
|
|
112
113
|
});
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
/** 404 for unknown ids; 501 not_implemented for known non-
|
|
116
|
+
/** 404 for unknown ids; 501 not_implemented for known non-owner nodes. */
|
|
116
117
|
function startRestartFallback(
|
|
117
118
|
reply: import('fastify').FastifyReply,
|
|
118
119
|
orchestrator: HostProcessOrchestrator,
|
|
@@ -127,7 +128,7 @@ function startRestartFallback(
|
|
|
127
128
|
}
|
|
128
129
|
return notImplemented(
|
|
129
130
|
reply,
|
|
130
|
-
`${verb} ${id}: only the
|
|
131
|
+
`${verb} ${id}: only the owner node is start/restart-able here; generic donated-node lifecycle is owned by the donation surface — use POST /grants to provision and DELETE /grants/:id to terminate.`,
|
|
131
132
|
);
|
|
132
133
|
}
|
|
133
134
|
|
|
@@ -21,14 +21,19 @@ import type { NatService } from '../../nat/index.js';
|
|
|
21
21
|
import type { UpdateService } from '../../update/index.js';
|
|
22
22
|
import type { HostSettingsStore } from '../settings-store.js';
|
|
23
23
|
|
|
24
|
-
const FORBIDDEN_KEYS = new Set<string>(['uiPort', 'libp2pPort', 'dataDir', 'identityPath', 'installId', 'installedAt', 'installerVersion', 'version']);
|
|
24
|
+
const FORBIDDEN_KEYS = new Set<string>(['uiPort', 'libp2pPort', 'dataDir', 'identityPath', 'installId', 'installedAt', 'installerVersion', 'version', 'ownCadre']);
|
|
25
25
|
|
|
26
26
|
const WRITABLE_TOP_KEYS = new Set<string>(['upnpEnabled', 'updates']);
|
|
27
27
|
const WRITABLE_UPDATES_KEYS = new Set<string>(['autoApply', 'manifestUrl']);
|
|
28
28
|
|
|
29
29
|
export interface SettingsRoutesOptions {
|
|
30
30
|
settingsStore: HostSettingsStore;
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Present only when the host runs its own personal cadre. In donor-only mode
|
|
33
|
+
* an `upnpEnabled` write still persists to host.config.json but has no running
|
|
34
|
+
* NatService to propagate into.
|
|
35
|
+
*/
|
|
36
|
+
nat?: NatService;
|
|
32
37
|
update?: UpdateService;
|
|
33
38
|
}
|
|
34
39
|
|
|
@@ -58,7 +63,10 @@ export function registerSettingsRoutes(app: FastifyInstance, opts: SettingsRoute
|
|
|
58
63
|
// takes effect immediately. NatService persists into nat.json — that's
|
|
59
64
|
// independent of host.config.json's `upnpEnabled` (which the installer
|
|
60
65
|
// uses on first run to seed nat.json). Update both for consistency.
|
|
61
|
-
|
|
66
|
+
// In donor-only mode there is no NatService — the write still persists.
|
|
67
|
+
if (nat) {
|
|
68
|
+
await nat.putSettings({ upnpEnabled: body.upnpEnabled as boolean });
|
|
69
|
+
}
|
|
62
70
|
}
|
|
63
71
|
if ('updates' in body) {
|
|
64
72
|
const updates = body.updates as { autoApply?: boolean; manifestUrl?: string };
|