@quolu/lattice 0.52.3 → 0.53.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 (188) hide show
  1. package/LICENSE +147 -147
  2. package/README.ja.md +355 -355
  3. package/README.md +258 -258
  4. package/bin/lattice-bridge.mjs +25 -0
  5. package/bin/lattice-hub.mjs +67 -0
  6. package/bin/lattice-mcp.mjs +0 -0
  7. package/bin/lattice-scripted-adapter.mjs +0 -0
  8. package/bin/lattice-scripted-worker.mjs +0 -0
  9. package/bin/lattice-work-order-adapter.mjs +0 -0
  10. package/bin/lattice.mjs +0 -0
  11. package/docs/bridge-setup.md +132 -132
  12. package/docs/schemas/lattice.executor_packet.v1.schema.json +57 -57
  13. package/docs/schemas/lattice.executor_receipt.v1.schema.json +66 -66
  14. package/docs/schemas/lattice.phase_todo_revision.v3.schema.json +360 -360
  15. package/docs/schemas/lattice.plan_create_input.v1.schema.json +56 -56
  16. package/docs/schemas/lattice.plan_create_input.v2.schema.json +72 -72
  17. package/docs/schemas/lattice.plan_create_input.v3.schema.json +81 -81
  18. package/docs/schemas/lattice.plan_create_input.v4.schema.json +85 -85
  19. package/docs/schemas/lattice.run_request.v1.schema.json +238 -238
  20. package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -55
  21. package/docs/schemas/lattice.runtime_adapter_registration_input.v1.schema.json +78 -78
  22. package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -86
  23. package/docs/schemas/lattice.todo_extraction.v2.schema.json +298 -298
  24. package/docs/schemas/lattice.todo_extraction.v3.schema.json +146 -146
  25. package/docs/schemas/lattice.todo_revision.v2.schema.json +260 -260
  26. package/docs/schemas/lattice.todo_revision_set.v3.schema.json +363 -363
  27. package/package.json +103 -103
  28. package/sensor/LICENSE +21 -21
  29. package/sensor/NOTICE +19 -19
  30. package/sensor/dist/bin/lattice-sensor.js +9 -9
  31. package/sensor/dist/db/index.js +24 -24
  32. package/sensor/dist/db/migrations.js +41 -41
  33. package/sensor/dist/db/queries.js +164 -164
  34. package/sensor/dist/db/schema.sql +205 -205
  35. package/sensor/dist/directory.js +5 -5
  36. package/sensor/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
  37. package/sensor/dist/extraction/wasm/tree-sitter-cfml.wasm +0 -0
  38. package/sensor/dist/extraction/wasm/tree-sitter-cfquery.wasm +0 -0
  39. package/sensor/dist/extraction/wasm/tree-sitter-cfscript.wasm +0 -0
  40. package/sensor/dist/extraction/wasm/tree-sitter-cobol.wasm +0 -0
  41. package/sensor/dist/extraction/wasm/tree-sitter-erlang.wasm +0 -0
  42. package/sensor/dist/extraction/wasm/tree-sitter-go.wasm +0 -0
  43. package/sensor/dist/extraction/wasm/tree-sitter-java.wasm +0 -0
  44. package/sensor/dist/extraction/wasm/tree-sitter-javascript.wasm +0 -0
  45. package/sensor/dist/extraction/wasm/tree-sitter-nix.wasm +0 -0
  46. package/sensor/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
  47. package/sensor/dist/extraction/wasm/tree-sitter-python.wasm +0 -0
  48. package/sensor/dist/extraction/wasm/tree-sitter-tsx.wasm +0 -0
  49. package/sensor/dist/extraction/wasm/tree-sitter-typescript.wasm +0 -0
  50. package/sensor/dist/extraction/wasm/tree-sitter-vbnet.wasm +0 -0
  51. package/sensor/dist/mcp/liveness-watchdog.js +53 -53
  52. package/sensor/dist/mcp/server-instructions.js +95 -95
  53. package/sensor/package.json +56 -56
  54. package/src/artifact-contracts-v2.mjs +325 -325
  55. package/src/artifact-contracts.mjs +895 -895
  56. package/src/boundary-compiler.mjs +712 -712
  57. package/src/boundary-observation-compiler-v2.mjs +344 -344
  58. package/src/bounded-seam.mjs +230 -230
  59. package/src/bridge-address.mjs +107 -107
  60. package/src/bridge-cli.mjs +303 -297
  61. package/src/bridge-config.mjs +382 -346
  62. package/src/bridge-daemon.mjs +378 -378
  63. package/src/bridge-hub-heartbeat.mjs +170 -0
  64. package/src/bridge-hub-protocol.mjs +198 -0
  65. package/src/bridge-hub-server.mjs +544 -0
  66. package/src/bridge-launch-agent.mjs +323 -323
  67. package/src/bridge-registrar.mjs +102 -102
  68. package/src/bridge-server.mjs +376 -376
  69. package/src/cli-help.mjs +308 -308
  70. package/src/cli-stdio.mjs +40 -40
  71. package/src/control-compiler.mjs +532 -532
  72. package/src/dag-chain.mjs +261 -261
  73. package/src/factory-diagnostics.mjs +188 -188
  74. package/src/git-process.mjs +72 -0
  75. package/src/hash-chain.mjs +76 -76
  76. package/src/hooks-cli.mjs +1057 -1057
  77. package/src/isolation-runner.mjs +409 -409
  78. package/src/node-version-guard.mjs +44 -44
  79. package/src/project-cli.mjs +3 -3
  80. package/src/project-identity.mjs +130 -130
  81. package/src/rc1-black-box-oracle.mjs +906 -906
  82. package/src/rc1-comparison.mjs +154 -154
  83. package/src/rc1-evidence-bundle.mjs +456 -456
  84. package/src/rc1-v4-campaign.mjs +708 -708
  85. package/src/rc1-v4-transform.mjs +467 -467
  86. package/src/rc1-v5-artifact-set.mjs +855 -855
  87. package/src/rc1-v5-behavior-evidence.mjs +594 -594
  88. package/src/rc1-v5-campaign.mjs +797 -797
  89. package/src/rc1-v5-transform.mjs +421 -421
  90. package/src/rc1-v6-artifact-set.mjs +807 -807
  91. package/src/rc1-v6-behavior-evidence.mjs +273 -273
  92. package/src/rc1-v6-campaign.mjs +623 -623
  93. package/src/rc1-v6-causal-binding.mjs +473 -473
  94. package/src/rc1-v6-measurement.mjs +313 -313
  95. package/src/rc2-artifact-set.mjs +1584 -1584
  96. package/src/rc2-campaign.mjs +1506 -1506
  97. package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
  98. package/src/rc2-delivery-policy-oracle.mjs +134 -134
  99. package/src/rc2-delivery-policy-transform.mjs +1127 -1127
  100. package/src/rc2-rc1-transfer-front-end.mjs +511 -511
  101. package/src/rc3-actual-dogfood.mjs +652 -652
  102. package/src/rc3-dogfood-scaffold.mjs +315 -315
  103. package/src/rc3-scripted-campaign.mjs +1383 -1383
  104. package/src/rc4-stage1-dogfood.mjs +673 -673
  105. package/src/runtime-adapter-registry.mjs +524 -524
  106. package/src/runtime-cli.mjs +4588 -4588
  107. package/src/runtime-contracts.mjs +824 -824
  108. package/src/runtime-control-store.mjs +604 -604
  109. package/src/runtime-controller-protocol.mjs +587 -587
  110. package/src/runtime-decision-verifier.mjs +701 -701
  111. package/src/runtime-diff-observer.mjs +361 -361
  112. package/src/runtime-direct-os-observer.mjs +301 -301
  113. package/src/runtime-driver-state.mjs +166 -166
  114. package/src/runtime-engine.mjs +779 -779
  115. package/src/runtime-errors.mjs +356 -356
  116. package/src/runtime-event-store.mjs +189 -189
  117. package/src/runtime-front-end.mjs +925 -925
  118. package/src/runtime-gate-store.mjs +481 -481
  119. package/src/runtime-hold-recompile.mjs +916 -916
  120. package/src/runtime-io-sentinel.mjs +391 -391
  121. package/src/runtime-lifecycle-lock.mjs +294 -294
  122. package/src/runtime-managed-supervisor.mjs +1490 -1490
  123. package/src/runtime-multi-epoch-store.mjs +838 -838
  124. package/src/runtime-projection.mjs +269 -269
  125. package/src/runtime-pull-intake.mjs +1192 -1192
  126. package/src/runtime-scripted-adapter-controller.mjs +1160 -1160
  127. package/src/runtime-scripted-executor.mjs +163 -163
  128. package/src/runtime-scripted-worktree.mjs +104 -104
  129. package/src/runtime-seam-resolve.mjs +428 -428
  130. package/src/runtime-seam-treatment.mjs +173 -173
  131. package/src/runtime-socket-owner.mjs +125 -125
  132. package/src/runtime-work-order-contracts.mjs +91 -91
  133. package/src/runtime-work-order-controller.mjs +1171 -1171
  134. package/src/runtime-worktree-executor.mjs +199 -199
  135. package/src/schedulability-compiler-v2.mjs +303 -303
  136. package/src/schedulability-verifier-v2.mjs +317 -317
  137. package/src/seam-apply.mjs +549 -549
  138. package/src/seam-commit-shared.mjs +22 -22
  139. package/src/seam-commit-transform.mjs +81 -81
  140. package/src/seam-commit.mjs +18 -18
  141. package/src/seam-cost.mjs +322 -322
  142. package/src/seam-derivation.mjs +188 -188
  143. package/src/seam-gate.mjs +146 -146
  144. package/src/seam-proposal-contracts.mjs +446 -446
  145. package/src/seam-proposal-queries.mjs +521 -521
  146. package/src/seam-proposal.mjs +2011 -2011
  147. package/src/seam-ref.mjs +33 -33
  148. package/src/seam-rewrite.mjs +286 -286
  149. package/src/seam-transform.mjs +554 -554
  150. package/src/seam-verification.mjs +260 -260
  151. package/src/sensor-adapter.mjs +432 -432
  152. package/src/sensor-cli.mjs +139 -139
  153. package/src/sensor-diff.mjs +661 -661
  154. package/src/sensor-node-runtime.mjs +53 -53
  155. package/src/sensor-runtime.mjs +52 -52
  156. package/src/timestamp-contract.mjs +8 -8
  157. package/src/todo-audit-pending.mjs +91 -91
  158. package/src/todo-chain.mjs +178 -178
  159. package/src/todo-cli.mjs +8 -8
  160. package/src/todo-contracts.mjs +728 -728
  161. package/src/todo-dashboard-registry.mjs +573 -573
  162. package/src/todo-dispatch-shape.mjs +190 -190
  163. package/src/todo-gantt-html-independence.mjs +239 -239
  164. package/src/todo-gantt-html-shared.mjs +226 -226
  165. package/src/todo-gantt-html-style.mjs +131 -131
  166. package/src/todo-gantt-html.mjs +248 -248
  167. package/src/todo-gantt-layout.mjs +974 -974
  168. package/src/todo-gantt-live.mjs +361 -361
  169. package/src/todo-gantt-nested.mjs +263 -263
  170. package/src/todo-gantt-presentation.mjs +217 -217
  171. package/src/todo-gantt-scope.mjs +123 -123
  172. package/src/todo-gantt-svg.mjs +353 -353
  173. package/src/todo-independence-contracts.mjs +595 -595
  174. package/src/todo-independence-guidance.mjs +322 -322
  175. package/src/todo-independence.mjs +640 -640
  176. package/src/todo-markdown-renderer.mjs +260 -260
  177. package/src/todo-migration.mjs +448 -448
  178. package/src/todo-narrative-anchor.mjs +130 -130
  179. package/src/todo-note-store.mjs +629 -629
  180. package/src/todo-parallel-candidates.mjs +114 -114
  181. package/src/todo-revision.mjs +995 -995
  182. package/src/todo-split.mjs +472 -472
  183. package/src/todo-status.mjs +690 -690
  184. package/src/todo-store-git-transaction.mjs +418 -418
  185. package/src/todo-store.mjs +42 -21
  186. package/src/treatment-compiler.mjs +728 -728
  187. package/src/treatment-runner.mjs +656 -656
  188. package/src/witness-scaffold.mjs +180 -180
@@ -1,376 +1,376 @@
1
- import { createServer, request as httpRequest } from 'node:http';
2
- import { request as httpsRequest } from 'node:https';
3
- import { constants as fsConstants } from 'node:fs';
4
- import { lstat, open } from 'node:fs/promises';
5
- import path from 'node:path';
6
- import { parseTree } from 'jsonc-parser';
7
-
8
- import { networkInterfaces } from 'node:os';
9
-
10
- import { resolveBridgeListenAddress } from './bridge-address.mjs';
11
- import { registerBridgeUpstream } from './bridge-registrar.mjs';
12
- import {
13
- BridgeConfigError, bridgeConfigPaths, normalizeBridgeAllowedHost, readBridgeConfig,
14
- } from './bridge-config.mjs';
15
-
16
- const HOP_BY_HOP = new Set([
17
- 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
18
- 'te', 'trailer', 'transfer-encoding', 'upgrade',
19
- ]);
20
- const UNTRUSTED_CLIENT_IP_HEADERS = new Set([
21
- 'cf-connecting-ip', 'client-ip', 'fastly-client-ip', 'true-client-ip',
22
- 'x-cluster-client-ip', 'x-proxyuser-ip',
23
- ]);
24
-
25
- function dashboardRuntimeDir(env) {
26
- const configured = env.LATTICE_DASHBOARD_RUNTIME_DIR;
27
- return typeof configured === 'string' && path.isAbsolute(configured)
28
- ? configured : path.join(bridgeConfigPaths(env).root, 'dashboard');
29
- }
30
-
31
- function duplicateJsonKey(node) {
32
- if (node?.type === 'object') {
33
- const keys = new Set();
34
- for (const property of node.children ?? []) {
35
- const [key, child] = property.children ?? [];
36
- if (keys.has(key?.value) || duplicateJsonKey(child)) return true;
37
- keys.add(key?.value);
38
- }
39
- } else if (node?.type === 'array') return (node.children ?? []).some(duplicateJsonKey);
40
- return false;
41
- }
42
-
43
- async function readDashboardDescriptor(ref) {
44
- let before;
45
- let handle;
46
- try {
47
- before = await lstat(ref);
48
- if (!before.isFile() || before.isSymbolicLink() || (before.mode & 0o777) !== 0o600 || before.size > 65_536) {
49
- throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor is unsafe');
50
- }
51
- handle = await open(ref, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
52
- const opened = await handle.stat();
53
- if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino
54
- || opened.size !== before.size || opened.size > 65_536) {
55
- throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor changed during validation');
56
- }
57
- const text = await handle.readFile('utf8');
58
- const after = await lstat(ref);
59
- if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size) {
60
- throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor changed during read');
61
- }
62
- const errors = [];
63
- const tree = parseTree(text, errors, { allowTrailingComma: false, disallowComments: true });
64
- if (errors.length > 0 || tree === undefined || duplicateJsonKey(tree)) {
65
- throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor JSON is invalid');
66
- }
67
- return JSON.parse(text);
68
- } catch (error) {
69
- if (error instanceof BridgeConfigError) throw error;
70
- throw new BridgeConfigError('BRIDGE_UPSTREAM_UNAVAILABLE', 'dashboard descriptor is unavailable', undefined, error);
71
- } finally { await handle?.close(); }
72
- }
73
-
74
- export async function resolveBridgeUpstream(upstream, {
75
- env = process.env, healthTimeoutMs = 2_000,
76
- } = {}) {
77
- if (upstream.mode === 'url') return new URL(upstream.url);
78
- let descriptor;
79
- try {
80
- descriptor = await readDashboardDescriptor(path.join(dashboardRuntimeDir(env), 'daemon.json'));
81
- } catch (error) {
82
- throw new BridgeConfigError('BRIDGE_UPSTREAM_UNAVAILABLE', 'dashboard descriptor is unavailable', undefined, error);
83
- }
84
- if (descriptor?.schema !== 'lattice.todo_dashboard_daemon.v1'
85
- || Object.keys(descriptor).sort().join(',') !== 'pid,port,schema,started_at'
86
- || !Number.isSafeInteger(descriptor.pid) || descriptor.pid <= 0
87
- || !Number.isSafeInteger(descriptor.port) || descriptor.port <= 0 || descriptor.port > 65_535
88
- || typeof descriptor.started_at !== 'string') {
89
- throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor is invalid');
90
- }
91
- try {
92
- const response = await fetch(`http://127.0.0.1:${descriptor.port}/__lattice/health`, {
93
- signal: AbortSignal.timeout(healthTimeoutMs),
94
- });
95
- const health = response.status === 200 ? await response.json() : null;
96
- if (health?.schema !== 'lattice.todo_dashboard_health.v1' || health.pid !== descriptor.pid
97
- || health.port !== descriptor.port) throw new Error('dashboard health mismatch');
98
- } catch (error) {
99
- throw new BridgeConfigError('BRIDGE_UPSTREAM_UNAVAILABLE',
100
- 'dashboard descriptor could not be attested against loopback health', undefined, error);
101
- }
102
- return new URL(`http://127.0.0.1:${descriptor.port}/`);
103
- }
104
-
105
- function responseError(response, status, code) {
106
- if (response.headersSent) {
107
- response.destroy();
108
- return;
109
- }
110
- response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
111
- response.end(`${JSON.stringify({ schema: 'lattice.bridge_http_error.v1', code })}\n`);
112
- }
113
-
114
- function connectionTokens(headers) {
115
- const value = headers.connection;
116
- return new Set((Array.isArray(value) ? value.join(',') : value ?? '')
117
- .split(',').map((entry) => entry.trim().toLowerCase()).filter(Boolean));
118
- }
119
-
120
- function forwardHeaders(headers) {
121
- const nominated = connectionTokens(headers);
122
- return Object.fromEntries(Object.entries(headers)
123
- .filter(([name, value]) => value !== undefined && name !== 'host'
124
- && name.toLowerCase() !== 'forwarded' && name.toLowerCase() !== 'x-real-ip'
125
- && !name.toLowerCase().startsWith('x-forwarded-')
126
- && !UNTRUSTED_CLIENT_IP_HEADERS.has(name.toLowerCase())
127
- && !HOP_BY_HOP.has(name.toLowerCase()) && !nominated.has(name.toLowerCase())));
128
- }
129
-
130
- function validatedRequestHost(value) {
131
- if (typeof value !== 'string' || value.length === 0 || value.length > 512
132
- || /[\s\\/@,]/u.test(value)) {
133
- throw new BridgeConfigError('BRIDGE_HOST_INVALID', 'request Host is invalid');
134
- }
135
- let parsed;
136
- try { parsed = new URL(`http://${value}`); } catch {
137
- throw new BridgeConfigError('BRIDGE_HOST_INVALID', 'request Host is invalid');
138
- }
139
- if (parsed.username !== '' || parsed.password !== '' || parsed.pathname !== '/'
140
- || parsed.search !== '' || parsed.hash !== '') {
141
- throw new BridgeConfigError('BRIDGE_HOST_INVALID', 'request Host is invalid');
142
- }
143
- const rawHostname = parsed.hostname.startsWith('[') && parsed.hostname.endsWith(']')
144
- ? parsed.hostname.slice(1, -1) : parsed.hostname;
145
- const hostname = normalizeBridgeAllowedHost(rawHostname);
146
- const authorityHost = hostname.includes(':') ? `[${hostname}]` : hostname;
147
- return { hostname, authority: parsed.port === '' ? authorityHost : `${authorityHost}:${parsed.port}` };
148
- }
149
-
150
- function forwardedHeaders(incoming, host) {
151
- const remote = incoming.socket.remoteAddress ?? 'unknown';
152
- const forwardedFor = remote.includes(':') ? `"[${remote}]"` : remote;
153
- return {
154
- forwarded: `for=${forwardedFor};host="${host.authority}";proto=http`,
155
- 'x-forwarded-for': remote,
156
- 'x-forwarded-host': host.authority,
157
- 'x-forwarded-proto': 'http',
158
- 'x-real-ip': remote,
159
- };
160
- }
161
-
162
- function upstreamUrl(base, requestUrl) {
163
- if (typeof requestUrl !== 'string' || requestUrl.includes('#') || !/^\/(?!\/)[^\s]*$/u.test(requestUrl)) {
164
- throw new BridgeConfigError('BRIDGE_REQUEST_TARGET_INVALID', 'bridge requires an origin-form request target');
165
- }
166
- const rawPath = requestUrl.split('?', 1)[0];
167
- if (rawPath.includes('\\') || rawPath.includes('%')) {
168
- throw new BridgeConfigError('BRIDGE_REQUEST_TARGET_INVALID',
169
- 'bridge request target contains encoded path bytes or a backslash');
170
- }
171
- if (rawPath.split('/').some((segment) => segment === '.' || segment === '..')) {
172
- throw new BridgeConfigError('BRIDGE_REQUEST_TARGET_INVALID', 'bridge request target contains a dot segment');
173
- }
174
- const basePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname}/`;
175
- const target = new URL(requestUrl.slice(1), base);
176
- if (target.origin !== base.origin || !target.pathname.startsWith(basePath)) {
177
- throw new BridgeConfigError('BRIDGE_REQUEST_TARGET_INVALID', 'bridge request target escapes upstream base');
178
- }
179
- return target;
180
- }
181
-
182
- function decodedRequestPath(requestUrl) {
183
- if (typeof requestUrl !== 'string') return null;
184
- try { return decodeURIComponent(requestUrl.split('?', 1)[0]); } catch { return null; }
185
- }
186
-
187
- export async function startBridgeServer({
188
- config, env = process.env,
189
- instanceToken = null,
190
- resolveUpstream = (upstream) => resolveBridgeUpstream(upstream, { env }),
191
- interfaces = networkInterfaces(),
192
- } = {}) {
193
- if (config?.enabled !== true) throw new BridgeConfigError('BRIDGE_DISABLED', 'bridge is disabled');
194
- if (!Array.isArray(config.allowed_hosts) || config.allowed_hosts.length === 0) {
195
- throw new BridgeConfigError('BRIDGE_CONFIG_INVALID', 'bridge allowed hosts are required');
196
- }
197
- // A DHCP lease change moves the host inside its own subnet and strands the
198
- // configured literal. Follow it rather than binding a dead address, but only
199
- // within the same subnet (see bridge-address.mjs for why that bound matters).
200
- const resolvedListen = resolveBridgeListenAddress({ configured: config.listen.address, interfaces });
201
- if (resolvedListen.effective === null) {
202
- throw new BridgeConfigError('BRIDGE_LISTEN_ADDRESS_ABSENT',
203
- 'configured bridge listen address is not present on this host',
204
- { ...config.listen, listen_state: resolvedListen.state });
205
- }
206
- const listenAddress = resolvedListen.effective;
207
- let allowedHosts = new Set(config.allowed_hosts);
208
- // The rebound address has to answer for itself, otherwise every request to it
209
- // is rejected by the Host allow-list the operator never knew had gone stale.
210
- if (listenAddress !== config.listen.address) {
211
- allowedHosts.add(normalizeBridgeAllowedHost(listenAddress));
212
- }
213
- let currentConfig = config;
214
- const handleRequest = async (incoming, response) => {
215
- let requestHost;
216
- try { requestHost = validatedRequestHost(incoming.headers.host); } catch (error) {
217
- responseError(response, 400, error?.code ?? 'BRIDGE_HOST_INVALID');
218
- return;
219
- }
220
- if (!allowedHosts.has(requestHost.hostname)) {
221
- responseError(response, 421, 'BRIDGE_HOST_NOT_ALLOWED');
222
- return;
223
- }
224
- if (incoming.url === '/__lattice/bridge-health') {
225
- response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
226
- const attested = typeof instanceToken === 'string'
227
- && incoming.headers['x-lattice-bridge-instance-token'] === instanceToken;
228
- response.end(`${JSON.stringify(attested
229
- ? { schema: 'lattice.bridge_health.v1', pid: process.pid,
230
- address: currentConfig.listen.address, port: currentConfig.listen.port,
231
- updated_at: currentConfig.updated_at ?? null }
232
- : { schema: 'lattice.bridge_health.v1', status: 'available' })}\n`);
233
- return;
234
- }
235
- if (decodedRequestPath(incoming.url) === '/__lattice/health') {
236
- responseError(response, 404, 'BRIDGE_INTERNAL_PATH_DENIED');
237
- return;
238
- }
239
- let target;
240
- try { target = upstreamUrl(await resolveUpstream(currentConfig.upstream), incoming.url); } catch (error) {
241
- responseError(response, error?.code === 'BRIDGE_REQUEST_TARGET_INVALID' ? 400 : 503,
242
- error?.code ?? 'BRIDGE_UPSTREAM_UNAVAILABLE');
243
- return;
244
- }
245
- let upstreamResponse = null;
246
- const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)(target, {
247
- method: incoming.method,
248
- headers: { ...forwardHeaders(incoming.headers), ...forwardedHeaders(incoming, requestHost), host: target.host },
249
- }, (incomingResponse) => {
250
- upstreamResponse = incomingResponse;
251
- response.writeHead(incomingResponse.statusCode ?? 502, forwardHeaders(incomingResponse.headers));
252
- incomingResponse.once('error', () => response.destroy());
253
- incomingResponse.pipe(response);
254
- });
255
- request.once('error', (error) => responseError(response, 502,
256
- error?.code === 'ECONNREFUSED' ? 'BRIDGE_UPSTREAM_REFUSED' : 'BRIDGE_PROXY_FAILED'));
257
- incoming.once('aborted', () => request.destroy());
258
- response.once('close', () => {
259
- if (!response.writableFinished) {
260
- request.destroy();
261
- upstreamResponse?.destroy();
262
- }
263
- });
264
- incoming.pipe(request);
265
- };
266
- const server = createServer((incoming, response) => {
267
- handleRequest(incoming, response).catch((error) => responseError(response, 500,
268
- error?.code ?? 'BRIDGE_REQUEST_FAILED'));
269
- });
270
- await new Promise((resolve, reject) => {
271
- server.once('error', reject);
272
- server.listen({ host: listenAddress, port: config.listen.port, exclusive: true }, resolve);
273
- }).catch((error) => {
274
- throw new BridgeConfigError(error?.code === 'EADDRINUSE' ? 'BRIDGE_PORT_UNAVAILABLE' : 'BRIDGE_BIND_FAILED',
275
- 'bridge listen failed', { ...config.listen, effective_address: listenAddress }, error);
276
- });
277
- const boundAddress = server.address();
278
- const actualPort = typeof boundAddress === 'object' && boundAddress !== null
279
- ? boundAddress.port : config.listen.port;
280
- let closed = false;
281
- return Object.freeze({
282
- address: listenAddress,
283
- configured_address: config.listen.address,
284
- rebound: listenAddress !== config.listen.address,
285
- port: actualPort,
286
- updateConfig(next) {
287
- if (next?.enabled !== true || next.listen.address !== config.listen.address
288
- || next.listen.port !== config.listen.port) {
289
- throw new BridgeConfigError('BRIDGE_RECONFIGURE_INVALID', 'live bridge binding cannot be mutated in place');
290
- }
291
- currentConfig = next;
292
- allowedHosts = new Set(next.allowed_hosts);
293
- if (listenAddress !== config.listen.address) {
294
- allowedHosts.add(normalizeBridgeAllowedHost(listenAddress));
295
- }
296
- },
297
- close: async () => {
298
- if (closed) return;
299
- closed = true;
300
- const completion = new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
301
- server.closeAllConnections?.();
302
- await completion;
303
- },
304
- });
305
- }
306
-
307
- export function bridgeRuntimeController({
308
- env = process.env, instanceToken = null,
309
- register = registerBridgeUpstream,
310
- report = (line) => process.stderr.write(`${line}\n`),
311
- readInterfaces = () => networkInterfaces(),
312
- } = {}) {
313
- let active = null;
314
- let fingerprint = null;
315
- // A fresh binding is exactly when the reverse proxy's literal may have gone
316
- // stale, so that is when the bridge tells it where it now is. A registration
317
- // failure is reported, never swallowed, and never takes the local bridge down:
318
- // the bridge is still serving, only the published route is behind.
319
- const announce = async (binding) => {
320
- try {
321
- const outcome = await register({ port: binding.port, env });
322
- if (outcome.state !== 'not_configured') report(JSON.stringify(outcome));
323
- } catch (error) {
324
- report(JSON.stringify({ schema: 'lattice.bridge_registrar_result.v1', state: 'failed',
325
- port: binding.port, host: null, remote: null,
326
- detail: error?.message ?? 'registration failed' }));
327
- }
328
- };
329
- return Object.freeze({
330
- async reconcile() {
331
- const config = await readBridgeConfig({ env });
332
- const next = config === null || !config.enabled ? null : JSON.stringify(config);
333
- // The config is not the only thing that can change under a live binding.
334
- // A DHCP lease change removes the bound address from the host without
335
- // touching a byte of config, and the socket lingers on an address nothing
336
- // can route to. Resolve the effective address on every pass, from the
337
- // interfaces as they are now, so the binding follows the host rather than
338
- // only the file. One snapshot serves both the comparison and the bind, so
339
- // a lease that changes mid-reconcile cannot land them on different
340
- // addresses.
341
- const interfaces = next === null ? null : readInterfaces();
342
- const effective = next === null ? null
343
- : resolveBridgeListenAddress({ configured: config.listen.address, interfaces }).effective;
344
- if (next === fingerprint && (active === null || active.address === effective)) return active;
345
- if (next === null) {
346
- await active?.close();
347
- active = null;
348
- fingerprint = null;
349
- return null;
350
- }
351
- // The binding is reused while it still serves the same configuration AND
352
- // still sits on the address that configuration resolves to today. The
353
- // effective address is stable for an unchanged host, so this compares
354
- // equal on every quiet pass and only forces a rebind when the ground has
355
- // actually moved.
356
- if (active !== null && active.configured_address === config.listen.address
357
- && active.port === config.listen.port && active.address === effective) {
358
- active.updateConfig(config);
359
- fingerprint = next;
360
- return active;
361
- }
362
- const replacement = await startBridgeServer({ config, env, instanceToken, interfaces });
363
- const previous = active;
364
- active = replacement;
365
- fingerprint = next;
366
- await previous?.close();
367
- await announce(active);
368
- return active;
369
- },
370
- async close() {
371
- await active?.close();
372
- active = null;
373
- fingerprint = null;
374
- },
375
- });
376
- }
1
+ import { createServer, request as httpRequest } from 'node:http';
2
+ import { request as httpsRequest } from 'node:https';
3
+ import { constants as fsConstants } from 'node:fs';
4
+ import { lstat, open } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { parseTree } from 'jsonc-parser';
7
+
8
+ import { networkInterfaces } from 'node:os';
9
+
10
+ import { resolveBridgeListenAddress } from './bridge-address.mjs';
11
+ import { registerBridgeUpstream } from './bridge-registrar.mjs';
12
+ import {
13
+ BridgeConfigError, bridgeConfigPaths, normalizeBridgeAllowedHost, readBridgeConfig,
14
+ } from './bridge-config.mjs';
15
+
16
+ const HOP_BY_HOP = new Set([
17
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
18
+ 'te', 'trailer', 'transfer-encoding', 'upgrade',
19
+ ]);
20
+ const UNTRUSTED_CLIENT_IP_HEADERS = new Set([
21
+ 'cf-connecting-ip', 'client-ip', 'fastly-client-ip', 'true-client-ip',
22
+ 'x-cluster-client-ip', 'x-proxyuser-ip',
23
+ ]);
24
+
25
+ function dashboardRuntimeDir(env) {
26
+ const configured = env.LATTICE_DASHBOARD_RUNTIME_DIR;
27
+ return typeof configured === 'string' && path.isAbsolute(configured)
28
+ ? configured : path.join(bridgeConfigPaths(env).root, 'dashboard');
29
+ }
30
+
31
+ function duplicateJsonKey(node) {
32
+ if (node?.type === 'object') {
33
+ const keys = new Set();
34
+ for (const property of node.children ?? []) {
35
+ const [key, child] = property.children ?? [];
36
+ if (keys.has(key?.value) || duplicateJsonKey(child)) return true;
37
+ keys.add(key?.value);
38
+ }
39
+ } else if (node?.type === 'array') return (node.children ?? []).some(duplicateJsonKey);
40
+ return false;
41
+ }
42
+
43
+ async function readDashboardDescriptor(ref) {
44
+ let before;
45
+ let handle;
46
+ try {
47
+ before = await lstat(ref);
48
+ if (!before.isFile() || before.isSymbolicLink() || (before.mode & 0o777) !== 0o600 || before.size > 65_536) {
49
+ throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor is unsafe');
50
+ }
51
+ handle = await open(ref, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
52
+ const opened = await handle.stat();
53
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino
54
+ || opened.size !== before.size || opened.size > 65_536) {
55
+ throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor changed during validation');
56
+ }
57
+ const text = await handle.readFile('utf8');
58
+ const after = await lstat(ref);
59
+ if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size) {
60
+ throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor changed during read');
61
+ }
62
+ const errors = [];
63
+ const tree = parseTree(text, errors, { allowTrailingComma: false, disallowComments: true });
64
+ if (errors.length > 0 || tree === undefined || duplicateJsonKey(tree)) {
65
+ throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor JSON is invalid');
66
+ }
67
+ return JSON.parse(text);
68
+ } catch (error) {
69
+ if (error instanceof BridgeConfigError) throw error;
70
+ throw new BridgeConfigError('BRIDGE_UPSTREAM_UNAVAILABLE', 'dashboard descriptor is unavailable', undefined, error);
71
+ } finally { await handle?.close(); }
72
+ }
73
+
74
+ export async function resolveBridgeUpstream(upstream, {
75
+ env = process.env, healthTimeoutMs = 2_000,
76
+ } = {}) {
77
+ if (upstream.mode === 'url') return new URL(upstream.url);
78
+ let descriptor;
79
+ try {
80
+ descriptor = await readDashboardDescriptor(path.join(dashboardRuntimeDir(env), 'daemon.json'));
81
+ } catch (error) {
82
+ throw new BridgeConfigError('BRIDGE_UPSTREAM_UNAVAILABLE', 'dashboard descriptor is unavailable', undefined, error);
83
+ }
84
+ if (descriptor?.schema !== 'lattice.todo_dashboard_daemon.v1'
85
+ || Object.keys(descriptor).sort().join(',') !== 'pid,port,schema,started_at'
86
+ || !Number.isSafeInteger(descriptor.pid) || descriptor.pid <= 0
87
+ || !Number.isSafeInteger(descriptor.port) || descriptor.port <= 0 || descriptor.port > 65_535
88
+ || typeof descriptor.started_at !== 'string') {
89
+ throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor is invalid');
90
+ }
91
+ try {
92
+ const response = await fetch(`http://127.0.0.1:${descriptor.port}/__lattice/health`, {
93
+ signal: AbortSignal.timeout(healthTimeoutMs),
94
+ });
95
+ const health = response.status === 200 ? await response.json() : null;
96
+ if (health?.schema !== 'lattice.todo_dashboard_health.v1' || health.pid !== descriptor.pid
97
+ || health.port !== descriptor.port) throw new Error('dashboard health mismatch');
98
+ } catch (error) {
99
+ throw new BridgeConfigError('BRIDGE_UPSTREAM_UNAVAILABLE',
100
+ 'dashboard descriptor could not be attested against loopback health', undefined, error);
101
+ }
102
+ return new URL(`http://127.0.0.1:${descriptor.port}/`);
103
+ }
104
+
105
+ function responseError(response, status, code) {
106
+ if (response.headersSent) {
107
+ response.destroy();
108
+ return;
109
+ }
110
+ response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
111
+ response.end(`${JSON.stringify({ schema: 'lattice.bridge_http_error.v1', code })}\n`);
112
+ }
113
+
114
+ function connectionTokens(headers) {
115
+ const value = headers.connection;
116
+ return new Set((Array.isArray(value) ? value.join(',') : value ?? '')
117
+ .split(',').map((entry) => entry.trim().toLowerCase()).filter(Boolean));
118
+ }
119
+
120
+ function forwardHeaders(headers) {
121
+ const nominated = connectionTokens(headers);
122
+ return Object.fromEntries(Object.entries(headers)
123
+ .filter(([name, value]) => value !== undefined && name !== 'host'
124
+ && name.toLowerCase() !== 'forwarded' && name.toLowerCase() !== 'x-real-ip'
125
+ && !name.toLowerCase().startsWith('x-forwarded-')
126
+ && !UNTRUSTED_CLIENT_IP_HEADERS.has(name.toLowerCase())
127
+ && !HOP_BY_HOP.has(name.toLowerCase()) && !nominated.has(name.toLowerCase())));
128
+ }
129
+
130
+ function validatedRequestHost(value) {
131
+ if (typeof value !== 'string' || value.length === 0 || value.length > 512
132
+ || /[\s\\/@,]/u.test(value)) {
133
+ throw new BridgeConfigError('BRIDGE_HOST_INVALID', 'request Host is invalid');
134
+ }
135
+ let parsed;
136
+ try { parsed = new URL(`http://${value}`); } catch {
137
+ throw new BridgeConfigError('BRIDGE_HOST_INVALID', 'request Host is invalid');
138
+ }
139
+ if (parsed.username !== '' || parsed.password !== '' || parsed.pathname !== '/'
140
+ || parsed.search !== '' || parsed.hash !== '') {
141
+ throw new BridgeConfigError('BRIDGE_HOST_INVALID', 'request Host is invalid');
142
+ }
143
+ const rawHostname = parsed.hostname.startsWith('[') && parsed.hostname.endsWith(']')
144
+ ? parsed.hostname.slice(1, -1) : parsed.hostname;
145
+ const hostname = normalizeBridgeAllowedHost(rawHostname);
146
+ const authorityHost = hostname.includes(':') ? `[${hostname}]` : hostname;
147
+ return { hostname, authority: parsed.port === '' ? authorityHost : `${authorityHost}:${parsed.port}` };
148
+ }
149
+
150
+ function forwardedHeaders(incoming, host) {
151
+ const remote = incoming.socket.remoteAddress ?? 'unknown';
152
+ const forwardedFor = remote.includes(':') ? `"[${remote}]"` : remote;
153
+ return {
154
+ forwarded: `for=${forwardedFor};host="${host.authority}";proto=http`,
155
+ 'x-forwarded-for': remote,
156
+ 'x-forwarded-host': host.authority,
157
+ 'x-forwarded-proto': 'http',
158
+ 'x-real-ip': remote,
159
+ };
160
+ }
161
+
162
+ function upstreamUrl(base, requestUrl) {
163
+ if (typeof requestUrl !== 'string' || requestUrl.includes('#') || !/^\/(?!\/)[^\s]*$/u.test(requestUrl)) {
164
+ throw new BridgeConfigError('BRIDGE_REQUEST_TARGET_INVALID', 'bridge requires an origin-form request target');
165
+ }
166
+ const rawPath = requestUrl.split('?', 1)[0];
167
+ if (rawPath.includes('\\') || rawPath.includes('%')) {
168
+ throw new BridgeConfigError('BRIDGE_REQUEST_TARGET_INVALID',
169
+ 'bridge request target contains encoded path bytes or a backslash');
170
+ }
171
+ if (rawPath.split('/').some((segment) => segment === '.' || segment === '..')) {
172
+ throw new BridgeConfigError('BRIDGE_REQUEST_TARGET_INVALID', 'bridge request target contains a dot segment');
173
+ }
174
+ const basePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname}/`;
175
+ const target = new URL(requestUrl.slice(1), base);
176
+ if (target.origin !== base.origin || !target.pathname.startsWith(basePath)) {
177
+ throw new BridgeConfigError('BRIDGE_REQUEST_TARGET_INVALID', 'bridge request target escapes upstream base');
178
+ }
179
+ return target;
180
+ }
181
+
182
+ function decodedRequestPath(requestUrl) {
183
+ if (typeof requestUrl !== 'string') return null;
184
+ try { return decodeURIComponent(requestUrl.split('?', 1)[0]); } catch { return null; }
185
+ }
186
+
187
+ export async function startBridgeServer({
188
+ config, env = process.env,
189
+ instanceToken = null,
190
+ resolveUpstream = (upstream) => resolveBridgeUpstream(upstream, { env }),
191
+ interfaces = networkInterfaces(),
192
+ } = {}) {
193
+ if (config?.enabled !== true) throw new BridgeConfigError('BRIDGE_DISABLED', 'bridge is disabled');
194
+ if (!Array.isArray(config.allowed_hosts) || config.allowed_hosts.length === 0) {
195
+ throw new BridgeConfigError('BRIDGE_CONFIG_INVALID', 'bridge allowed hosts are required');
196
+ }
197
+ // A DHCP lease change moves the host inside its own subnet and strands the
198
+ // configured literal. Follow it rather than binding a dead address, but only
199
+ // within the same subnet (see bridge-address.mjs for why that bound matters).
200
+ const resolvedListen = resolveBridgeListenAddress({ configured: config.listen.address, interfaces });
201
+ if (resolvedListen.effective === null) {
202
+ throw new BridgeConfigError('BRIDGE_LISTEN_ADDRESS_ABSENT',
203
+ 'configured bridge listen address is not present on this host',
204
+ { ...config.listen, listen_state: resolvedListen.state });
205
+ }
206
+ const listenAddress = resolvedListen.effective;
207
+ let allowedHosts = new Set(config.allowed_hosts);
208
+ // The rebound address has to answer for itself, otherwise every request to it
209
+ // is rejected by the Host allow-list the operator never knew had gone stale.
210
+ if (listenAddress !== config.listen.address) {
211
+ allowedHosts.add(normalizeBridgeAllowedHost(listenAddress));
212
+ }
213
+ let currentConfig = config;
214
+ const handleRequest = async (incoming, response) => {
215
+ let requestHost;
216
+ try { requestHost = validatedRequestHost(incoming.headers.host); } catch (error) {
217
+ responseError(response, 400, error?.code ?? 'BRIDGE_HOST_INVALID');
218
+ return;
219
+ }
220
+ if (!allowedHosts.has(requestHost.hostname)) {
221
+ responseError(response, 421, 'BRIDGE_HOST_NOT_ALLOWED');
222
+ return;
223
+ }
224
+ if (incoming.url === '/__lattice/bridge-health') {
225
+ response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
226
+ const attested = typeof instanceToken === 'string'
227
+ && incoming.headers['x-lattice-bridge-instance-token'] === instanceToken;
228
+ response.end(`${JSON.stringify(attested
229
+ ? { schema: 'lattice.bridge_health.v1', pid: process.pid,
230
+ address: currentConfig.listen.address, port: currentConfig.listen.port,
231
+ updated_at: currentConfig.updated_at ?? null }
232
+ : { schema: 'lattice.bridge_health.v1', status: 'available' })}\n`);
233
+ return;
234
+ }
235
+ if (decodedRequestPath(incoming.url) === '/__lattice/health') {
236
+ responseError(response, 404, 'BRIDGE_INTERNAL_PATH_DENIED');
237
+ return;
238
+ }
239
+ let target;
240
+ try { target = upstreamUrl(await resolveUpstream(currentConfig.upstream), incoming.url); } catch (error) {
241
+ responseError(response, error?.code === 'BRIDGE_REQUEST_TARGET_INVALID' ? 400 : 503,
242
+ error?.code ?? 'BRIDGE_UPSTREAM_UNAVAILABLE');
243
+ return;
244
+ }
245
+ let upstreamResponse = null;
246
+ const request = (target.protocol === 'https:' ? httpsRequest : httpRequest)(target, {
247
+ method: incoming.method,
248
+ headers: { ...forwardHeaders(incoming.headers), ...forwardedHeaders(incoming, requestHost), host: target.host },
249
+ }, (incomingResponse) => {
250
+ upstreamResponse = incomingResponse;
251
+ response.writeHead(incomingResponse.statusCode ?? 502, forwardHeaders(incomingResponse.headers));
252
+ incomingResponse.once('error', () => response.destroy());
253
+ incomingResponse.pipe(response);
254
+ });
255
+ request.once('error', (error) => responseError(response, 502,
256
+ error?.code === 'ECONNREFUSED' ? 'BRIDGE_UPSTREAM_REFUSED' : 'BRIDGE_PROXY_FAILED'));
257
+ incoming.once('aborted', () => request.destroy());
258
+ response.once('close', () => {
259
+ if (!response.writableFinished) {
260
+ request.destroy();
261
+ upstreamResponse?.destroy();
262
+ }
263
+ });
264
+ incoming.pipe(request);
265
+ };
266
+ const server = createServer((incoming, response) => {
267
+ handleRequest(incoming, response).catch((error) => responseError(response, 500,
268
+ error?.code ?? 'BRIDGE_REQUEST_FAILED'));
269
+ });
270
+ await new Promise((resolve, reject) => {
271
+ server.once('error', reject);
272
+ server.listen({ host: listenAddress, port: config.listen.port, exclusive: true }, resolve);
273
+ }).catch((error) => {
274
+ throw new BridgeConfigError(error?.code === 'EADDRINUSE' ? 'BRIDGE_PORT_UNAVAILABLE' : 'BRIDGE_BIND_FAILED',
275
+ 'bridge listen failed', { ...config.listen, effective_address: listenAddress }, error);
276
+ });
277
+ const boundAddress = server.address();
278
+ const actualPort = typeof boundAddress === 'object' && boundAddress !== null
279
+ ? boundAddress.port : config.listen.port;
280
+ let closed = false;
281
+ return Object.freeze({
282
+ address: listenAddress,
283
+ configured_address: config.listen.address,
284
+ rebound: listenAddress !== config.listen.address,
285
+ port: actualPort,
286
+ updateConfig(next) {
287
+ if (next?.enabled !== true || next.listen.address !== config.listen.address
288
+ || next.listen.port !== config.listen.port) {
289
+ throw new BridgeConfigError('BRIDGE_RECONFIGURE_INVALID', 'live bridge binding cannot be mutated in place');
290
+ }
291
+ currentConfig = next;
292
+ allowedHosts = new Set(next.allowed_hosts);
293
+ if (listenAddress !== config.listen.address) {
294
+ allowedHosts.add(normalizeBridgeAllowedHost(listenAddress));
295
+ }
296
+ },
297
+ close: async () => {
298
+ if (closed) return;
299
+ closed = true;
300
+ const completion = new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
301
+ server.closeAllConnections?.();
302
+ await completion;
303
+ },
304
+ });
305
+ }
306
+
307
+ export function bridgeRuntimeController({
308
+ env = process.env, instanceToken = null,
309
+ register = registerBridgeUpstream,
310
+ report = (line) => process.stderr.write(`${line}\n`),
311
+ readInterfaces = () => networkInterfaces(),
312
+ } = {}) {
313
+ let active = null;
314
+ let fingerprint = null;
315
+ // A fresh binding is exactly when the reverse proxy's literal may have gone
316
+ // stale, so that is when the bridge tells it where it now is. A registration
317
+ // failure is reported, never swallowed, and never takes the local bridge down:
318
+ // the bridge is still serving, only the published route is behind.
319
+ const announce = async (binding) => {
320
+ try {
321
+ const outcome = await register({ port: binding.port, env });
322
+ if (outcome.state !== 'not_configured') report(JSON.stringify(outcome));
323
+ } catch (error) {
324
+ report(JSON.stringify({ schema: 'lattice.bridge_registrar_result.v1', state: 'failed',
325
+ port: binding.port, host: null, remote: null,
326
+ detail: error?.message ?? 'registration failed' }));
327
+ }
328
+ };
329
+ return Object.freeze({
330
+ async reconcile() {
331
+ const config = await readBridgeConfig({ env });
332
+ const next = config === null || !config.enabled ? null : JSON.stringify(config);
333
+ // The config is not the only thing that can change under a live binding.
334
+ // A DHCP lease change removes the bound address from the host without
335
+ // touching a byte of config, and the socket lingers on an address nothing
336
+ // can route to. Resolve the effective address on every pass, from the
337
+ // interfaces as they are now, so the binding follows the host rather than
338
+ // only the file. One snapshot serves both the comparison and the bind, so
339
+ // a lease that changes mid-reconcile cannot land them on different
340
+ // addresses.
341
+ const interfaces = next === null ? null : readInterfaces();
342
+ const effective = next === null ? null
343
+ : resolveBridgeListenAddress({ configured: config.listen.address, interfaces }).effective;
344
+ if (next === fingerprint && (active === null || active.address === effective)) return active;
345
+ if (next === null) {
346
+ await active?.close();
347
+ active = null;
348
+ fingerprint = null;
349
+ return null;
350
+ }
351
+ // The binding is reused while it still serves the same configuration AND
352
+ // still sits on the address that configuration resolves to today. The
353
+ // effective address is stable for an unchanged host, so this compares
354
+ // equal on every quiet pass and only forces a rebind when the ground has
355
+ // actually moved.
356
+ if (active !== null && active.configured_address === config.listen.address
357
+ && active.port === config.listen.port && active.address === effective) {
358
+ active.updateConfig(config);
359
+ fingerprint = next;
360
+ return active;
361
+ }
362
+ const replacement = await startBridgeServer({ config, env, instanceToken, interfaces });
363
+ const previous = active;
364
+ active = replacement;
365
+ fingerprint = next;
366
+ await previous?.close();
367
+ await announce(active);
368
+ return active;
369
+ },
370
+ async close() {
371
+ await active?.close();
372
+ active = null;
373
+ fingerprint = null;
374
+ },
375
+ });
376
+ }