@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.
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/self-label.d.ts +36 -0
- package/dist/auth/self-label.js +57 -0
- package/dist/auth/self-label.js.map +1 -0
- package/dist/auth/trust-circle.d.ts +36 -9
- package/dist/auth/trust-circle.js +40 -20
- package/dist/auth/trust-circle.js.map +1 -1
- package/dist/auth/types.d.ts +2 -2
- package/dist/bin/host.js +291 -54
- package/dist/bin/host.js.map +1 -1
- package/dist/donation/__tests__/fake-orchestrator.d.ts +103 -0
- package/dist/donation/__tests__/fake-orchestrator.js +180 -0
- package/dist/donation/__tests__/fake-orchestrator.js.map +1 -0
- package/dist/donation/donation-service.d.ts +361 -0
- package/dist/donation/donation-service.js +763 -0
- package/dist/donation/donation-service.js.map +1 -0
- package/dist/donation/donation-store.d.ts +49 -0
- package/dist/donation/donation-store.js +148 -0
- package/dist/donation/donation-store.js.map +1 -0
- package/dist/donation/donation-supervisor.d.ts +167 -0
- package/dist/donation/donation-supervisor.js +341 -0
- package/dist/donation/donation-supervisor.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 +20 -0
- package/dist/donation/index.js +17 -0
- package/dist/donation/index.js.map +1 -0
- package/dist/donation/types.d.ts +201 -0
- package/dist/donation/types.js +40 -0
- package/dist/donation/types.js.map +1 -0
- package/dist/index.d.ts +9 -5
- package/dist/index.js +7 -3
- 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 +144 -32
- package/dist/orchestrator/host-process-orchestrator.js +397 -128
- 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/node-identity.d.ts +26 -0
- package/dist/orchestrator/node-identity.js +36 -0
- package/dist/orchestrator/node-identity.js.map +1 -0
- package/dist/orchestrator/port-allocator.d.ts +14 -0
- package/dist/orchestrator/port-allocator.js +45 -0
- package/dist/orchestrator/port-allocator.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/owner/owner-node-client.d.ts +110 -0
- package/dist/{authority/authority-node-client.js → owner/owner-node-client.js} +58 -17
- package/dist/owner/owner-node-client.js.map +1 -0
- package/dist/server/error-handler.d.ts +22 -1
- package/dist/server/error-handler.js +64 -1
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/events/types.d.ts +3 -0
- package/dist/server/index.d.ts +33 -2
- package/dist/server/index.js +43 -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 +182 -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/server/routes/strands.d.ts +20 -0
- package/dist/server/routes/strands.js +38 -0
- package/dist/server/routes/strands.js.map +1 -0
- package/dist/strands/index.d.ts +7 -0
- package/dist/strands/index.js +6 -0
- package/dist/strands/index.js.map +1 -0
- package/dist/strands/strand-service.d.ts +50 -0
- package/dist/strands/strand-service.js +103 -0
- package/dist/strands/strand-service.js.map +1 -0
- package/dist/strands/types.d.ts +93 -0
- package/dist/strands/types.js +23 -0
- package/dist/strands/types.js.map +1 -0
- package/dist/ui/assets/index-BVoqLjyX.css +1 -0
- package/dist/ui/assets/index-C-iks41M.js +17 -0
- package/dist/ui/assets/index-C-iks41M.js.map +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/update/release-key.d.ts +1 -1
- package/dist/update/release-key.js +8 -6
- package/dist/update/release-key.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/build-targets.test.ts +26 -0
- package/src/__tests__/global-setup.ts +51 -0
- package/src/__tests__/{host-authority.smoke.test.ts → host-owner.smoke.test.ts} +7 -7
- package/src/__tests__/orchestrator-env-scrub.test.ts +140 -0
- package/src/__tests__/orchestrator-node-identity.test.ts +204 -0
- 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/__tests__/orchestrator.test.ts +387 -6
- package/src/auth/__tests__/self-label.test.ts +85 -0
- package/src/auth/__tests__/trust-circle-integration.test.ts +152 -147
- package/src/auth/__tests__/trust-circle.test.ts +55 -11
- package/src/auth/index.ts +2 -0
- package/src/auth/self-label.ts +74 -0
- package/src/auth/trust-circle.ts +54 -22
- package/src/auth/types.ts +2 -2
- package/src/bin/host.ts +1299 -1038
- package/src/donation/__tests__/donation-service.test.ts +1068 -0
- package/src/donation/__tests__/donation-supervisor.test.ts +510 -0
- package/src/donation/__tests__/fake-orchestrator.test.ts +168 -0
- package/src/donation/__tests__/fake-orchestrator.ts +225 -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 +962 -0
- package/src/donation/donation-store.ts +171 -0
- package/src/donation/donation-supervisor.ts +388 -0
- package/src/donation/grant-service.ts +200 -0
- package/src/donation/grant-store.ts +152 -0
- package/src/donation/index.ts +64 -0
- package/src/donation/types.ts +243 -0
- package/src/index.ts +51 -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/__tests__/node-identity.test.ts +62 -0
- package/src/orchestrator/host-process-orchestrator.ts +401 -134
- package/src/orchestrator/index.ts +3 -3
- package/src/orchestrator/node-identity.ts +40 -0
- package/src/orchestrator/port-allocator.ts +48 -0
- 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} +83 -23
- 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 +263 -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/__tests__/strands-route.test.ts +233 -0
- package/src/server/error-handler.ts +67 -1
- package/src/server/events/types.ts +1 -0
- package/src/server/index.ts +75 -17
- package/src/server/routes/grants-admin.ts +41 -0
- package/src/server/routes/grants.ts +210 -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/server/routes/strands.ts +54 -0
- package/src/strands/__tests__/strand-service.test.ts +165 -0
- package/src/strands/index.ts +14 -0
- package/src/strands/strand-service.ts +135 -0
- package/src/strands/types.ts +102 -0
- package/src/update/__tests__/release-key.test.ts +16 -3
- package/src/update/release-key.ts +8 -6
- package/dist/authority/authority-node-client.d.ts +0 -77
- 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/dist/ui/assets/index-BlMXQPSw.js +0 -14
- package/dist/ui/assets/index-BlMXQPSw.js.map +0 -1
- package/dist/ui/assets/index-BwU5mJGF.css +0 -1
- package/src/authority/index.ts +0 -12
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/api/strands` route tests — a real Fastify built by `createLocalUiServer`
|
|
3
|
+
* (so the error handler, the mount gate, and the static not-found fallback are
|
|
4
|
+
* all the production ones) over a fake StrandService.
|
|
5
|
+
*
|
|
6
|
+
* What the service does is covered by `strands/__tests__/strand-service.test.ts`;
|
|
7
|
+
* this file is about the HTTP contract: envelope shape, status mapping, the
|
|
8
|
+
* `confirm` query param, and exactly-once bus publication.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
16
|
+
|
|
17
|
+
import { createLocalUiServer } from '../index.js';
|
|
18
|
+
import type { LocalUiEvent } from '../events/types.js';
|
|
19
|
+
import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
|
|
20
|
+
import type { StrandService } from '../../strands/index.js';
|
|
21
|
+
import { StrandError } from '../../strands/index.js';
|
|
22
|
+
import type { StrandListSnapshot, StrandRemovalResult } from '../../strands/index.js';
|
|
23
|
+
|
|
24
|
+
function fakeOrchestrator(): HostProcessOrchestrator {
|
|
25
|
+
return {
|
|
26
|
+
listNodes: () => [],
|
|
27
|
+
getNode: () => undefined,
|
|
28
|
+
resolveDockerId: () => undefined,
|
|
29
|
+
onStateChange: () => () => undefined,
|
|
30
|
+
} as unknown as HostProcessOrchestrator;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SNAPSHOT: StrandListSnapshot = {
|
|
34
|
+
strands: [
|
|
35
|
+
{ id: 'open-one', type: 'o', running: true, status: 'active' },
|
|
36
|
+
{ id: 'closed-one', type: 'c', running: false, status: null },
|
|
37
|
+
],
|
|
38
|
+
controlConnections: 3,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
interface FakeStrands {
|
|
42
|
+
service: StrandService;
|
|
43
|
+
/** Every (id, confirm) the route forwarded. */
|
|
44
|
+
calls: Array<{ strandId: string; confirm: boolean }>;
|
|
45
|
+
/** Set to make `list` and `remove` throw. */
|
|
46
|
+
failWith?: unknown;
|
|
47
|
+
/** Set to make `remove` report a strand that was never published. */
|
|
48
|
+
absent?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fakeStrandService(): FakeStrands {
|
|
52
|
+
const state: FakeStrands = {
|
|
53
|
+
calls: [],
|
|
54
|
+
service: undefined as unknown as StrandService,
|
|
55
|
+
};
|
|
56
|
+
state.service = {
|
|
57
|
+
async list(): Promise<StrandListSnapshot> {
|
|
58
|
+
if (state.failWith) throw state.failWith;
|
|
59
|
+
return SNAPSHOT;
|
|
60
|
+
},
|
|
61
|
+
async remove(strandId: string, opts: { confirm: boolean }): Promise<StrandRemovalResult> {
|
|
62
|
+
state.calls.push({ strandId, confirm: opts.confirm });
|
|
63
|
+
if (state.failWith) throw state.failWith;
|
|
64
|
+
return state.absent
|
|
65
|
+
? { strandId, published: false, type: null, removed: false, alone: false }
|
|
66
|
+
: { strandId, published: true, type: 'o', removed: true, alone: false };
|
|
67
|
+
},
|
|
68
|
+
} as unknown as StrandService;
|
|
69
|
+
return state;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let dataDir: string;
|
|
73
|
+
let server: ReturnType<typeof createLocalUiServer>;
|
|
74
|
+
let strands: FakeStrands;
|
|
75
|
+
let events: LocalUiEvent[];
|
|
76
|
+
|
|
77
|
+
function buildServer(opts: { withStrands: boolean }): void {
|
|
78
|
+
server = createLocalUiServer({
|
|
79
|
+
uiPort: 8765,
|
|
80
|
+
dataDir,
|
|
81
|
+
orchestrator: fakeOrchestrator(),
|
|
82
|
+
...(opts.withStrands ? { strands: strands.service } : {}),
|
|
83
|
+
forcePort: 0,
|
|
84
|
+
});
|
|
85
|
+
events = [];
|
|
86
|
+
server.events.subscribe((e) => events.push(e));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
dataDir = mkdtempSync(join(tmpdir(), 'cadre-host-strands-route-'));
|
|
91
|
+
strands = fakeStrandService();
|
|
92
|
+
buildServer({ withStrands: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(async () => {
|
|
96
|
+
await server.app.close();
|
|
97
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('GET /api/strands', () => {
|
|
101
|
+
it('returns the snapshot in the /api/* envelope', async () => {
|
|
102
|
+
const res = await server.app.inject({ method: 'GET', url: '/api/strands' });
|
|
103
|
+
expect(res.statusCode).toBe(200);
|
|
104
|
+
const body = res.json() as { ok: boolean; data: StrandListSnapshot };
|
|
105
|
+
expect(body.ok).toBe(true);
|
|
106
|
+
expect(body.data.strands).toHaveLength(2);
|
|
107
|
+
expect(body.data.strands[0]?.id).toBe('open-one');
|
|
108
|
+
expect(body.data.controlConnections).toBe(3);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('maps a service failure through the same error handler as the DELETE arm', async () => {
|
|
112
|
+
strands.failWith = new StrandError('node_unavailable', 'Owner node unavailable: refused');
|
|
113
|
+
const res = await server.app.inject({ method: 'GET', url: '/api/strands' });
|
|
114
|
+
expect(res.statusCode).toBe(503);
|
|
115
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('node_unavailable');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('DELETE /api/strands/:id', () => {
|
|
120
|
+
it('removes a published strand → 200 and exactly one strands-changed event', async () => {
|
|
121
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
|
|
122
|
+
expect(res.statusCode).toBe(200);
|
|
123
|
+
const body = res.json() as { ok: boolean; data: StrandRemovalResult };
|
|
124
|
+
expect(body.ok).toBe(true);
|
|
125
|
+
expect(body.data).toMatchObject({ strandId: 'open-one', published: true, removed: true });
|
|
126
|
+
expect(events).toEqual([{ type: 'strands-changed', kind: 'removed' }]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('an absent strand → 200, published: false, and NO event', async () => {
|
|
130
|
+
strands.absent = true;
|
|
131
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/never-published' });
|
|
132
|
+
expect(res.statusCode).toBe(200);
|
|
133
|
+
const body = res.json() as { data: StrandRemovalResult };
|
|
134
|
+
expect(body.data.published).toBe(false);
|
|
135
|
+
expect(body.data.removed).toBe(false);
|
|
136
|
+
expect(events).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('forwards confirm=1 as true and its absence as false', async () => {
|
|
140
|
+
await server.app.inject({ method: 'DELETE', url: '/api/strands/a' });
|
|
141
|
+
await server.app.inject({ method: 'DELETE', url: '/api/strands/b?confirm=1' });
|
|
142
|
+
await server.app.inject({ method: 'DELETE', url: '/api/strands/c?confirm=true' });
|
|
143
|
+
expect(strands.calls).toEqual([
|
|
144
|
+
{ strandId: 'a', confirm: false },
|
|
145
|
+
{ strandId: 'b', confirm: true },
|
|
146
|
+
{ strandId: 'c', confirm: true },
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('treats a guessy confirm value as NOT confirmed', async () => {
|
|
151
|
+
// `yes`/`on`/empty are refused deliberately — a typo must not destroy a
|
|
152
|
+
// closed strand's membership key.
|
|
153
|
+
await server.app.inject({ method: 'DELETE', url: '/api/strands/a?confirm=yes' });
|
|
154
|
+
await server.app.inject({ method: 'DELETE', url: '/api/strands/b?confirm=' });
|
|
155
|
+
expect(strands.calls).toEqual([
|
|
156
|
+
{ strandId: 'a', confirm: false },
|
|
157
|
+
{ strandId: 'b', confirm: false },
|
|
158
|
+
]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('ignores query params other than confirm rather than proxying them', async () => {
|
|
162
|
+
await server.app.inject({ method: 'DELETE', url: '/api/strands/a?force=1&confirm=1' });
|
|
163
|
+
expect(strands.calls).toEqual([{ strandId: 'a', confirm: true }]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// The route is one path segment, so an id holding a `/` has to arrive
|
|
167
|
+
// percent-encoded. Fastify decodes `params`, so the service sees the literal id
|
|
168
|
+
// and forwards it — nothing in this chain needs to refuse such an id.
|
|
169
|
+
it('carries a percent-encoded "/" through to the service as a literal', async () => {
|
|
170
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/ns%2Fstrand' });
|
|
171
|
+
expect(res.statusCode).toBe(200);
|
|
172
|
+
expect(strands.calls).toEqual([{ strandId: 'ns/strand', confirm: false }]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('404s an UNencoded "/" at the router, before any handler runs', async () => {
|
|
176
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/ns/strand' });
|
|
177
|
+
expect(res.statusCode).toBe(404);
|
|
178
|
+
expect(strands.calls).toEqual([]);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('DELETE /api/strands/:id — error mapping', () => {
|
|
183
|
+
it('confirmation_required → 428 with the node message intact', async () => {
|
|
184
|
+
const message = 'Refusing to remove closed strand closed-one without confirmation: …';
|
|
185
|
+
strands.failWith = new StrandError('confirmation_required', message);
|
|
186
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/closed-one' });
|
|
187
|
+
expect(res.statusCode).toBe(428);
|
|
188
|
+
const body = res.json() as { ok: boolean; error: { code: string; message: string } };
|
|
189
|
+
expect(body.ok).toBe(false);
|
|
190
|
+
expect(body.error.code).toBe('confirmation_required');
|
|
191
|
+
expect(body.error.message).toBe(message);
|
|
192
|
+
expect(events).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('node_unavailable → 503', async () => {
|
|
196
|
+
strands.failWith = new StrandError('node_unavailable', 'Owner node unavailable: refused');
|
|
197
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
|
|
198
|
+
expect(res.statusCode).toBe(503);
|
|
199
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('node_unavailable');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('invalid_id → 400', async () => {
|
|
203
|
+
strands.failWith = new StrandError('invalid_id', 'A strand id is required');
|
|
204
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/%20' });
|
|
205
|
+
expect(res.statusCode).toBe(400);
|
|
206
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_id');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('internal → 500', async () => {
|
|
210
|
+
strands.failWith = new StrandError('internal', 'node said something new');
|
|
211
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
|
|
212
|
+
expect(res.statusCode).toBe(500);
|
|
213
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('internal');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('donor-only mode (no strands option)', () => {
|
|
218
|
+
beforeEach(async () => {
|
|
219
|
+
await server.app.close();
|
|
220
|
+
buildServer({ withStrands: false });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('404s GET /api/strands through the static not-found handler', async () => {
|
|
224
|
+
const res = await server.app.inject({ method: 'GET', url: '/api/strands' });
|
|
225
|
+
expect(res.statusCode).toBe(404);
|
|
226
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('not_found');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('404s DELETE /api/strands/:id', async () => {
|
|
230
|
+
const res = await server.app.inject({ method: 'DELETE', url: '/api/strands/open-one' });
|
|
231
|
+
expect(res.statusCode).toBe(404);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Fastify error handler — translates known typed errors to stable HTTP status
|
|
3
3
|
* codes and uniform `{ ok: false, error: { code, message } }` payloads.
|
|
4
4
|
*
|
|
5
|
-
* Codes (cross-referenced with `auth/types.ts`, `nat/types.ts`, `
|
|
5
|
+
* Codes (cross-referenced with `auth/types.ts`, `nat/types.ts`, `strands/types.ts`,
|
|
6
|
+
* `update/types.ts`):
|
|
6
7
|
* - TrustCircleError
|
|
7
8
|
* invalid_label, invalid_token → 400
|
|
8
9
|
* not_found, already_redeemed → 404
|
|
@@ -16,12 +17,32 @@
|
|
|
16
17
|
* mapping_failed, router_unreachable,
|
|
17
18
|
* ip_detection_failed, ddns_update_failed → 500
|
|
18
19
|
* node_unavailable → 503
|
|
20
|
+
* - StrandError
|
|
21
|
+
* invalid_id → 400
|
|
22
|
+
* confirmation_required → 428
|
|
23
|
+
* node_unavailable → 503
|
|
24
|
+
* internal → 500
|
|
19
25
|
* - UpdateErrorException
|
|
20
26
|
* manifest_invalid, no_update_available,
|
|
21
27
|
* min_previous_version → 400
|
|
22
28
|
* apply_in_progress → 409
|
|
23
29
|
* signature_invalid, unsupported_state_version,
|
|
24
30
|
* apply_failed, rollback_failed, storage_error → 500
|
|
31
|
+
* - GrantError
|
|
32
|
+
* invalid_label, invalid_max_nodes,
|
|
33
|
+
* invalid_ttl → 400
|
|
34
|
+
* not_found → 404
|
|
35
|
+
* storage_error → 500
|
|
36
|
+
* - DonationError
|
|
37
|
+
* invalid_request → 400
|
|
38
|
+
* unauthorized → 401
|
|
39
|
+
* forbidden → 403
|
|
40
|
+
* not_found → 404
|
|
41
|
+
* invalid_state → 409
|
|
42
|
+
* quota_exceeded → 429
|
|
43
|
+
* orchestrator_error, storage_error → 500
|
|
44
|
+
* seed_failed → 502
|
|
45
|
+
* peer_unavailable → 503
|
|
25
46
|
* - everything else → 500 (code "internal")
|
|
26
47
|
*/
|
|
27
48
|
|
|
@@ -30,7 +51,9 @@ import debug from 'debug';
|
|
|
30
51
|
|
|
31
52
|
import { TrustCircleError, type TrustCircleErrorCode } from '../auth/types.js';
|
|
32
53
|
import { NatError, type NatErrorCode } from '../nat/types.js';
|
|
54
|
+
import { StrandError, type StrandErrorCode } from '../strands/types.js';
|
|
33
55
|
import { UpdateErrorException, type UpdateErrorCode } from '../update/types.js';
|
|
56
|
+
import { GrantError, type GrantErrorCode, DonationError, type DonationErrorCode } from '../donation/types.js';
|
|
34
57
|
|
|
35
58
|
const log = debug('cadre:host:error-handler');
|
|
36
59
|
|
|
@@ -57,6 +80,40 @@ const NAT_STATUS: Record<NatErrorCode, number> = {
|
|
|
57
80
|
node_unavailable: 503,
|
|
58
81
|
};
|
|
59
82
|
|
|
83
|
+
/**
|
|
84
|
+
* 428 (Precondition Required) for `confirmation_required`: the node refused a
|
|
85
|
+
* closed-strand removal because the caller had not confirmed. It is not a 400
|
|
86
|
+
* (the request was well-formed) and not a 409 (nothing conflicts) — the caller
|
|
87
|
+
* must re-send with the precondition satisfied.
|
|
88
|
+
*/
|
|
89
|
+
const STRAND_STATUS: Record<StrandErrorCode, number> = {
|
|
90
|
+
invalid_id: 400,
|
|
91
|
+
confirmation_required: 428,
|
|
92
|
+
node_unavailable: 503,
|
|
93
|
+
internal: 500,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const GRANT_STATUS: Record<GrantErrorCode, number> = {
|
|
97
|
+
invalid_label: 400,
|
|
98
|
+
invalid_max_nodes: 400,
|
|
99
|
+
invalid_ttl: 400,
|
|
100
|
+
not_found: 404,
|
|
101
|
+
storage_error: 500,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const DONATION_STATUS: Record<DonationErrorCode, number> = {
|
|
105
|
+
unauthorized: 401,
|
|
106
|
+
forbidden: 403,
|
|
107
|
+
quota_exceeded: 429,
|
|
108
|
+
invalid_request: 400,
|
|
109
|
+
not_found: 404,
|
|
110
|
+
invalid_state: 409,
|
|
111
|
+
seed_failed: 502,
|
|
112
|
+
peer_unavailable: 503,
|
|
113
|
+
orchestrator_error: 500,
|
|
114
|
+
storage_error: 500,
|
|
115
|
+
};
|
|
116
|
+
|
|
60
117
|
const UPDATE_STATUS: Record<UpdateErrorCode, number> = {
|
|
61
118
|
manifest_invalid: 400,
|
|
62
119
|
no_update_available: 400,
|
|
@@ -97,9 +154,18 @@ function classify(err: FastifyError): Classified {
|
|
|
97
154
|
if (err instanceof NatError) {
|
|
98
155
|
return { status: NAT_STATUS[err.code] ?? 500, code: err.code, message: err.message };
|
|
99
156
|
}
|
|
157
|
+
if (err instanceof StrandError) {
|
|
158
|
+
return { status: STRAND_STATUS[err.code] ?? 500, code: err.code, message: err.message };
|
|
159
|
+
}
|
|
100
160
|
if (err instanceof UpdateErrorException) {
|
|
101
161
|
return { status: UPDATE_STATUS[err.code] ?? 500, code: err.code, message: err.message };
|
|
102
162
|
}
|
|
163
|
+
if (err instanceof GrantError) {
|
|
164
|
+
return { status: GRANT_STATUS[err.code] ?? 500, code: err.code, message: err.message };
|
|
165
|
+
}
|
|
166
|
+
if (err instanceof DonationError) {
|
|
167
|
+
return { status: DONATION_STATUS[err.code] ?? 500, code: err.code, message: err.message };
|
|
168
|
+
}
|
|
103
169
|
// Fastify body-parsing / validation errors have a statusCode we should honour.
|
|
104
170
|
if (typeof err.statusCode === 'number' && err.statusCode >= 400 && err.statusCode < 600) {
|
|
105
171
|
return {
|
|
@@ -12,6 +12,7 @@ import type { DirectReachability, PortForwardMode } from '../../nat/index.js';
|
|
|
12
12
|
export type LocalUiEvent =
|
|
13
13
|
| { type: 'node-state-changed'; nodeId: string; status: ContainerStatus }
|
|
14
14
|
| { type: 'trust-circle-changed'; kind: 'invited' | 'redeemed' | 'revoked' }
|
|
15
|
+
| { type: 'strands-changed'; kind: 'removed' }
|
|
15
16
|
| {
|
|
16
17
|
type: 'connectivity-changed';
|
|
17
18
|
portMode: PortForwardMode;
|
package/src/server/index.ts
CHANGED
|
@@ -20,8 +20,12 @@ import type { TrustCircleService } from '../auth/index.js';
|
|
|
20
20
|
import { createTrustCircleHandlers } from '../auth/index.js';
|
|
21
21
|
import type { NatService } from '../nat/index.js';
|
|
22
22
|
import { createNatHandlers } from '../nat/index.js';
|
|
23
|
+
import type { StrandService } from '../strands/index.js';
|
|
24
|
+
import { createStrandHandlers } from '../strands/index.js';
|
|
23
25
|
import type { UpdateService } from '../update/index.js';
|
|
24
26
|
import { createUpdateHandlers } from '../update/index.js';
|
|
27
|
+
import type { GrantService, DonationService } from '../donation/index.js';
|
|
28
|
+
import { createGrantAdminHandlers } from '../donation/index.js';
|
|
25
29
|
|
|
26
30
|
import { EventBus } from './events/bus.js';
|
|
27
31
|
import { registerSseRoute } from './events/sse-route.js';
|
|
@@ -31,10 +35,13 @@ import { registerStaticMount } from './static.js';
|
|
|
31
35
|
import { buildFastify, startListening, stopListening } from './server.js';
|
|
32
36
|
import { registerNatRoutes } from './routes/nat.js';
|
|
33
37
|
import { registerTrustCircleRoutes } from './routes/trust-circle.js';
|
|
38
|
+
import { registerStrandRoutes } from './routes/strands.js';
|
|
34
39
|
import { registerUpdateRoutes } from './routes/update.js';
|
|
35
40
|
import { registerStatusRoute } from './routes/status.js';
|
|
36
41
|
import { registerNodesRoutes } from './routes/nodes.js';
|
|
37
42
|
import { registerSettingsRoutes } from './routes/settings.js';
|
|
43
|
+
import { registerGrantsAdminRoutes } from './routes/grants-admin.js';
|
|
44
|
+
import { registerGrantsRoutes } from './routes/grants.js';
|
|
38
45
|
import { HostSettingsStore } from './settings-store.js';
|
|
39
46
|
|
|
40
47
|
export interface LocalUiServerOptions {
|
|
@@ -44,10 +51,39 @@ export interface LocalUiServerOptions {
|
|
|
44
51
|
dataDir: string;
|
|
45
52
|
/** Wired dependencies — all owned by the caller. */
|
|
46
53
|
orchestrator: HostProcessOrchestrator;
|
|
47
|
-
|
|
48
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Trust-circle service — present only when the host runs its own personal
|
|
56
|
+
* cadre (`ownCadre.enabled`). Absent in donor-only mode, where `/auth/*`
|
|
57
|
+
* stays unmounted and 404s.
|
|
58
|
+
*/
|
|
59
|
+
trustCircle?: TrustCircleService;
|
|
60
|
+
/**
|
|
61
|
+
* NAT service — present only when the host runs its own personal cadre.
|
|
62
|
+
* Absent in donor-only mode (loopback-only in v1), where `/nat/*` stays
|
|
63
|
+
* unmounted and 404s.
|
|
64
|
+
*/
|
|
65
|
+
nat?: NatService;
|
|
66
|
+
/**
|
|
67
|
+
* Strand service — present only when the host runs its own personal cadre.
|
|
68
|
+
* Absent in donor-only mode, where there is no owner node to ask, so
|
|
69
|
+
* `/api/strands` stays unmounted and 404s through the static handler.
|
|
70
|
+
*/
|
|
71
|
+
strands?: StrandService;
|
|
49
72
|
/** Optional — 6.4.2 lands this; nullable while still iterating. */
|
|
50
73
|
update?: UpdateService;
|
|
74
|
+
/**
|
|
75
|
+
* Donation grant layer. When present, mounts the loopback admin surface at
|
|
76
|
+
* `/grants-admin` (issue/list/revoke). Optional so existing callers/tests
|
|
77
|
+
* that don't exercise donations need not wire it.
|
|
78
|
+
*/
|
|
79
|
+
grants?: GrantService;
|
|
80
|
+
/**
|
|
81
|
+
* Donation lifecycle service. When present (alongside `grants`), mounts the
|
|
82
|
+
* bearer-gated grantee-facing provisioning surface at `/grants`
|
|
83
|
+
* (provision / peer / seed / terminate). Optional so callers/tests that don't
|
|
84
|
+
* exercise the donor path need not wire it.
|
|
85
|
+
*/
|
|
86
|
+
donations?: DonationService;
|
|
51
87
|
/** Settings I/O facade. Defaults to a new one rooted at `dataDir`. */
|
|
52
88
|
settingsStore?: HostSettingsStore;
|
|
53
89
|
/** Bus instance — pass the same one to subsystems that publish events. */
|
|
@@ -99,18 +135,37 @@ export function createLocalUiServer(opts: LocalUiServerOptions): LocalUiServer {
|
|
|
99
135
|
|
|
100
136
|
registerStatusRoute(app, {
|
|
101
137
|
orchestrator: opts.orchestrator,
|
|
102
|
-
trustCircle: opts.trustCircle,
|
|
103
|
-
nat: opts.nat,
|
|
138
|
+
...(opts.trustCircle ? { trustCircle: opts.trustCircle } : {}),
|
|
139
|
+
...(opts.nat ? { nat: opts.nat } : {}),
|
|
104
140
|
...(opts.update ? { update: opts.update } : {}),
|
|
105
141
|
});
|
|
106
142
|
registerNodesRoutes(app, { orchestrator: opts.orchestrator });
|
|
107
|
-
registerSettingsRoutes(app, { settingsStore, nat: opts.nat, ...(opts.update ? { update: opts.update } : {}) });
|
|
143
|
+
registerSettingsRoutes(app, { settingsStore, ...(opts.nat ? { nat: opts.nat } : {}), ...(opts.update ? { update: opts.update } : {}) });
|
|
108
144
|
|
|
109
|
-
|
|
110
|
-
|
|
145
|
+
// Trust-circle + NAT + strand surfaces exist only when the host runs its own
|
|
146
|
+
// personal cadre. In donor-only mode they're left unmounted, so `/auth/*`,
|
|
147
|
+
// `/nat/*` and `/api/strands` fall through to the static not-found handler and
|
|
148
|
+
// 404 (see static.ts).
|
|
149
|
+
if (opts.trustCircle) {
|
|
150
|
+
registerTrustCircleRoutes(app, { handlers: createTrustCircleHandlers(opts.trustCircle), events });
|
|
151
|
+
}
|
|
152
|
+
if (opts.nat) {
|
|
153
|
+
registerNatRoutes(app, { handlers: createNatHandlers(opts.nat), events });
|
|
154
|
+
}
|
|
155
|
+
if (opts.strands) {
|
|
156
|
+
registerStrandRoutes(app, { handlers: createStrandHandlers(opts.strands), events });
|
|
157
|
+
}
|
|
111
158
|
if (opts.update) {
|
|
112
159
|
registerUpdateRoutes(app, { handlers: createUpdateHandlers(opts.update), events });
|
|
113
160
|
}
|
|
161
|
+
if (opts.grants) {
|
|
162
|
+
registerGrantsAdminRoutes(app, { handlers: createGrantAdminHandlers(opts.grants) });
|
|
163
|
+
// Grantee-facing provisioning surface needs both the donation service and a
|
|
164
|
+
// grant validator (the GrantService doubles as the validator).
|
|
165
|
+
if (opts.donations) {
|
|
166
|
+
registerGrantsRoutes(app, { donations: opts.donations, grants: opts.grants });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
114
169
|
|
|
115
170
|
// Static mount registers as the not-found handler — declared last so the
|
|
116
171
|
// API paths above win.
|
|
@@ -147,16 +202,19 @@ export function createLocalUiServer(opts: LocalUiServerOptions): LocalUiServer {
|
|
|
147
202
|
);
|
|
148
203
|
|
|
149
204
|
// 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
|
-
|
|
205
|
+
// signal even if no settings have changed yet this session. Skipped in
|
|
206
|
+
// donor-only mode, where there is no NatService.
|
|
207
|
+
if (opts.nat) {
|
|
208
|
+
try {
|
|
209
|
+
const snap = opts.nat.getStatus();
|
|
210
|
+
events.publish({
|
|
211
|
+
type: 'connectivity-changed',
|
|
212
|
+
portMode: snap.portMode,
|
|
213
|
+
directReachability: snap.directReachability,
|
|
214
|
+
});
|
|
215
|
+
} catch {
|
|
216
|
+
// NatService.start may not have completed; harmless to skip.
|
|
217
|
+
}
|
|
160
218
|
}
|
|
161
219
|
|
|
162
220
|
// 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
|
+
}
|