@serfab/cadre-host 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/dist/server/static.js +38 -38
  2. package/dist/ui/assets/index-C-iks41M.js.map +1 -1
  3. package/dist/ui/index.html +12 -12
  4. package/package.json +4 -4
  5. package/service/cadre-host.service.tmpl +16 -16
  6. package/service/com.serfab.cadre-host.plist.tmpl +35 -35
  7. package/service/install-service.ps1 +98 -98
  8. package/service/uninstall-service.ps1 +19 -19
  9. package/src/__tests__/build-targets.test.ts +26 -26
  10. package/src/__tests__/cli-invite.smoke.test.ts +192 -192
  11. package/src/__tests__/cli-nat.smoke.test.ts +212 -212
  12. package/src/__tests__/cli.smoke.test.ts +46 -46
  13. package/src/__tests__/global-setup.ts +51 -51
  14. package/src/__tests__/host-owner.smoke.test.ts +123 -123
  15. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -140
  16. package/src/__tests__/orchestrator-node-identity.test.ts +204 -204
  17. package/src/__tests__/orchestrator-owner.test.ts +198 -198
  18. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
  19. package/src/__tests__/orchestrator-push.test.ts +145 -145
  20. package/src/__tests__/orchestrator.test.ts +951 -951
  21. package/src/auth/__tests__/duration.test.ts +28 -28
  22. package/src/auth/__tests__/self-label.test.ts +85 -85
  23. package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
  24. package/src/auth/__tests__/trust-circle.test.ts +445 -445
  25. package/src/auth/duration.ts +33 -33
  26. package/src/auth/index.ts +21 -21
  27. package/src/auth/self-label.ts +74 -74
  28. package/src/auth/trust-circle-store.ts +155 -155
  29. package/src/auth/types.ts +99 -99
  30. package/src/donation/__tests__/donation-service.test.ts +1068 -1068
  31. package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
  32. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
  33. package/src/donation/__tests__/fake-orchestrator.ts +225 -225
  34. package/src/donation/__tests__/grant-service.test.ts +213 -213
  35. package/src/donation/__tests__/grant-store.test.ts +113 -113
  36. package/src/donation/donation-service.ts +962 -962
  37. package/src/donation/donation-store.ts +171 -171
  38. package/src/donation/donation-supervisor.ts +388 -388
  39. package/src/donation/grant-service.ts +200 -200
  40. package/src/donation/grant-store.ts +152 -152
  41. package/src/donation/index.ts +64 -64
  42. package/src/donation/types.ts +243 -243
  43. package/src/installer/__tests__/identity.test.ts +34 -34
  44. package/src/installer/__tests__/paths.test.ts +47 -47
  45. package/src/installer/__tests__/service-host.test.ts +62 -62
  46. package/src/installer/__tests__/wizard.test.ts +64 -64
  47. package/src/installer/identity.ts +49 -49
  48. package/src/installer/paths.ts +109 -109
  49. package/src/installer/platform.ts +27 -27
  50. package/src/installer/service-host/index.ts +21 -21
  51. package/src/installer/service-host/template.ts +21 -21
  52. package/src/installer/wizard.ts +103 -103
  53. package/src/nat/__tests__/address-resolver.test.ts +82 -82
  54. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
  55. package/src/nat/__tests__/ddns-updater.test.ts +253 -253
  56. package/src/nat/__tests__/external-ip.test.ts +124 -124
  57. package/src/nat/__tests__/nat-service.test.ts +497 -497
  58. package/src/nat/__tests__/nat-store.test.ts +93 -93
  59. package/src/nat/__tests__/port-mapper.test.ts +191 -191
  60. package/src/nat/__tests__/reachability.test.ts +24 -24
  61. package/src/nat/__tests__/secrets.test.ts +178 -178
  62. package/src/nat/address-resolver.ts +51 -51
  63. package/src/nat/ddns/duckdns.ts +76 -76
  64. package/src/nat/ddns/index.ts +55 -55
  65. package/src/nat/ddns/updater.ts +243 -243
  66. package/src/nat/external-ip.ts +154 -154
  67. package/src/nat/index.ts +71 -71
  68. package/src/nat/nat-service.ts +501 -501
  69. package/src/nat/nat-store.ts +154 -154
  70. package/src/nat/port-mapper.ts +329 -329
  71. package/src/nat/reachability.ts +34 -34
  72. package/src/nat/secrets/file-store.ts +130 -130
  73. package/src/nat/secrets/index.ts +112 -112
  74. package/src/nat/secrets/keytar-store.ts +67 -67
  75. package/src/nat/types.ts +159 -159
  76. package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
  77. package/src/orchestrator/host-process-orchestrator.ts +1133 -1133
  78. package/src/orchestrator/index.ts +10 -10
  79. package/src/orchestrator/log-rotator.ts +53 -53
  80. package/src/orchestrator/node-identity.ts +40 -40
  81. package/src/orchestrator/pid-liveness.ts +24 -24
  82. package/src/orchestrator/port-allocator.ts +91 -91
  83. package/src/orchestrator/state-store.ts +90 -90
  84. package/src/orchestrator/types.ts +141 -141
  85. package/src/owner/__tests__/owner-node-client.test.ts +194 -194
  86. package/src/owner/index.ts +12 -12
  87. package/src/owner/owner-node-client.ts +259 -259
  88. package/src/push/__tests__/push.test.ts +90 -90
  89. package/src/push/index.ts +146 -146
  90. package/src/server/__tests__/bus.test.ts +38 -38
  91. package/src/server/__tests__/error-handler.test.ts +118 -118
  92. package/src/server/__tests__/grants-admin-route.test.ts +73 -73
  93. package/src/server/__tests__/grants-route.test.ts +263 -263
  94. package/src/server/__tests__/nodes-route.test.ts +259 -259
  95. package/src/server/__tests__/origin-guard.test.ts +72 -72
  96. package/src/server/__tests__/publishers.test.ts +216 -216
  97. package/src/server/__tests__/server.smoke.test.ts +253 -253
  98. package/src/server/__tests__/settings-route.test.ts +194 -194
  99. package/src/server/__tests__/sse-route.test.ts +119 -119
  100. package/src/server/__tests__/static.test.ts +145 -145
  101. package/src/server/__tests__/status-route.test.ts +134 -134
  102. package/src/server/__tests__/strands-route.test.ts +233 -233
  103. package/src/server/error-handler.ts +178 -178
  104. package/src/server/events/bus.ts +42 -42
  105. package/src/server/events/sse-route.ts +81 -81
  106. package/src/server/events/types.ts +23 -23
  107. package/src/server/index.ts +267 -267
  108. package/src/server/origin-guard.ts +84 -84
  109. package/src/server/routes/grants-admin.ts +41 -41
  110. package/src/server/routes/grants.ts +210 -210
  111. package/src/server/routes/nat.ts +63 -63
  112. package/src/server/routes/nodes.ts +170 -170
  113. package/src/server/routes/settings.ts +123 -123
  114. package/src/server/routes/status.ts +118 -118
  115. package/src/server/routes/strands.ts +54 -54
  116. package/src/server/routes/trust-circle.ts +49 -49
  117. package/src/server/routes/update.ts +43 -43
  118. package/src/server/server.ts +122 -122
  119. package/src/server/settings-store.ts +37 -37
  120. package/src/server/static.ts +207 -207
  121. package/src/strands/__tests__/strand-service.test.ts +165 -165
  122. package/src/strands/index.ts +14 -14
  123. package/src/strands/strand-service.ts +135 -135
  124. package/src/strands/types.ts +102 -102
  125. package/src/update/__tests__/apply.test.ts +158 -158
  126. package/src/update/__tests__/manifest.test.ts +128 -128
  127. package/src/update/__tests__/release-key.test.ts +42 -42
  128. package/src/update/__tests__/sign.test.ts +78 -78
  129. package/src/update/__tests__/store.test.ts +81 -81
  130. package/src/update/__tests__/update-service.integration.test.ts +256 -256
  131. package/src/update/__tests__/version.test.ts +31 -31
  132. package/src/update/apply.ts +190 -190
  133. package/src/update/index.ts +341 -341
  134. package/src/update/release-key.ts +71 -71
  135. package/src/update/sign.ts +74 -74
  136. package/src/update/store.ts +101 -101
  137. package/src/update/types.ts +109 -109
  138. package/src/update/version.ts +80 -80
@@ -1,194 +1,194 @@
1
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
- import { tmpdir } from 'node:os';
3
- import { join } from 'node:path';
4
-
5
- import Fastify from 'fastify';
6
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7
-
8
- import { registerErrorHandler } from '../error-handler.js';
9
- import { registerSettingsRoutes } from '../routes/settings.js';
10
- import { HostSettingsStore } from '../settings-store.js';
11
- import type { NatService } from '../../nat/index.js';
12
- import type { NatStatusSnapshot } from '../../nat/types.js';
13
- import type { UpdateService } from '../../update/index.js';
14
- import type { UpdateSettings } from '../../update/types.js';
15
-
16
- function fakeNat(): { svc: NatService; calls: Array<unknown> } {
17
- const calls: unknown[] = [];
18
- const stub = {
19
- putSettings: async (patch: unknown) => { calls.push(patch); return {} as NatStatusSnapshot; },
20
- } as unknown as NatService;
21
- return { svc: stub, calls };
22
- }
23
-
24
- function fakeUpdate(): { svc: UpdateService; calls: UpdateSettings[] } {
25
- const calls: UpdateSettings[] = [];
26
- const stub = {
27
- putSettings: async (patch: { autoApply?: boolean; manifestUrl?: string }) => {
28
- calls.push({ autoApply: patch.autoApply ?? false, ...(patch.manifestUrl ? { manifestUrl: patch.manifestUrl } : {}) });
29
- return { autoApply: patch.autoApply ?? false } as UpdateSettings;
30
- },
31
- } as unknown as UpdateService;
32
- return { svc: stub, calls };
33
- }
34
-
35
- function writeV2Config(dir: string): string {
36
- const cfg = {
37
- version: 2,
38
- installId: 'inst-x',
39
- uiPort: 8765,
40
- libp2pPort: 4001,
41
- dataDir: dir,
42
- identityPath: join(dir, 'identity.key'),
43
- upnpEnabled: true,
44
- installedAt: '2025-01-01T00:00:00Z',
45
- installerVersion: '0.6.0',
46
- updates: { autoApply: false },
47
- };
48
- const path = join(dir, 'host.config.json');
49
- writeFileSync(path, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
50
- return path;
51
- }
52
-
53
- function writeV1Config(dir: string): string {
54
- const cfg = {
55
- version: 1,
56
- installId: 'inst-x',
57
- uiPort: 8765,
58
- libp2pPort: 4001,
59
- dataDir: dir,
60
- identityPath: join(dir, 'identity.key'),
61
- upnpEnabled: true,
62
- installedAt: '2025-01-01T00:00:00Z',
63
- installerVersion: '0.6.0',
64
- };
65
- const path = join(dir, 'host.config.json');
66
- writeFileSync(path, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
67
- return path;
68
- }
69
-
70
- describe('/api/settings routes', () => {
71
- let app: ReturnType<typeof Fastify>;
72
- let dataDir: string;
73
-
74
- beforeEach(async () => {
75
- dataDir = mkdtempSync(join(tmpdir(), 'cadre-host-settings-'));
76
- });
77
-
78
- afterEach(async () => {
79
- if (app) await app.close();
80
- rmSync(dataDir, { recursive: true, force: true });
81
- });
82
-
83
- it('GET returns the persisted config', async () => {
84
- writeV2Config(dataDir);
85
- app = Fastify();
86
- registerErrorHandler(app);
87
- registerSettingsRoutes(app, {
88
- settingsStore: new HostSettingsStore({ dataDir }),
89
- nat: fakeNat().svc,
90
- });
91
- const res = await app.inject({ method: 'GET', url: '/api/settings' });
92
- expect(res.statusCode).toBe(200);
93
- const body = res.json() as { data: { uiPort: number; updates: { autoApply: boolean } } };
94
- expect(body.data.uiPort).toBe(8765);
95
- expect(body.data.updates.autoApply).toBe(false);
96
- });
97
-
98
- it('PUT propagates upnpEnabled to nat service and host.config.json', async () => {
99
- writeV2Config(dataDir);
100
- const nat = fakeNat();
101
- app = Fastify();
102
- registerErrorHandler(app);
103
- registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: nat.svc });
104
- const res = await app.inject({
105
- method: 'PUT',
106
- url: '/api/settings',
107
- headers: { 'content-type': 'application/json' },
108
- payload: JSON.stringify({ upnpEnabled: false }),
109
- });
110
- expect(res.statusCode).toBe(200);
111
- expect(nat.calls).toEqual([{ upnpEnabled: false }]);
112
- const body = res.json() as { data: { upnpEnabled: boolean } };
113
- expect(body.data.upnpEnabled).toBe(false);
114
- });
115
-
116
- it('PUT updates.autoApply propagates to update service', async () => {
117
- writeV2Config(dataDir);
118
- const nat = fakeNat();
119
- const update = fakeUpdate();
120
- app = Fastify();
121
- registerErrorHandler(app);
122
- registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: nat.svc, update: update.svc });
123
- const res = await app.inject({
124
- method: 'PUT',
125
- url: '/api/settings',
126
- headers: { 'content-type': 'application/json' },
127
- payload: JSON.stringify({ updates: { autoApply: true } }),
128
- });
129
- expect(res.statusCode).toBe(200);
130
- expect(update.calls).toEqual([{ autoApply: true }]);
131
- const body = res.json() as { data: { updates: { autoApply: boolean } } };
132
- expect(body.data.updates.autoApply).toBe(true);
133
- });
134
-
135
- it('PUT uiPort returns 400 invalid_setting', async () => {
136
- writeV2Config(dataDir);
137
- app = Fastify();
138
- registerErrorHandler(app);
139
- registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
140
- const res = await app.inject({
141
- method: 'PUT',
142
- url: '/api/settings',
143
- headers: { 'content-type': 'application/json' },
144
- payload: JSON.stringify({ uiPort: 9999 }),
145
- });
146
- expect(res.statusCode).toBe(400);
147
- const body = res.json() as { error: { code: string; message: string } };
148
- expect(body.error.code).toBe('invalid_setting');
149
- expect(body.error.message).toMatch(/uiPort/);
150
- });
151
-
152
- it('PUT ownCadre returns 400 invalid_setting (install-time only)', async () => {
153
- writeV2Config(dataDir);
154
- app = Fastify();
155
- registerErrorHandler(app);
156
- registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
157
- const res = await app.inject({
158
- method: 'PUT',
159
- url: '/api/settings',
160
- headers: { 'content-type': 'application/json' },
161
- payload: JSON.stringify({ ownCadre: { enabled: true } }),
162
- });
163
- expect(res.statusCode).toBe(400);
164
- const body = res.json() as { error: { code: string; message: string } };
165
- expect(body.error.code).toBe('invalid_setting');
166
- expect(body.error.message).toMatch(/ownCadre/);
167
- });
168
-
169
- it('PUT unknown setting returns 400', async () => {
170
- writeV2Config(dataDir);
171
- app = Fastify();
172
- registerErrorHandler(app);
173
- registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
174
- const res = await app.inject({
175
- method: 'PUT',
176
- url: '/api/settings',
177
- headers: { 'content-type': 'application/json' },
178
- payload: JSON.stringify({ totallyMadeUp: 'value' }),
179
- });
180
- expect(res.statusCode).toBe(400);
181
- });
182
-
183
- it('reads v1 host.config.json and migrates to v2 on first read', async () => {
184
- writeV1Config(dataDir);
185
- app = Fastify();
186
- registerErrorHandler(app);
187
- registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
188
- const res = await app.inject({ method: 'GET', url: '/api/settings' });
189
- expect(res.statusCode).toBe(200);
190
- const body = res.json() as { data: { version: number; updates: { autoApply: boolean } } };
191
- expect(body.data.version).toBe(2);
192
- expect(body.data.updates.autoApply).toBe(false);
193
- });
194
- });
1
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import Fastify from 'fastify';
6
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7
+
8
+ import { registerErrorHandler } from '../error-handler.js';
9
+ import { registerSettingsRoutes } from '../routes/settings.js';
10
+ import { HostSettingsStore } from '../settings-store.js';
11
+ import type { NatService } from '../../nat/index.js';
12
+ import type { NatStatusSnapshot } from '../../nat/types.js';
13
+ import type { UpdateService } from '../../update/index.js';
14
+ import type { UpdateSettings } from '../../update/types.js';
15
+
16
+ function fakeNat(): { svc: NatService; calls: Array<unknown> } {
17
+ const calls: unknown[] = [];
18
+ const stub = {
19
+ putSettings: async (patch: unknown) => { calls.push(patch); return {} as NatStatusSnapshot; },
20
+ } as unknown as NatService;
21
+ return { svc: stub, calls };
22
+ }
23
+
24
+ function fakeUpdate(): { svc: UpdateService; calls: UpdateSettings[] } {
25
+ const calls: UpdateSettings[] = [];
26
+ const stub = {
27
+ putSettings: async (patch: { autoApply?: boolean; manifestUrl?: string }) => {
28
+ calls.push({ autoApply: patch.autoApply ?? false, ...(patch.manifestUrl ? { manifestUrl: patch.manifestUrl } : {}) });
29
+ return { autoApply: patch.autoApply ?? false } as UpdateSettings;
30
+ },
31
+ } as unknown as UpdateService;
32
+ return { svc: stub, calls };
33
+ }
34
+
35
+ function writeV2Config(dir: string): string {
36
+ const cfg = {
37
+ version: 2,
38
+ installId: 'inst-x',
39
+ uiPort: 8765,
40
+ libp2pPort: 4001,
41
+ dataDir: dir,
42
+ identityPath: join(dir, 'identity.key'),
43
+ upnpEnabled: true,
44
+ installedAt: '2025-01-01T00:00:00Z',
45
+ installerVersion: '0.6.0',
46
+ updates: { autoApply: false },
47
+ };
48
+ const path = join(dir, 'host.config.json');
49
+ writeFileSync(path, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
50
+ return path;
51
+ }
52
+
53
+ function writeV1Config(dir: string): string {
54
+ const cfg = {
55
+ version: 1,
56
+ installId: 'inst-x',
57
+ uiPort: 8765,
58
+ libp2pPort: 4001,
59
+ dataDir: dir,
60
+ identityPath: join(dir, 'identity.key'),
61
+ upnpEnabled: true,
62
+ installedAt: '2025-01-01T00:00:00Z',
63
+ installerVersion: '0.6.0',
64
+ };
65
+ const path = join(dir, 'host.config.json');
66
+ writeFileSync(path, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
67
+ return path;
68
+ }
69
+
70
+ describe('/api/settings routes', () => {
71
+ let app: ReturnType<typeof Fastify>;
72
+ let dataDir: string;
73
+
74
+ beforeEach(async () => {
75
+ dataDir = mkdtempSync(join(tmpdir(), 'cadre-host-settings-'));
76
+ });
77
+
78
+ afterEach(async () => {
79
+ if (app) await app.close();
80
+ rmSync(dataDir, { recursive: true, force: true });
81
+ });
82
+
83
+ it('GET returns the persisted config', async () => {
84
+ writeV2Config(dataDir);
85
+ app = Fastify();
86
+ registerErrorHandler(app);
87
+ registerSettingsRoutes(app, {
88
+ settingsStore: new HostSettingsStore({ dataDir }),
89
+ nat: fakeNat().svc,
90
+ });
91
+ const res = await app.inject({ method: 'GET', url: '/api/settings' });
92
+ expect(res.statusCode).toBe(200);
93
+ const body = res.json() as { data: { uiPort: number; updates: { autoApply: boolean } } };
94
+ expect(body.data.uiPort).toBe(8765);
95
+ expect(body.data.updates.autoApply).toBe(false);
96
+ });
97
+
98
+ it('PUT propagates upnpEnabled to nat service and host.config.json', async () => {
99
+ writeV2Config(dataDir);
100
+ const nat = fakeNat();
101
+ app = Fastify();
102
+ registerErrorHandler(app);
103
+ registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: nat.svc });
104
+ const res = await app.inject({
105
+ method: 'PUT',
106
+ url: '/api/settings',
107
+ headers: { 'content-type': 'application/json' },
108
+ payload: JSON.stringify({ upnpEnabled: false }),
109
+ });
110
+ expect(res.statusCode).toBe(200);
111
+ expect(nat.calls).toEqual([{ upnpEnabled: false }]);
112
+ const body = res.json() as { data: { upnpEnabled: boolean } };
113
+ expect(body.data.upnpEnabled).toBe(false);
114
+ });
115
+
116
+ it('PUT updates.autoApply propagates to update service', async () => {
117
+ writeV2Config(dataDir);
118
+ const nat = fakeNat();
119
+ const update = fakeUpdate();
120
+ app = Fastify();
121
+ registerErrorHandler(app);
122
+ registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: nat.svc, update: update.svc });
123
+ const res = await app.inject({
124
+ method: 'PUT',
125
+ url: '/api/settings',
126
+ headers: { 'content-type': 'application/json' },
127
+ payload: JSON.stringify({ updates: { autoApply: true } }),
128
+ });
129
+ expect(res.statusCode).toBe(200);
130
+ expect(update.calls).toEqual([{ autoApply: true }]);
131
+ const body = res.json() as { data: { updates: { autoApply: boolean } } };
132
+ expect(body.data.updates.autoApply).toBe(true);
133
+ });
134
+
135
+ it('PUT uiPort returns 400 invalid_setting', async () => {
136
+ writeV2Config(dataDir);
137
+ app = Fastify();
138
+ registerErrorHandler(app);
139
+ registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
140
+ const res = await app.inject({
141
+ method: 'PUT',
142
+ url: '/api/settings',
143
+ headers: { 'content-type': 'application/json' },
144
+ payload: JSON.stringify({ uiPort: 9999 }),
145
+ });
146
+ expect(res.statusCode).toBe(400);
147
+ const body = res.json() as { error: { code: string; message: string } };
148
+ expect(body.error.code).toBe('invalid_setting');
149
+ expect(body.error.message).toMatch(/uiPort/);
150
+ });
151
+
152
+ it('PUT ownCadre returns 400 invalid_setting (install-time only)', async () => {
153
+ writeV2Config(dataDir);
154
+ app = Fastify();
155
+ registerErrorHandler(app);
156
+ registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
157
+ const res = await app.inject({
158
+ method: 'PUT',
159
+ url: '/api/settings',
160
+ headers: { 'content-type': 'application/json' },
161
+ payload: JSON.stringify({ ownCadre: { enabled: true } }),
162
+ });
163
+ expect(res.statusCode).toBe(400);
164
+ const body = res.json() as { error: { code: string; message: string } };
165
+ expect(body.error.code).toBe('invalid_setting');
166
+ expect(body.error.message).toMatch(/ownCadre/);
167
+ });
168
+
169
+ it('PUT unknown setting returns 400', async () => {
170
+ writeV2Config(dataDir);
171
+ app = Fastify();
172
+ registerErrorHandler(app);
173
+ registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
174
+ const res = await app.inject({
175
+ method: 'PUT',
176
+ url: '/api/settings',
177
+ headers: { 'content-type': 'application/json' },
178
+ payload: JSON.stringify({ totallyMadeUp: 'value' }),
179
+ });
180
+ expect(res.statusCode).toBe(400);
181
+ });
182
+
183
+ it('reads v1 host.config.json and migrates to v2 on first read', async () => {
184
+ writeV1Config(dataDir);
185
+ app = Fastify();
186
+ registerErrorHandler(app);
187
+ registerSettingsRoutes(app, { settingsStore: new HostSettingsStore({ dataDir }), nat: fakeNat().svc });
188
+ const res = await app.inject({ method: 'GET', url: '/api/settings' });
189
+ expect(res.statusCode).toBe(200);
190
+ const body = res.json() as { data: { version: number; updates: { autoApply: boolean } } };
191
+ expect(body.data.version).toBe(2);
192
+ expect(body.data.updates.autoApply).toBe(false);
193
+ });
194
+ });
@@ -1,119 +1,119 @@
1
- import { createServer, type AddressInfo } from 'node:net';
2
-
3
- import Fastify from 'fastify';
4
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
5
-
6
- import { EventBus } from '../events/bus.js';
7
- import { registerSseRoute } from '../events/sse-route.js';
8
-
9
- async function pickFreePort(): Promise<number> {
10
- return await new Promise<number>((resolve, reject) => {
11
- const server = createServer();
12
- server.unref();
13
- server.on('error', reject);
14
- server.listen({ host: '127.0.0.1', port: 0 }, () => {
15
- const addr = server.address() as AddressInfo;
16
- server.close(() => resolve(addr.port));
17
- });
18
- });
19
- }
20
-
21
- /** Parse SSE chunks and append decoded events to `events`. */
22
- function parseSseChunk(buf: string, state: { current: { type: string; data: string } }, events: Array<{ type: string; data: string }>): string {
23
- let buffered = buf;
24
- let nl: number;
25
- while ((nl = buffered.indexOf('\n')) !== -1) {
26
- const line = buffered.slice(0, nl).replace(/\r$/, '');
27
- buffered = buffered.slice(nl + 1);
28
- if (line === '') {
29
- if (state.current.type || state.current.data) {
30
- events.push({ ...state.current });
31
- state.current.type = '';
32
- state.current.data = '';
33
- }
34
- continue;
35
- }
36
- if (line.startsWith(':')) continue;
37
- if (line.startsWith('event:')) { state.current.type = line.slice(6).trim(); continue; }
38
- if (line.startsWith('data:')) { state.current.data = line.slice(5).trim(); continue; }
39
- }
40
- return buffered;
41
- }
42
-
43
- describe('GET /api/events (SSE)', () => {
44
- let app: ReturnType<typeof Fastify>;
45
- let bus: EventBus;
46
- let port: number;
47
-
48
- beforeEach(async () => {
49
- port = await pickFreePort();
50
- bus = new EventBus();
51
- app = Fastify();
52
- registerSseRoute(app, { events: bus, heartbeatIntervalMs: 50 });
53
- await app.listen({ host: '127.0.0.1', port });
54
- });
55
-
56
- afterEach(async () => {
57
- await app.close();
58
- });
59
-
60
- it('delivers published events in order and cleans up on disconnect', async () => {
61
- const { request: httpRequest } = await import('node:http');
62
- const events: Array<{ type: string; data: string }> = [];
63
- const state = { current: { type: '', data: '' } };
64
- let leftover = '';
65
- let done = false;
66
-
67
- const req = httpRequest({
68
- hostname: '127.0.0.1',
69
- port,
70
- path: '/api/events',
71
- method: 'GET',
72
- headers: { accept: 'text/event-stream' },
73
- });
74
-
75
- const responseReady = new Promise<void>((resolve) => {
76
- req.on('response', (res) => {
77
- res.setEncoding('utf8');
78
- res.on('data', (chunk: string) => {
79
- leftover = parseSseChunk(leftover + chunk, state, events);
80
- });
81
- res.on('end', () => { done = true; });
82
- resolve();
83
- });
84
- });
85
- req.end();
86
- await responseReady;
87
-
88
- // Give the connection a beat to settle on the server, then publish.
89
- await new Promise<void>((r) => setTimeout(r, 30));
90
- bus.publish({ type: 'trust-circle-changed', kind: 'invited' });
91
- bus.publish({ type: 'trust-circle-changed', kind: 'redeemed' });
92
- bus.publish({ type: 'connectivity-changed', portMode: 'auto-upnp', directReachability: 'reachable' });
93
-
94
- // Wait until at least 3 events have arrived (or 1s).
95
- const deadline = Date.now() + 1000;
96
- while (events.length < 3 && Date.now() < deadline) {
97
- await new Promise<void>((r) => setTimeout(r, 25));
98
- }
99
-
100
- expect(events.length).toBeGreaterThanOrEqual(3);
101
- expect(events.slice(0, 3).map((e) => e.type)).toEqual([
102
- 'trust-circle-changed',
103
- 'trust-circle-changed',
104
- 'connectivity-changed',
105
- ]);
106
- const first = JSON.parse(events[0]!.data) as { kind: string };
107
- expect(first.kind).toBe('invited');
108
-
109
- expect(bus.listenerCount()).toBe(1);
110
- req.destroy();
111
- // Give the server's 'close' handler a tick to run.
112
- const dropDeadline = Date.now() + 1000;
113
- while (bus.listenerCount() > 0 && Date.now() < dropDeadline) {
114
- await new Promise<void>((r) => setTimeout(r, 25));
115
- }
116
- expect(bus.listenerCount()).toBe(0);
117
- void done;
118
- });
119
- });
1
+ import { createServer, type AddressInfo } from 'node:net';
2
+
3
+ import Fastify from 'fastify';
4
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
5
+
6
+ import { EventBus } from '../events/bus.js';
7
+ import { registerSseRoute } from '../events/sse-route.js';
8
+
9
+ async function pickFreePort(): Promise<number> {
10
+ return await new Promise<number>((resolve, reject) => {
11
+ const server = createServer();
12
+ server.unref();
13
+ server.on('error', reject);
14
+ server.listen({ host: '127.0.0.1', port: 0 }, () => {
15
+ const addr = server.address() as AddressInfo;
16
+ server.close(() => resolve(addr.port));
17
+ });
18
+ });
19
+ }
20
+
21
+ /** Parse SSE chunks and append decoded events to `events`. */
22
+ function parseSseChunk(buf: string, state: { current: { type: string; data: string } }, events: Array<{ type: string; data: string }>): string {
23
+ let buffered = buf;
24
+ let nl: number;
25
+ while ((nl = buffered.indexOf('\n')) !== -1) {
26
+ const line = buffered.slice(0, nl).replace(/\r$/, '');
27
+ buffered = buffered.slice(nl + 1);
28
+ if (line === '') {
29
+ if (state.current.type || state.current.data) {
30
+ events.push({ ...state.current });
31
+ state.current.type = '';
32
+ state.current.data = '';
33
+ }
34
+ continue;
35
+ }
36
+ if (line.startsWith(':')) continue;
37
+ if (line.startsWith('event:')) { state.current.type = line.slice(6).trim(); continue; }
38
+ if (line.startsWith('data:')) { state.current.data = line.slice(5).trim(); continue; }
39
+ }
40
+ return buffered;
41
+ }
42
+
43
+ describe('GET /api/events (SSE)', () => {
44
+ let app: ReturnType<typeof Fastify>;
45
+ let bus: EventBus;
46
+ let port: number;
47
+
48
+ beforeEach(async () => {
49
+ port = await pickFreePort();
50
+ bus = new EventBus();
51
+ app = Fastify();
52
+ registerSseRoute(app, { events: bus, heartbeatIntervalMs: 50 });
53
+ await app.listen({ host: '127.0.0.1', port });
54
+ });
55
+
56
+ afterEach(async () => {
57
+ await app.close();
58
+ });
59
+
60
+ it('delivers published events in order and cleans up on disconnect', async () => {
61
+ const { request: httpRequest } = await import('node:http');
62
+ const events: Array<{ type: string; data: string }> = [];
63
+ const state = { current: { type: '', data: '' } };
64
+ let leftover = '';
65
+ let done = false;
66
+
67
+ const req = httpRequest({
68
+ hostname: '127.0.0.1',
69
+ port,
70
+ path: '/api/events',
71
+ method: 'GET',
72
+ headers: { accept: 'text/event-stream' },
73
+ });
74
+
75
+ const responseReady = new Promise<void>((resolve) => {
76
+ req.on('response', (res) => {
77
+ res.setEncoding('utf8');
78
+ res.on('data', (chunk: string) => {
79
+ leftover = parseSseChunk(leftover + chunk, state, events);
80
+ });
81
+ res.on('end', () => { done = true; });
82
+ resolve();
83
+ });
84
+ });
85
+ req.end();
86
+ await responseReady;
87
+
88
+ // Give the connection a beat to settle on the server, then publish.
89
+ await new Promise<void>((r) => setTimeout(r, 30));
90
+ bus.publish({ type: 'trust-circle-changed', kind: 'invited' });
91
+ bus.publish({ type: 'trust-circle-changed', kind: 'redeemed' });
92
+ bus.publish({ type: 'connectivity-changed', portMode: 'auto-upnp', directReachability: 'reachable' });
93
+
94
+ // Wait until at least 3 events have arrived (or 1s).
95
+ const deadline = Date.now() + 1000;
96
+ while (events.length < 3 && Date.now() < deadline) {
97
+ await new Promise<void>((r) => setTimeout(r, 25));
98
+ }
99
+
100
+ expect(events.length).toBeGreaterThanOrEqual(3);
101
+ expect(events.slice(0, 3).map((e) => e.type)).toEqual([
102
+ 'trust-circle-changed',
103
+ 'trust-circle-changed',
104
+ 'connectivity-changed',
105
+ ]);
106
+ const first = JSON.parse(events[0]!.data) as { kind: string };
107
+ expect(first.kind).toBe('invited');
108
+
109
+ expect(bus.listenerCount()).toBe(1);
110
+ req.destroy();
111
+ // Give the server's 'close' handler a tick to run.
112
+ const dropDeadline = Date.now() + 1000;
113
+ while (bus.listenerCount() > 0 && Date.now() < dropDeadline) {
114
+ await new Promise<void>((r) => setTimeout(r, 25));
115
+ }
116
+ expect(bus.listenerCount()).toBe(0);
117
+ void done;
118
+ });
119
+ });