@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
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
export { HostProcessOrchestrator,
|
|
2
|
-
export type {
|
|
1
|
+
export { HostProcessOrchestrator, OWNER_CONTAINER_ID } from './host-process-orchestrator.js';
|
|
2
|
+
export type { OwnerAdminEndpoint } from './host-process-orchestrator.js';
|
|
3
3
|
export type {
|
|
4
4
|
HostProcessConfig,
|
|
5
5
|
ManagedNodeInfo,
|
|
6
6
|
NodeStateListener,
|
|
7
7
|
NodePorts,
|
|
8
|
-
|
|
8
|
+
OwnerSpawnConfig,
|
|
9
9
|
} from './types.js';
|
|
10
10
|
export type { PersistedHandle } from './state-store.js';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-node identity keys for orchestrator-managed children.
|
|
3
|
+
*
|
|
4
|
+
* A donated node joins a *foreign* cadre: the requester approves its peer id
|
|
5
|
+
* and its seed nominates dial addresses keyed to that peer id. Both bind to the
|
|
6
|
+
* identity, so a node that generated a fresh libp2p keypair per process would
|
|
7
|
+
* become a stranger to the cadre that admitted it on every restart. It also
|
|
8
|
+
* needs a stable on-disk home for its node-local stores — `cadre-cli start`
|
|
9
|
+
* opens `FileBootstrapPeerStore` / `FileTrustedOwnerStore` only when a protobuf
|
|
10
|
+
* identity key file is configured.
|
|
11
|
+
*
|
|
12
|
+
* So every managed node gets its own `identity.key` inside its workdir, written
|
|
13
|
+
* once and reused thereafter. The workdir is removed when the loan is
|
|
14
|
+
* terminated (`removeContainer`), so nothing the node persisted outlives it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { generateIdentity, loadIdentity, type IdentityRecord } from '../installer/identity.js';
|
|
21
|
+
|
|
22
|
+
const IDENTITY_FILE = 'identity.key';
|
|
23
|
+
|
|
24
|
+
/** Path to a managed node's own protobuf identity key inside its workdir. */
|
|
25
|
+
export function nodeIdentityPath(workdir: string): string {
|
|
26
|
+
return join(workdir, IDENTITY_FILE);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Ensure `<workdir>/identity.key` exists, generating an Ed25519 protobuf key on
|
|
31
|
+
* first call and REUSING it on every later call. Reuse is the load-bearing
|
|
32
|
+
* property: a re-spawn of the same container must land on the same peer id.
|
|
33
|
+
*/
|
|
34
|
+
export async function ensureNodeIdentity(workdir: string): Promise<{ path: string; peerId: string }> {
|
|
35
|
+
const path = nodeIdentityPath(workdir);
|
|
36
|
+
const record: IdentityRecord = existsSync(path)
|
|
37
|
+
? loadIdentity(path)
|
|
38
|
+
: await generateIdentity(path);
|
|
39
|
+
return { path, peerId: record.peerId };
|
|
40
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { NodePorts } from './types.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Range-based port tracker. Allocations are in-memory; on orchestrator
|
|
3
5
|
* restart the caller rehydrates the used-set from persisted state via
|
|
@@ -41,3 +43,49 @@ export class PortAllocator {
|
|
|
41
43
|
return this.usedPorts.has(port);
|
|
42
44
|
}
|
|
43
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Allocation order for a node's port set. Fixed so that swapping a caller onto
|
|
49
|
+
* {@link allocateNodePorts} does not shift the ports an existing deployment
|
|
50
|
+
* already handed out.
|
|
51
|
+
*/
|
|
52
|
+
const NODE_PORT_KEYS = ['health', 'metrics', 'p2p', 'admin'] as const;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Allocate a full NodePorts set atomically — a mid-way failure (an exhausted
|
|
56
|
+
* range) releases everything already taken rather than stranding the ports it
|
|
57
|
+
* got before the throw. `overrides` supply a port instead of allocating one
|
|
58
|
+
* (the owner node's p2p port, which is fixed by NAT config).
|
|
59
|
+
*
|
|
60
|
+
* Overrides are reserved FIRST, before any allocation, so an override that
|
|
61
|
+
* happens to sit inside the managed range cannot also be handed out to another
|
|
62
|
+
* key. Reserving is `markUsed`, a documented no-op outside the range — which is
|
|
63
|
+
* the production case for the owner node's libp2p port, and why reserving first
|
|
64
|
+
* leaves every real port assignment unchanged.
|
|
65
|
+
*/
|
|
66
|
+
export function allocateNodePorts(
|
|
67
|
+
allocator: PortAllocator,
|
|
68
|
+
overrides?: Partial<NodePorts>,
|
|
69
|
+
): NodePorts {
|
|
70
|
+
const reserved: number[] = [];
|
|
71
|
+
const ports: Partial<NodePorts> = {};
|
|
72
|
+
try {
|
|
73
|
+
for (const key of NODE_PORT_KEYS) {
|
|
74
|
+
const override = overrides?.[key];
|
|
75
|
+
if (override === undefined) continue;
|
|
76
|
+
allocator.markUsed(override);
|
|
77
|
+
reserved.push(override);
|
|
78
|
+
ports[key] = override;
|
|
79
|
+
}
|
|
80
|
+
for (const key of NODE_PORT_KEYS) {
|
|
81
|
+
if (ports[key] !== undefined) continue;
|
|
82
|
+
const port = allocator.allocate();
|
|
83
|
+
reserved.push(port);
|
|
84
|
+
ports[key] = port;
|
|
85
|
+
}
|
|
86
|
+
} catch (err) {
|
|
87
|
+
for (const port of reserved) allocator.release(port);
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
return ports as NodePorts;
|
|
91
|
+
}
|
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, renameSync, writeFileSync, mkdirSync } from '
|
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import debug from 'debug';
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type { OwnerSpawnConfig, NodePorts } from './types.js';
|
|
6
6
|
|
|
7
7
|
const log = debug('cadre:host:state-store');
|
|
8
8
|
|
|
@@ -21,15 +21,15 @@ export interface PersistedHandle {
|
|
|
21
21
|
spawnedAt: string; // ISO timestamp
|
|
22
22
|
partyId: string;
|
|
23
23
|
profile: 'storage' | 'transaction';
|
|
24
|
-
/** True for the admin's
|
|
25
|
-
|
|
24
|
+
/** True for the admin's owner node. */
|
|
25
|
+
owner?: boolean;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export interface PersistedState {
|
|
29
29
|
version: 1;
|
|
30
30
|
handles: PersistedHandle[];
|
|
31
|
-
/** Spawn parameters for the
|
|
32
|
-
|
|
31
|
+
/** Spawn parameters for the owner node, so it can be re-spawned on demand. */
|
|
32
|
+
ownerConfig?: OwnerSpawnConfig;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
const STATE_VERSION = 1;
|
|
@@ -57,7 +57,7 @@ export class StateStore {
|
|
|
57
57
|
return {
|
|
58
58
|
version: STATE_VERSION,
|
|
59
59
|
handles: parsed.handles,
|
|
60
|
-
...(parsed.
|
|
60
|
+
...(parsed.ownerConfig ? { ownerConfig: parsed.ownerConfig } : {}),
|
|
61
61
|
};
|
|
62
62
|
} catch (err) {
|
|
63
63
|
// A corrupt state file is operationally significant — surviving children
|
|
@@ -74,7 +74,7 @@ export class StateStore {
|
|
|
74
74
|
{
|
|
75
75
|
version: STATE_VERSION,
|
|
76
76
|
handles: state.handles,
|
|
77
|
-
...(state.
|
|
77
|
+
...(state.ownerConfig ? { ownerConfig: state.ownerConfig } : {}),
|
|
78
78
|
},
|
|
79
79
|
null,
|
|
80
80
|
2,
|
|
@@ -47,7 +47,7 @@ export interface NodePorts {
|
|
|
47
47
|
health: number;
|
|
48
48
|
metrics: number;
|
|
49
49
|
p2p: number;
|
|
50
|
-
/** Loopback admin-channel port. Bound only by the
|
|
50
|
+
/** Loopback admin-channel port. Bound only by the owner node. */
|
|
51
51
|
admin: number;
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -62,8 +62,8 @@ export interface Handle {
|
|
|
62
62
|
spawnedAt: string;
|
|
63
63
|
partyId: string;
|
|
64
64
|
profile: 'storage' | 'transaction';
|
|
65
|
-
/** True for the admin's
|
|
66
|
-
|
|
65
|
+
/** True for the admin's owner node (binds the admin channel). */
|
|
66
|
+
owner?: boolean;
|
|
67
67
|
/** Live ChildProcess reference; absent after re-attach via init(). */
|
|
68
68
|
child?: ChildProcess;
|
|
69
69
|
/** Marked false when init() finds the PID dead or token mismatched. */
|
|
@@ -83,16 +83,16 @@ export interface ManagedNodeInfo {
|
|
|
83
83
|
spawnedAt: string;
|
|
84
84
|
workdir: string;
|
|
85
85
|
ports: NodePorts;
|
|
86
|
-
/** True for the admin's
|
|
87
|
-
|
|
86
|
+
/** True for the admin's owner node. */
|
|
87
|
+
owner?: boolean;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
|
-
* Persisted spawn parameters for the admin's
|
|
92
|
-
* the handles so an orchestrator restart can re-spawn the
|
|
91
|
+
* Persisted spawn parameters for the admin's owner node. Stored alongside
|
|
92
|
+
* the handles so an orchestrator restart can re-spawn the owner node from
|
|
93
93
|
* `/api/nodes/:id/{start,restart}` without the caller re-supplying them.
|
|
94
94
|
*/
|
|
95
|
-
export interface
|
|
95
|
+
export interface OwnerSpawnConfig {
|
|
96
96
|
/** Absolute path to the host's libp2p protobuf identity (`identity.key`). */
|
|
97
97
|
identityPath: string;
|
|
98
98
|
/** Control-network partyId (the host's installId). */
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
2
|
import http from 'node:http';
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
import type {
|
|
4
|
+
import { OwnerNodeClient, OwnerNodeUnavailableError } from '../owner-node-client.js';
|
|
5
|
+
import type { OwnerAdminEndpoint } from '../../orchestrator/index.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Stub HTTP server matching the 6.6 admin contract. Records the last request
|
|
@@ -52,16 +52,16 @@ afterEach(async () => {
|
|
|
52
52
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
function makeClient():
|
|
56
|
-
const endpoint:
|
|
57
|
-
return new
|
|
55
|
+
function makeClient(): OwnerNodeClient {
|
|
56
|
+
const endpoint: OwnerAdminEndpoint = { baseUrl, token: TOKEN };
|
|
57
|
+
return new OwnerNodeClient(endpoint);
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
describe('
|
|
60
|
+
describe('OwnerNodeClient', () => {
|
|
61
61
|
it('createInvite POSTs /admin/invites with bearer + body and unwraps data', async () => {
|
|
62
62
|
responder = () => ({
|
|
63
63
|
status: 200,
|
|
64
|
-
payload: { ok: true, data: { invite: { partyId: 'p',
|
|
64
|
+
payload: { ok: true, data: { invite: { partyId: 'p', ownerAddrs: [], token: 't', createdAt: 1 }, encodedInvite: 'enc' } },
|
|
65
65
|
});
|
|
66
66
|
const client = makeClient();
|
|
67
67
|
const result = await client.createInvite('t', 60_000);
|
|
@@ -75,7 +75,7 @@ describe('AuthorityNodeClient', () => {
|
|
|
75
75
|
|
|
76
76
|
it('acceptPhone POSTs /admin/accept-phone', async () => {
|
|
77
77
|
const client = makeClient();
|
|
78
|
-
await client.acceptPhone({ phonePeerId: '12D3KooWX', token: 't' }, { partyId: 'p',
|
|
78
|
+
await client.acceptPhone({ phonePeerId: '12D3KooWX', token: 't' }, { partyId: 'p', ownerAddrs: [], token: 't', createdAt: 1 });
|
|
79
79
|
expect(last?.method).toBe('POST');
|
|
80
80
|
expect(last?.path).toBe('/admin/accept-phone');
|
|
81
81
|
const body = JSON.parse(last!.body);
|
|
@@ -106,6 +106,22 @@ describe('AuthorityNodeClient', () => {
|
|
|
106
106
|
expect(last?.path).toBe('/admin/members/peer-1');
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
it('listAuthorizedMembers GETs /admin/authorized-members and returns the array', async () => {
|
|
110
|
+
responder = () => ({ status: 200, payload: { ok: true, data: { members: [{ peerId: 'b', multiaddr: null }] } } });
|
|
111
|
+
const client = makeClient();
|
|
112
|
+
const members = await client.listAuthorizedMembers();
|
|
113
|
+
expect(last?.method).toBe('GET');
|
|
114
|
+
expect(last?.path).toBe('/admin/authorized-members');
|
|
115
|
+
expect(members).toEqual([{ peerId: 'b', multiaddr: null }]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('isAuthorizedMember GETs /admin/authorized-members/:peerId and returns the boolean', async () => {
|
|
119
|
+
responder = () => ({ status: 200, payload: { ok: true, data: { member: false } } });
|
|
120
|
+
const client = makeClient();
|
|
121
|
+
expect(await client.isAuthorizedMember('peer-2')).toBe(false);
|
|
122
|
+
expect(last?.path).toBe('/admin/authorized-members/peer-2');
|
|
123
|
+
});
|
|
124
|
+
|
|
109
125
|
it('getPeerId GETs /admin/identity', async () => {
|
|
110
126
|
responder = () => ({ status: 200, payload: { ok: true, data: { peerId: '12D3KooWHost', partyId: 'party' } } });
|
|
111
127
|
const client = makeClient();
|
|
@@ -128,22 +144,34 @@ describe('AuthorityNodeClient', () => {
|
|
|
128
144
|
expect(JSON.parse(last!.body)).toEqual({ addresses: ['/dns4/h/tcp/1/p2p/x'] });
|
|
129
145
|
});
|
|
130
146
|
|
|
147
|
+
it('addDrone POSTs /admin/add-drone with body and unwraps the seed result', async () => {
|
|
148
|
+
responder = () => ({ status: 200, payload: { ok: true, data: { seed: { partyId: 'p', peers: [] }, encodedSeed: 'enc-seed' } } });
|
|
149
|
+
const client = makeClient();
|
|
150
|
+
const result = await client.addDrone({ dronePeerId: '12D3KooWDrone', droneMultiaddrs: ['/ip4/10.0.0.9/tcp/4001'] });
|
|
151
|
+
|
|
152
|
+
expect(result.encodedSeed).toBe('enc-seed');
|
|
153
|
+
expect(last?.method).toBe('POST');
|
|
154
|
+
expect(last?.path).toBe('/admin/add-drone');
|
|
155
|
+
expect(last?.auth).toBe(`Bearer ${TOKEN}`);
|
|
156
|
+
expect(JSON.parse(last!.body)).toEqual({ dronePeerId: '12D3KooWDrone', droneMultiaddrs: ['/ip4/10.0.0.9/tcp/4001'] });
|
|
157
|
+
});
|
|
158
|
+
|
|
131
159
|
it('encodeInvite mirrors cadre-core base64url(JSON)', () => {
|
|
132
160
|
const client = makeClient();
|
|
133
|
-
const invite = { partyId: 'p',
|
|
161
|
+
const invite = { partyId: 'p', ownerAddrs: [], token: 't', createdAt: 1 };
|
|
134
162
|
const encoded = client.encodeInvite(invite);
|
|
135
163
|
const decoded = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
|
|
136
164
|
expect(decoded).toEqual(invite);
|
|
137
165
|
});
|
|
138
166
|
|
|
139
|
-
it('maps a non-2xx response to
|
|
167
|
+
it('maps a non-2xx response to OwnerNodeUnavailableError carrying the code', async () => {
|
|
140
168
|
responder = () => ({ status: 503, payload: { ok: false, error: { code: 'not_ready', message: 'node booting' } } });
|
|
141
169
|
const client = makeClient();
|
|
142
|
-
await expect(client.getPeerId()).rejects.toBeInstanceOf(
|
|
170
|
+
await expect(client.getPeerId()).rejects.toBeInstanceOf(OwnerNodeUnavailableError);
|
|
143
171
|
await expect(client.getPeerId()).rejects.toMatchObject({ nodeCode: 'not_ready' });
|
|
144
172
|
});
|
|
145
173
|
|
|
146
|
-
it('maps a refused connection to
|
|
174
|
+
it('maps a refused connection to OwnerNodeUnavailableError', async () => {
|
|
147
175
|
// Bind a throwaway server to claim a port, then close it so the address
|
|
148
176
|
// is guaranteed refused.
|
|
149
177
|
const tmp = http.createServer();
|
|
@@ -155,12 +183,12 @@ describe('AuthorityNodeClient', () => {
|
|
|
155
183
|
});
|
|
156
184
|
await new Promise<void>((resolve) => tmp.close(() => resolve()));
|
|
157
185
|
|
|
158
|
-
const client = new
|
|
159
|
-
await expect(client.getPeerId()).rejects.toBeInstanceOf(
|
|
186
|
+
const client = new OwnerNodeClient({ baseUrl: `http://127.0.0.1:${closedPort}`, token: TOKEN });
|
|
187
|
+
await expect(client.getPeerId()).rejects.toBeInstanceOf(OwnerNodeUnavailableError);
|
|
160
188
|
});
|
|
161
189
|
|
|
162
|
-
it('throws
|
|
163
|
-
const client = new
|
|
164
|
-
await expect(client.getPeerId()).rejects.toBeInstanceOf(
|
|
190
|
+
it('throws OwnerNodeUnavailableError when the endpoint is undefined', async () => {
|
|
191
|
+
const client = new OwnerNodeClient(() => undefined);
|
|
192
|
+
await expect(client.getPeerId()).rejects.toBeInstanceOf(OwnerNodeUnavailableError);
|
|
165
193
|
});
|
|
166
194
|
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-node delegation surface for cadre-host.
|
|
3
|
+
*
|
|
4
|
+
* The manager process spawns the admin's owner cadre node (via
|
|
5
|
+
* `HostProcessOrchestrator`) and delegates owner/membership/identity
|
|
6
|
+
* operations to it over the loopback admin channel using
|
|
7
|
+
* {@link OwnerNodeClient}. See `docs/cadre-host.md` § Control-plane
|
|
8
|
+
* separation.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { OwnerNodeClient, OwnerNodeUnavailableError } from './owner-node-client.js';
|
|
12
|
+
export type { OwnerNodeClientOptions } from './owner-node-client.js';
|
|
@@ -1,69 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTTP client for the
|
|
2
|
+
* HTTP client for the owner node's loopback admin channel (the 6.6
|
|
3
3
|
* `cadre-node-admin-channel` contract).
|
|
4
4
|
*
|
|
5
5
|
* cadre-host's manager process holds no in-process `CadreNode`; it spawns the
|
|
6
|
-
* admin's
|
|
7
|
-
* and delegates all
|
|
8
|
-
* client. The client implements **
|
|
9
|
-
* the trust-circle one (`auth/trust-circle.ts`)
|
|
10
|
-
* (`nat/nat-service.ts`)
|
|
6
|
+
* admin's owner node as a managed child (see `HostProcessOrchestrator`)
|
|
7
|
+
* and delegates all owner/membership/identity operations to it over this
|
|
8
|
+
* client. The client implements **all three** trimmed `CadreNodeLike`
|
|
9
|
+
* interfaces — the trust-circle one (`auth/trust-circle.ts`), the NAT one
|
|
10
|
+
* (`nat/nat-service.ts`), and the strand one (`strands/strand-service.ts`) —
|
|
11
|
+
* plus `pushInviteAddresses` and `addDrone` (the node-donation requester side).
|
|
11
12
|
*
|
|
12
13
|
* Transport: `Authorization: Bearer <token>` against
|
|
13
14
|
* `http://127.0.0.1:<adminPort>`. Every response uses the cadre-provider
|
|
14
15
|
* envelope `{ ok: true, data }` / `{ ok: false, error: { code, message } }`.
|
|
15
16
|
* A refused connection, a transport error, or any non-2xx response surfaces
|
|
16
|
-
* as {@link
|
|
17
|
+
* as {@link OwnerNodeUnavailableError}; the trust-circle / NAT services
|
|
17
18
|
* translate that into a `node_unavailable` domain error so the management API
|
|
18
19
|
* returns a clear 503 rather than a raw 500.
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
import debug from 'debug';
|
|
22
|
-
import type { CadreInvite } from '@serfab/cadre-core';
|
|
23
|
+
import type { CadreInvite, DroneInitResult } from '@serfab/cadre-core';
|
|
23
24
|
|
|
24
|
-
import type {
|
|
25
|
+
import type { OwnerAdminEndpoint } from '../orchestrator/index.js';
|
|
25
26
|
import type { CadreNodeLike as TrustCircleCadreNodeLike } from '../auth/trust-circle.js';
|
|
26
27
|
import type { CadreNodeLike as NatCadreNodeLike } from '../nat/nat-service.js';
|
|
28
|
+
import type { CadreNodeLike as StrandCadreNodeLike } from '../strands/strand-service.js';
|
|
29
|
+
import type { StrandListSnapshot, StrandRemovalResult } from '../strands/types.js';
|
|
27
30
|
|
|
28
|
-
const log = debug('cadre:host:
|
|
31
|
+
const log = debug('cadre:host:owner-client');
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
|
-
* Raised when the
|
|
34
|
+
* Raised when the owner node's admin channel can't be reached or returns
|
|
32
35
|
* a non-success response. Carries the node's stable error `code` when one was
|
|
33
36
|
* present in the envelope (e.g. `not_ready`, `not_authorized`).
|
|
34
37
|
*/
|
|
35
|
-
export class
|
|
38
|
+
export class OwnerNodeUnavailableError extends Error {
|
|
36
39
|
/** The admin-channel error code, when the node answered with an envelope. */
|
|
37
40
|
readonly nodeCode?: string;
|
|
38
41
|
|
|
39
42
|
constructor(message: string, nodeCode?: string) {
|
|
40
43
|
super(message);
|
|
41
|
-
this.name = '
|
|
44
|
+
this.name = 'OwnerNodeUnavailableError';
|
|
42
45
|
if (nodeCode !== undefined) this.nodeCode = nodeCode;
|
|
43
46
|
}
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/** Endpoint provided directly or via a getter (so it tracks re-spawns). */
|
|
47
50
|
type EndpointSource =
|
|
48
|
-
|
|
|
49
|
-
| (() =>
|
|
51
|
+
| OwnerAdminEndpoint
|
|
52
|
+
| (() => OwnerAdminEndpoint | undefined)
|
|
50
53
|
| undefined;
|
|
51
54
|
|
|
52
|
-
export interface
|
|
55
|
+
export interface OwnerNodeClientOptions {
|
|
53
56
|
/** Fetch override for tests. Defaults to the global `fetch`. */
|
|
54
57
|
fetch?: typeof fetch;
|
|
55
58
|
}
|
|
56
59
|
|
|
57
|
-
export class
|
|
60
|
+
export class OwnerNodeClient
|
|
61
|
+
implements TrustCircleCadreNodeLike, NatCadreNodeLike, StrandCadreNodeLike {
|
|
58
62
|
private readonly endpointSource: EndpointSource;
|
|
59
63
|
private readonly fetchImpl: typeof fetch;
|
|
60
64
|
|
|
61
65
|
/**
|
|
62
66
|
* @param endpoint Either a fixed `{ baseUrl, token }` or a getter returning
|
|
63
|
-
* the current endpoint (pass `orchestrator.
|
|
67
|
+
* the current endpoint (pass `orchestrator.getOwnerAdminEndpoint`
|
|
64
68
|
* bound, so a restart's new bearer token is picked up automatically).
|
|
65
69
|
*/
|
|
66
|
-
constructor(endpoint: EndpointSource, opts:
|
|
70
|
+
constructor(endpoint: EndpointSource, opts: OwnerNodeClientOptions = {}) {
|
|
67
71
|
this.endpointSource = endpoint;
|
|
68
72
|
this.fetchImpl = opts.fetch ?? fetch;
|
|
69
73
|
}
|
|
@@ -123,6 +127,23 @@ export class AuthorityNodeClient implements TrustCircleCadreNodeLike, NatCadreNo
|
|
|
123
127
|
return data.member;
|
|
124
128
|
}
|
|
125
129
|
|
|
130
|
+
/** Authorized-membership surface (excludes self) — see {@link listMembers} for the addressable one. */
|
|
131
|
+
async listAuthorizedMembers(): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
|
|
132
|
+
const data = await this.request<{ members: Array<{ peerId: string; multiaddr: string | null }> }>(
|
|
133
|
+
'GET',
|
|
134
|
+
'/admin/authorized-members',
|
|
135
|
+
);
|
|
136
|
+
return data.members;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async isAuthorizedMember(peerId: string): Promise<boolean> {
|
|
140
|
+
const data = await this.request<{ member: boolean }>(
|
|
141
|
+
'GET',
|
|
142
|
+
`/admin/authorized-members/${encodeURIComponent(peerId)}`,
|
|
143
|
+
);
|
|
144
|
+
return data.member;
|
|
145
|
+
}
|
|
146
|
+
|
|
126
147
|
// --- NAT CadreNodeLike ---
|
|
127
148
|
|
|
128
149
|
async getPeerId(): Promise<string> {
|
|
@@ -135,6 +156,45 @@ export class AuthorityNodeClient implements TrustCircleCadreNodeLike, NatCadreNo
|
|
|
135
156
|
return data.multiaddrs;
|
|
136
157
|
}
|
|
137
158
|
|
|
159
|
+
// --- strand CadreNodeLike ---
|
|
160
|
+
|
|
161
|
+
/** This party's strands plus the node's current control-connection count. */
|
|
162
|
+
async listStrands(): Promise<StrandListSnapshot> {
|
|
163
|
+
return await this.request<StrandListSnapshot>('GET', '/admin/strands');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Remove this party's participation in one strand.
|
|
168
|
+
*
|
|
169
|
+
* `?confirm=1` is appended **only** when the caller asked for it. The node
|
|
170
|
+
* refuses an unconfirmed closed-strand removal with `confirmation_required`;
|
|
171
|
+
* that refusal is surfaced, never retried with the flag added — the row holds
|
|
172
|
+
* this party's membership key for the strand and nothing else does.
|
|
173
|
+
*/
|
|
174
|
+
async removeStrand(strandId: string, opts: { confirm: boolean }): Promise<StrandRemovalResult> {
|
|
175
|
+
const query = opts.confirm ? '?confirm=1' : '';
|
|
176
|
+
return await this.request<StrandRemovalResult>(
|
|
177
|
+
'DELETE',
|
|
178
|
+
`/admin/strands/${encodeURIComponent(strandId)}${query}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// --- drone seeding (node-donation requester side) ---
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Mint a seed authorizing a drone — a provider-hosted / donated node — to join
|
|
186
|
+
* this node's cadre. The node signs the seed with its own authority key; only
|
|
187
|
+
* the signed, public `encodedSeed` is returned (the private key never leaves
|
|
188
|
+
* the node). This is the requester ("phone") side of the node-donation flow:
|
|
189
|
+
* the donor host presents the returned seed to the donated node's `POST /seed`.
|
|
190
|
+
*/
|
|
191
|
+
async addDrone(options: { dronePeerId: string; droneMultiaddrs: string[] }): Promise<DroneInitResult> {
|
|
192
|
+
return await this.request<DroneInitResult>('POST', '/admin/add-drone', {
|
|
193
|
+
dronePeerId: options.dronePeerId,
|
|
194
|
+
droneMultiaddrs: options.droneMultiaddrs,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
138
198
|
// --- push-model invite addresses ---
|
|
139
199
|
|
|
140
200
|
/** Push the NAT-resolved addresses the node should embed in future invites. */
|
|
@@ -144,10 +204,10 @@ export class AuthorityNodeClient implements TrustCircleCadreNodeLike, NatCadreNo
|
|
|
144
204
|
|
|
145
205
|
// --- internals ---
|
|
146
206
|
|
|
147
|
-
private resolveEndpoint():
|
|
207
|
+
private resolveEndpoint(): OwnerAdminEndpoint {
|
|
148
208
|
const ep = typeof this.endpointSource === 'function' ? this.endpointSource() : this.endpointSource;
|
|
149
209
|
if (!ep) {
|
|
150
|
-
throw new
|
|
210
|
+
throw new OwnerNodeUnavailableError('Owner node admin endpoint is not available');
|
|
151
211
|
}
|
|
152
212
|
return ep;
|
|
153
213
|
}
|
|
@@ -167,7 +227,7 @@ export class AuthorityNodeClient implements TrustCircleCadreNodeLike, NatCadreNo
|
|
|
167
227
|
} catch (err) {
|
|
168
228
|
const message = err instanceof Error ? err.message : String(err);
|
|
169
229
|
log('admin %s %s transport error: %s', method, path, message);
|
|
170
|
-
throw new
|
|
230
|
+
throw new OwnerNodeUnavailableError(`Owner node unreachable: ${message}`);
|
|
171
231
|
}
|
|
172
232
|
|
|
173
233
|
const envelope = await this.parseEnvelope(res);
|
|
@@ -175,7 +235,7 @@ export class AuthorityNodeClient implements TrustCircleCadreNodeLike, NatCadreNo
|
|
|
175
235
|
const code = envelope.error?.code;
|
|
176
236
|
const message = envelope.error?.message ?? `admin ${method} ${path} → HTTP ${res.status}`;
|
|
177
237
|
log('admin %s %s failed: [%s] %s', method, path, code ?? res.status, message);
|
|
178
|
-
throw new
|
|
238
|
+
throw new OwnerNodeUnavailableError(message, code);
|
|
179
239
|
}
|
|
180
240
|
return envelope.data as T;
|
|
181
241
|
}
|
|
@@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
|
4
4
|
import { TrustCircleError } from '../../auth/types.js';
|
|
5
5
|
import { NatError } from '../../nat/types.js';
|
|
6
6
|
import { UpdateErrorException } from '../../update/types.js';
|
|
7
|
+
import { GrantError } from '../../donation/types.js';
|
|
7
8
|
import { registerErrorHandler } from '../error-handler.js';
|
|
8
9
|
|
|
9
10
|
describe('error handler', () => {
|
|
@@ -24,6 +25,10 @@ describe('error handler', () => {
|
|
|
24
25
|
const { code } = req.params as { code: string };
|
|
25
26
|
throw new UpdateErrorException(code as never, `update err: ${code}`);
|
|
26
27
|
});
|
|
28
|
+
app.get('/grant/:code', async (req: FastifyRequest) => {
|
|
29
|
+
const { code } = req.params as { code: string };
|
|
30
|
+
throw new GrantError(code as never, `grant err: ${code}`);
|
|
31
|
+
});
|
|
27
32
|
app.get('/unknown', async () => {
|
|
28
33
|
throw new Error('mystery');
|
|
29
34
|
});
|
|
@@ -85,6 +90,23 @@ describe('error handler', () => {
|
|
|
85
90
|
expect(res.statusCode).toBe(400);
|
|
86
91
|
});
|
|
87
92
|
|
|
93
|
+
it('GrantError invalid_max_nodes → 400', async () => {
|
|
94
|
+
const res = await app.inject({ method: 'GET', url: '/grant/invalid_max_nodes' });
|
|
95
|
+
expect(res.statusCode).toBe(400);
|
|
96
|
+
const body = res.json() as { error: { code: string } };
|
|
97
|
+
expect(body.error.code).toBe('invalid_max_nodes');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('GrantError not_found → 404', async () => {
|
|
101
|
+
const res = await app.inject({ method: 'GET', url: '/grant/not_found' });
|
|
102
|
+
expect(res.statusCode).toBe(404);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('GrantError storage_error → 500', async () => {
|
|
106
|
+
const res = await app.inject({ method: 'GET', url: '/grant/storage_error' });
|
|
107
|
+
expect(res.statusCode).toBe(500);
|
|
108
|
+
});
|
|
109
|
+
|
|
88
110
|
it('unknown Error → 500 internal', async () => {
|
|
89
111
|
const res = await app.inject({ method: 'GET', url: '/unknown' });
|
|
90
112
|
expect(res.statusCode).toBe(500);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { registerErrorHandler } from '../error-handler.js';
|
|
8
|
+
import { registerGrantsAdminRoutes } from '../routes/grants-admin.js';
|
|
9
|
+
import { GrantService, GrantStore, createGrantAdminHandlers } from '../../donation/index.js';
|
|
10
|
+
import type { Grant } from '../../donation/index.js';
|
|
11
|
+
|
|
12
|
+
let tmpRoot: string;
|
|
13
|
+
let app: ReturnType<typeof Fastify>;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-grants-route-'));
|
|
17
|
+
const service = new GrantService({ store: new GrantStore(tmpRoot) });
|
|
18
|
+
app = Fastify();
|
|
19
|
+
registerErrorHandler(app);
|
|
20
|
+
registerGrantsAdminRoutes(app, { handlers: createGrantAdminHandlers(service) });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
await app.close();
|
|
25
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('/grants-admin routes', () => {
|
|
29
|
+
it('POST issues a grant, GET lists it, DELETE revokes it', async () => {
|
|
30
|
+
const post = await app.inject({
|
|
31
|
+
method: 'POST',
|
|
32
|
+
url: '/grants-admin',
|
|
33
|
+
payload: { label: "Alice's cadre", maxNodes: 2 },
|
|
34
|
+
});
|
|
35
|
+
expect(post.statusCode).toBe(200);
|
|
36
|
+
const created = (post.json() as { grant: Grant }).grant;
|
|
37
|
+
expect(created.token).toBeTruthy();
|
|
38
|
+
expect(created.maxNodes).toBe(2);
|
|
39
|
+
|
|
40
|
+
const list = await app.inject({ method: 'GET', url: '/grants-admin' });
|
|
41
|
+
expect(list.statusCode).toBe(200);
|
|
42
|
+
expect((list.json() as { grants: Grant[] }).grants.map(g => g.token)).toContain(created.token);
|
|
43
|
+
|
|
44
|
+
const del = await app.inject({
|
|
45
|
+
method: 'DELETE',
|
|
46
|
+
url: `/grants-admin/${encodeURIComponent(created.token)}`,
|
|
47
|
+
});
|
|
48
|
+
expect(del.statusCode).toBe(200);
|
|
49
|
+
|
|
50
|
+
// Revoked → still listed, now carries revokedAt.
|
|
51
|
+
const after = await app.inject({ method: 'GET', url: '/grants-admin' });
|
|
52
|
+
const row = (after.json() as { grants: Grant[] }).grants.find(g => g.token === created.token);
|
|
53
|
+
expect(row?.revokedAt).toBeTruthy();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('POST with an empty label → 400 invalid_label', async () => {
|
|
57
|
+
const res = await app.inject({ method: 'POST', url: '/grants-admin', payload: { label: ' ' } });
|
|
58
|
+
expect(res.statusCode).toBe(400);
|
|
59
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_label');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('POST with a bad maxNodes → 400 invalid_max_nodes', async () => {
|
|
63
|
+
const res = await app.inject({ method: 'POST', url: '/grants-admin', payload: { label: 'X', maxNodes: 0 } });
|
|
64
|
+
expect(res.statusCode).toBe(400);
|
|
65
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_max_nodes');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('DELETE of an unknown token → 404 not_found', async () => {
|
|
69
|
+
const res = await app.inject({ method: 'DELETE', url: '/grants-admin/nope' });
|
|
70
|
+
expect(res.statusCode).toBe(404);
|
|
71
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe('not_found');
|
|
72
|
+
});
|
|
73
|
+
});
|