@serfab/cadre-host 1.10.0 → 1.12.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.
@@ -1,73 +1,73 @@
1
- import {
2
- allocatePortSet,
3
- releasePortSet,
4
- reservePortSet,
5
- type PortAllocator,
6
- } from '@serfab/cadre-provider';
7
-
8
- import type { NodePorts } from './types.js';
9
-
10
- /**
11
- * Allocation order for a node's port set. Fixed so that swapping a caller onto
12
- * {@link allocateNodePorts} does not shift the ports an existing deployment
13
- * already handed out — which is also why a key added later goes on the end
14
- * (`ws` came after the first four).
15
- */
16
- const NODE_PORT_KEYS = ['health', 'metrics', 'p2p', 'admin', 'ws'] as const satisfies readonly (keyof NodePorts)[];
17
-
18
- /**
19
- * Allocate a full NodePorts set atomically, in {@link NODE_PORT_KEYS} order.
20
- * `overrides` supply a port instead of allocating one (the owner node's p2p
21
- * port, which is fixed by NAT config; a re-spawn's previous ports, see
22
- * {@link reusedNodePorts}). Overrides are reserved before anything is allocated
23
- * and are trusted, not checked — see `allocatePortSet`.
24
- */
25
- export function allocateNodePorts(
26
- allocator: PortAllocator,
27
- overrides?: Partial<NodePorts>,
28
- ): NodePorts {
29
- return allocatePortSet(allocator, NODE_PORT_KEYS, overrides);
30
- }
31
-
32
- /**
33
- * Hold a node's whole port set against `allocator` — the inverse of
34
- * {@link releaseNodePorts}. A key the set lacks is skipped (see `markUsed`).
35
- */
36
- export function reserveNodePorts(allocator: PortAllocator, ports: NodePorts): void {
37
- reservePortSet(allocator, NODE_PORT_KEYS, ports);
38
- }
39
-
40
- /**
41
- * Give a node's whole port set back to `allocator`. A key the set lacks releases
42
- * nothing: deleting a value the used-set never held is a no-op.
43
- */
44
- export function releaseNodePorts(allocator: PortAllocator, ports: NodePorts): void {
45
- releasePortSet(allocator, NODE_PORT_KEYS, ports);
46
- }
47
-
48
- /**
49
- * The ports a re-spawn comes back on: the set the dropped handle held, as
50
- * {@link allocateNodePorts} overrides. `{}` when nothing was dropped (a first
51
- * spawn, or a handle lost along with `state.json`), so every port is allocated
52
- * fresh.
53
- *
54
- * Why a re-spawn must not simply take the lowest free ports: a requester with no
55
- * address of its own (a phone) reaches a lent node only by dialing the address it
56
- * was given, so a node that comes back on a different port is one that requester
57
- * can no longer find.
58
- *
59
- * A key the dropped handle lacks — `ws` on a handle persisted by an older build —
60
- * is left out, and so allocated fresh. When more than one handle was dropped (only
61
- * possible from a `state.json` that already held duplicates), the last one stored
62
- * wins and the others' ports stay released.
63
- */
64
- export function reusedNodePorts(dropped: readonly { ports: NodePorts }[]): Partial<NodePorts> {
65
- const reused: Partial<NodePorts> = {};
66
- const previous = dropped[dropped.length - 1]?.ports;
67
- if (!previous) return reused;
68
- for (const key of NODE_PORT_KEYS) {
69
- const port = previous[key];
70
- if (Number.isInteger(port)) reused[key] = port;
71
- }
72
- return reused;
73
- }
1
+ import {
2
+ allocatePortSet,
3
+ releasePortSet,
4
+ reservePortSet,
5
+ type PortAllocator,
6
+ } from '@serfab/cadre-provider';
7
+
8
+ import type { NodePorts } from './types.js';
9
+
10
+ /**
11
+ * Allocation order for a node's port set. Fixed so that swapping a caller onto
12
+ * {@link allocateNodePorts} does not shift the ports an existing deployment
13
+ * already handed out — which is also why a key added later goes on the end
14
+ * (`ws` came after the first four).
15
+ */
16
+ const NODE_PORT_KEYS = ['health', 'metrics', 'p2p', 'admin', 'ws'] as const satisfies readonly (keyof NodePorts)[];
17
+
18
+ /**
19
+ * Allocate a full NodePorts set atomically, in {@link NODE_PORT_KEYS} order.
20
+ * `overrides` supply a port instead of allocating one (the owner node's p2p
21
+ * port, which is fixed by NAT config; a re-spawn's previous ports, see
22
+ * {@link reusedNodePorts}). Overrides are reserved before anything is allocated
23
+ * and are trusted, not checked — see `allocatePortSet`.
24
+ */
25
+ export function allocateNodePorts(
26
+ allocator: PortAllocator,
27
+ overrides?: Partial<NodePorts>,
28
+ ): NodePorts {
29
+ return allocatePortSet(allocator, NODE_PORT_KEYS, overrides);
30
+ }
31
+
32
+ /**
33
+ * Hold a node's whole port set against `allocator` — the inverse of
34
+ * {@link releaseNodePorts}. A key the set lacks is skipped (see `markUsed`).
35
+ */
36
+ export function reserveNodePorts(allocator: PortAllocator, ports: NodePorts): void {
37
+ reservePortSet(allocator, NODE_PORT_KEYS, ports);
38
+ }
39
+
40
+ /**
41
+ * Give a node's whole port set back to `allocator`. A key the set lacks releases
42
+ * nothing: deleting a value the used-set never held is a no-op.
43
+ */
44
+ export function releaseNodePorts(allocator: PortAllocator, ports: NodePorts): void {
45
+ releasePortSet(allocator, NODE_PORT_KEYS, ports);
46
+ }
47
+
48
+ /**
49
+ * The ports a re-spawn comes back on: the set the dropped handle held, as
50
+ * {@link allocateNodePorts} overrides. `{}` when nothing was dropped (a first
51
+ * spawn, or a handle lost along with `state.json`), so every port is allocated
52
+ * fresh.
53
+ *
54
+ * Why a re-spawn must not simply take the lowest free ports: a requester with no
55
+ * address of its own (a phone) reaches a lent node only by dialing the address it
56
+ * was given, so a node that comes back on a different port is one that requester
57
+ * can no longer find.
58
+ *
59
+ * A key the dropped handle lacks — `ws` on a handle persisted by an older build —
60
+ * is left out, and so allocated fresh. When more than one handle was dropped (only
61
+ * possible from a `state.json` that already held duplicates), the last one stored
62
+ * wins and the others' ports stay released.
63
+ */
64
+ export function reusedNodePorts(dropped: readonly { ports: NodePorts }[]): Partial<NodePorts> {
65
+ const reused: Partial<NodePorts> = {};
66
+ const previous = dropped[dropped.length - 1]?.ports;
67
+ if (!previous) return reused;
68
+ for (const key of NODE_PORT_KEYS) {
69
+ const port = previous[key];
70
+ if (Number.isInteger(port)) reused[key] = port;
71
+ }
72
+ return reused;
73
+ }
@@ -1,171 +1,171 @@
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 { EventBus } from '../events/bus.js';
9
- import { registerGrantsAdminRoutes } from '../routes/grants-admin.js';
10
- import {
11
- DonationService,
12
- DonationStore,
13
- GrantService,
14
- GrantStore,
15
- createGrantAdminHandlers,
16
- } from '../../donation/index.js';
17
- import type { DonationView, Grant, GrantListing } from '../../donation/index.js';
18
- import { FakeOrchestrator } from '../../donation/__tests__/fake-orchestrator.js';
19
-
20
- /** A well-formed 32-byte base64url owner key, which `provision` requires. */
21
- const OWNER_KEY = Buffer.alloc(32, 7).toString('base64url');
22
-
23
- let tmpRoot: string;
24
- let app: ReturnType<typeof Fastify>;
25
- let grants: GrantService;
26
- let donations: DonationService;
27
- let donationStore: DonationStore;
28
- let orch: FakeOrchestrator;
29
-
30
- beforeEach(() => {
31
- tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-grants-route-'));
32
- grants = new GrantService({ store: new GrantStore(tmpRoot) });
33
- orch = new FakeOrchestrator();
34
- donationStore = new DonationStore(tmpRoot);
35
- donations = new DonationService({ orchestrator: orch, grants, store: donationStore });
36
- app = Fastify();
37
- registerErrorHandler(app);
38
- registerGrantsAdminRoutes(app, { handlers: createGrantAdminHandlers(grants, donations), events: new EventBus() });
39
- });
40
-
41
- afterEach(async () => {
42
- await app.close();
43
- try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
44
- });
45
-
46
- describe('/grants-admin routes', () => {
47
- it('POST issues a grant, GET lists it, DELETE revokes it', async () => {
48
- const post = await app.inject({
49
- method: 'POST',
50
- url: '/grants-admin',
51
- payload: { label: "Alice's cadre", maxNodes: 2 },
52
- });
53
- expect(post.statusCode).toBe(200);
54
- const created = (post.json() as { grant: Grant }).grant;
55
- expect(created.token).toBeTruthy();
56
- expect(created.maxNodes).toBe(2);
57
-
58
- const list = await app.inject({ method: 'GET', url: '/grants-admin' });
59
- expect(list.statusCode).toBe(200);
60
- expect((list.json() as { grants: Grant[] }).grants.map(g => g.token)).toContain(created.token);
61
-
62
- const del = await app.inject({
63
- method: 'DELETE',
64
- url: `/grants-admin/${encodeURIComponent(created.token)}`,
65
- });
66
- expect(del.statusCode).toBe(200);
67
-
68
- // Revoked → still listed, now carries revokedAt.
69
- const after = await app.inject({ method: 'GET', url: '/grants-admin' });
70
- const row = (after.json() as { grants: Grant[] }).grants.find(g => g.token === created.token);
71
- expect(row?.revokedAt).toBeTruthy();
72
- });
73
-
74
- it('POST with an empty label → 400 invalid_label', async () => {
75
- const res = await app.inject({ method: 'POST', url: '/grants-admin', payload: { label: ' ' } });
76
- expect(res.statusCode).toBe(400);
77
- expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_label');
78
- });
79
-
80
- it('POST with a bad maxNodes → 400 invalid_max_nodes', async () => {
81
- const res = await app.inject({ method: 'POST', url: '/grants-admin', payload: { label: 'X', maxNodes: 0 } });
82
- expect(res.statusCode).toBe(400);
83
- expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_max_nodes');
84
- });
85
-
86
- it('DELETE of an unknown token → 404 not_found', async () => {
87
- const res = await app.inject({ method: 'DELETE', url: '/grants-admin/nope' });
88
- expect(res.statusCode).toBe(404);
89
- expect((res.json() as { error: { code: string } }).error.code).toBe('not_found');
90
- });
91
- });
92
-
93
- function provisionUnder(token: string): Promise<DonationView> {
94
- return donations.provision({ grantToken: token, partyId: 'party-P', bootstrapNodes: [], ownerKeys: [OWNER_KEY] });
95
- }
96
-
97
- describe('/grants-admin listing', () => {
98
- it("GET reports each grant's live nodes and the donations a revoke would end", async () => {
99
- const a = grants.issue({ label: 'A', maxNodes: 3 });
100
- const b = grants.issue({ label: 'B' });
101
- const seeded = await provisionUnder(a.token);
102
- const failed = await provisionUnder(a.token);
103
- const ended = await provisionUnder(a.token);
104
- const other = await provisionUnder(b.token);
105
- donationStore.put({ ...donationStore.get(seeded.id)!, status: 'seeded' });
106
- donationStore.put({ ...donationStore.get(failed.id)!, status: 'error', error: 'respawn gave up' });
107
- await donations.terminate(ended.id);
108
-
109
- const res = await app.inject({ method: 'GET', url: '/grants-admin' });
110
-
111
- const listed = (res.json() as { grants: GrantListing[] }).grants;
112
- const rowA = listed.find(g => g.token === a.token);
113
- const rowB = listed.find(g => g.token === b.token);
114
- // `error` is not live, but a revoke still tears it down; `terminated` is neither.
115
- expect(rowA?.liveNodes).toBe(1);
116
- expect(rowA?.donations).toEqual(expect.arrayContaining([
117
- { id: seeded.id, status: 'seeded' },
118
- { id: failed.id, status: 'error' },
119
- ]));
120
- expect(rowA?.donations).toHaveLength(2);
121
- expect(rowB?.liveNodes).toBe(1);
122
- expect(rowB?.donations).toEqual([{ id: other.id, status: 'awaiting_seed' }]);
123
- });
124
- });
125
-
126
- describe('/grants-admin donated-node teardown', () => {
127
- it('DELETE /grants-admin/:token terminates the nodes donated under the grant', async () => {
128
- const { token } = grants.issue({ label: 'Alice' });
129
- const donation = await provisionUnder(token);
130
-
131
- const res = await app.inject({ method: 'DELETE', url: `/grants-admin/${encodeURIComponent(token)}` });
132
-
133
- expect(res.statusCode).toBe(200);
134
- expect(res.json()).toEqual({ ok: true, terminated: [donation.id] });
135
- expect(donations.get(donation.id)?.status).toBe('terminated');
136
- expect(orch.removed).toEqual(['dock_1']);
137
- });
138
-
139
- it('?keepNodes=true revokes the grant but leaves its nodes running', async () => {
140
- const { token } = grants.issue({ label: 'Alice' });
141
- const donation = await provisionUnder(token);
142
-
143
- const res = await app.inject({
144
- method: 'DELETE',
145
- url: `/grants-admin/${encodeURIComponent(token)}?keepNodes=true`,
146
- });
147
-
148
- expect(res.statusCode).toBe(200);
149
- expect(res.json()).toEqual({ ok: true, terminated: [] });
150
- expect(grants.validate(token).reason).toBe('revoked');
151
- expect(donations.get(donation.id)?.status).toBe('awaiting_seed');
152
- expect(orch.stopped).toEqual([]);
153
- });
154
-
155
- it('DELETE /grants-admin/donations/:id ends one node under a revoked grant; an unknown id 404s', async () => {
156
- const { token } = grants.issue({ label: 'Alice', maxNodes: 2 });
157
- const kept = await provisionUnder(token);
158
- const ended = await provisionUnder(token);
159
- grants.revoke(token);
160
-
161
- const res = await app.inject({ method: 'DELETE', url: `/grants-admin/donations/${ended.id}` });
162
-
163
- expect(res.statusCode).toBe(200);
164
- expect(donations.get(ended.id)?.status).toBe('terminated');
165
- expect(donations.get(kept.id)?.status).toBe('awaiting_seed');
166
-
167
- const unknown = await app.inject({ method: 'DELETE', url: '/grants-admin/donations/grn_nope' });
168
- expect(unknown.statusCode).toBe(404);
169
- expect((unknown.json() as { error: { code: string } }).error.code).toBe('not_found');
170
- });
171
- });
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 { EventBus } from '../events/bus.js';
9
+ import { registerGrantsAdminRoutes } from '../routes/grants-admin.js';
10
+ import {
11
+ DonationService,
12
+ DonationStore,
13
+ GrantService,
14
+ GrantStore,
15
+ createGrantAdminHandlers,
16
+ } from '../../donation/index.js';
17
+ import type { DonationView, Grant, GrantListing } from '../../donation/index.js';
18
+ import { FakeOrchestrator } from '../../donation/__tests__/fake-orchestrator.js';
19
+
20
+ /** A well-formed 32-byte base64url owner key, which `provision` requires. */
21
+ const OWNER_KEY = Buffer.alloc(32, 7).toString('base64url');
22
+
23
+ let tmpRoot: string;
24
+ let app: ReturnType<typeof Fastify>;
25
+ let grants: GrantService;
26
+ let donations: DonationService;
27
+ let donationStore: DonationStore;
28
+ let orch: FakeOrchestrator;
29
+
30
+ beforeEach(() => {
31
+ tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-grants-route-'));
32
+ grants = new GrantService({ store: new GrantStore(tmpRoot) });
33
+ orch = new FakeOrchestrator();
34
+ donationStore = new DonationStore(tmpRoot);
35
+ donations = new DonationService({ orchestrator: orch, grants, store: donationStore });
36
+ app = Fastify();
37
+ registerErrorHandler(app);
38
+ registerGrantsAdminRoutes(app, { handlers: createGrantAdminHandlers(grants, donations), events: new EventBus() });
39
+ });
40
+
41
+ afterEach(async () => {
42
+ await app.close();
43
+ try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
44
+ });
45
+
46
+ describe('/grants-admin routes', () => {
47
+ it('POST issues a grant, GET lists it, DELETE revokes it', async () => {
48
+ const post = await app.inject({
49
+ method: 'POST',
50
+ url: '/grants-admin',
51
+ payload: { label: "Alice's cadre", maxNodes: 2 },
52
+ });
53
+ expect(post.statusCode).toBe(200);
54
+ const created = (post.json() as { grant: Grant }).grant;
55
+ expect(created.token).toBeTruthy();
56
+ expect(created.maxNodes).toBe(2);
57
+
58
+ const list = await app.inject({ method: 'GET', url: '/grants-admin' });
59
+ expect(list.statusCode).toBe(200);
60
+ expect((list.json() as { grants: Grant[] }).grants.map(g => g.token)).toContain(created.token);
61
+
62
+ const del = await app.inject({
63
+ method: 'DELETE',
64
+ url: `/grants-admin/${encodeURIComponent(created.token)}`,
65
+ });
66
+ expect(del.statusCode).toBe(200);
67
+
68
+ // Revoked → still listed, now carries revokedAt.
69
+ const after = await app.inject({ method: 'GET', url: '/grants-admin' });
70
+ const row = (after.json() as { grants: Grant[] }).grants.find(g => g.token === created.token);
71
+ expect(row?.revokedAt).toBeTruthy();
72
+ });
73
+
74
+ it('POST with an empty label → 400 invalid_label', async () => {
75
+ const res = await app.inject({ method: 'POST', url: '/grants-admin', payload: { label: ' ' } });
76
+ expect(res.statusCode).toBe(400);
77
+ expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_label');
78
+ });
79
+
80
+ it('POST with a bad maxNodes → 400 invalid_max_nodes', async () => {
81
+ const res = await app.inject({ method: 'POST', url: '/grants-admin', payload: { label: 'X', maxNodes: 0 } });
82
+ expect(res.statusCode).toBe(400);
83
+ expect((res.json() as { error: { code: string } }).error.code).toBe('invalid_max_nodes');
84
+ });
85
+
86
+ it('DELETE of an unknown token → 404 not_found', async () => {
87
+ const res = await app.inject({ method: 'DELETE', url: '/grants-admin/nope' });
88
+ expect(res.statusCode).toBe(404);
89
+ expect((res.json() as { error: { code: string } }).error.code).toBe('not_found');
90
+ });
91
+ });
92
+
93
+ function provisionUnder(token: string): Promise<DonationView> {
94
+ return donations.provision({ grantToken: token, partyId: 'party-P', bootstrapNodes: [], ownerKeys: [OWNER_KEY] });
95
+ }
96
+
97
+ describe('/grants-admin listing', () => {
98
+ it("GET reports each grant's live nodes and the donations a revoke would end", async () => {
99
+ const a = grants.issue({ label: 'A', maxNodes: 3 });
100
+ const b = grants.issue({ label: 'B' });
101
+ const seeded = await provisionUnder(a.token);
102
+ const failed = await provisionUnder(a.token);
103
+ const ended = await provisionUnder(a.token);
104
+ const other = await provisionUnder(b.token);
105
+ donationStore.put({ ...donationStore.get(seeded.id)!, status: 'seeded' });
106
+ donationStore.put({ ...donationStore.get(failed.id)!, status: 'error', error: 'respawn gave up' });
107
+ await donations.terminate(ended.id);
108
+
109
+ const res = await app.inject({ method: 'GET', url: '/grants-admin' });
110
+
111
+ const listed = (res.json() as { grants: GrantListing[] }).grants;
112
+ const rowA = listed.find(g => g.token === a.token);
113
+ const rowB = listed.find(g => g.token === b.token);
114
+ // `error` is not live, but a revoke still tears it down; `terminated` is neither.
115
+ expect(rowA?.liveNodes).toBe(1);
116
+ expect(rowA?.donations).toEqual(expect.arrayContaining([
117
+ { id: seeded.id, status: 'seeded' },
118
+ { id: failed.id, status: 'error' },
119
+ ]));
120
+ expect(rowA?.donations).toHaveLength(2);
121
+ expect(rowB?.liveNodes).toBe(1);
122
+ expect(rowB?.donations).toEqual([{ id: other.id, status: 'awaiting_seed' }]);
123
+ });
124
+ });
125
+
126
+ describe('/grants-admin donated-node teardown', () => {
127
+ it('DELETE /grants-admin/:token terminates the nodes donated under the grant', async () => {
128
+ const { token } = grants.issue({ label: 'Alice' });
129
+ const donation = await provisionUnder(token);
130
+
131
+ const res = await app.inject({ method: 'DELETE', url: `/grants-admin/${encodeURIComponent(token)}` });
132
+
133
+ expect(res.statusCode).toBe(200);
134
+ expect(res.json()).toEqual({ ok: true, terminated: [donation.id] });
135
+ expect(donations.get(donation.id)?.status).toBe('terminated');
136
+ expect(orch.removed).toEqual(['dock_1']);
137
+ });
138
+
139
+ it('?keepNodes=true revokes the grant but leaves its nodes running', async () => {
140
+ const { token } = grants.issue({ label: 'Alice' });
141
+ const donation = await provisionUnder(token);
142
+
143
+ const res = await app.inject({
144
+ method: 'DELETE',
145
+ url: `/grants-admin/${encodeURIComponent(token)}?keepNodes=true`,
146
+ });
147
+
148
+ expect(res.statusCode).toBe(200);
149
+ expect(res.json()).toEqual({ ok: true, terminated: [] });
150
+ expect(grants.validate(token).reason).toBe('revoked');
151
+ expect(donations.get(donation.id)?.status).toBe('awaiting_seed');
152
+ expect(orch.stopped).toEqual([]);
153
+ });
154
+
155
+ it('DELETE /grants-admin/donations/:id ends one node under a revoked grant; an unknown id 404s', async () => {
156
+ const { token } = grants.issue({ label: 'Alice', maxNodes: 2 });
157
+ const kept = await provisionUnder(token);
158
+ const ended = await provisionUnder(token);
159
+ grants.revoke(token);
160
+
161
+ const res = await app.inject({ method: 'DELETE', url: `/grants-admin/donations/${ended.id}` });
162
+
163
+ expect(res.statusCode).toBe(200);
164
+ expect(donations.get(ended.id)?.status).toBe('terminated');
165
+ expect(donations.get(kept.id)?.status).toBe('awaiting_seed');
166
+
167
+ const unknown = await app.inject({ method: 'DELETE', url: '/grants-admin/donations/grn_nope' });
168
+ expect(unknown.statusCode).toBe(404);
169
+ expect((unknown.json() as { error: { code: string } }).error.code).toBe('not_found');
170
+ });
171
+ });
@@ -1,24 +1,24 @@
1
- /**
2
- * Event types broadcast over the SPA's SSE channel.
3
- *
4
- * Publishers (orchestrator hook, route adapters, update observer) call
5
- * EventBus.publish; the `/api/events` route fan-outs to connected clients.
6
- */
7
-
8
- import type { ContainerStatus } from '@serfab/cadre-provider';
9
-
10
- import type { DirectReachability, PortForwardMode } from '../../nat/index.js';
11
-
12
- export type LocalUiEvent =
13
- | { type: 'node-state-changed'; nodeId: string; status: ContainerStatus }
14
- | { type: 'trust-circle-changed'; kind: 'invited' | 'redeemed' | 'revoked' }
15
- | { type: 'strands-changed'; kind: 'removed' }
16
- | { type: 'grants-changed'; kind: 'issued' | 'revoked' | 'terminated' }
17
- | {
18
- type: 'connectivity-changed';
19
- portMode: PortForwardMode;
20
- directReachability: DirectReachability;
21
- }
22
- | { type: 'update-available'; version: string; releaseNotesUrl?: string };
23
-
24
- export type LocalUiEventType = LocalUiEvent['type'];
1
+ /**
2
+ * Event types broadcast over the SPA's SSE channel.
3
+ *
4
+ * Publishers (orchestrator hook, route adapters, update observer) call
5
+ * EventBus.publish; the `/api/events` route fan-outs to connected clients.
6
+ */
7
+
8
+ import type { ContainerStatus } from '@serfab/cadre-provider';
9
+
10
+ import type { DirectReachability, PortForwardMode } from '../../nat/index.js';
11
+
12
+ export type LocalUiEvent =
13
+ | { type: 'node-state-changed'; nodeId: string; status: ContainerStatus }
14
+ | { type: 'trust-circle-changed'; kind: 'invited' | 'redeemed' | 'revoked' }
15
+ | { type: 'strands-changed'; kind: 'removed' }
16
+ | { type: 'grants-changed'; kind: 'issued' | 'revoked' | 'terminated' }
17
+ | {
18
+ type: 'connectivity-changed';
19
+ portMode: PortForwardMode;
20
+ directReachability: DirectReachability;
21
+ }
22
+ | { type: 'update-available'; version: string; releaseNotesUrl?: string };
23
+
24
+ export type LocalUiEventType = LocalUiEvent['type'];