@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
@@ -0,0 +1,544 @@
1
+ /**
2
+ * Bridge hub HTTP server (bh2) — the multi-terminal aggregator that fronts
3
+ * several `lattice bridge` instances behind a single public origin.
4
+ *
5
+ * This module wires I/O (sockets, disk, the clock) around the pure contract
6
+ * in `bridge-hub-protocol.mjs` (bh1). It does not reinvent registration,
7
+ * heartbeat, staleness, or conflict semantics — it only supplies the parts
8
+ * the contract deliberately does not own: an HTTP surface, a locked file
9
+ * store, and a reverse proxy to the owning terminal's bridge.
10
+ *
11
+ * Endpoints:
12
+ * - `POST /__lattice/hub/register` — a terminal's registration/heartbeat call.
13
+ * - `GET /projects/` — the aggregate listing across all registered terminals.
14
+ * - `* /projects/<project_id>/*` — reverse proxy to the owning terminal's
15
+ * bridge, or a typed 404/503 when the project is unknown or offline.
16
+ *
17
+ * Safety posture mirrors `bridge-server.mjs` (Host allow-list, hop-by-hop
18
+ * header stripping, Forwarded header regeneration, origin-form request
19
+ * target validation, streaming proxy via `.pipe()` so SSE is never
20
+ * buffered) without importing from it — `bridge-server.mjs` is owned by a
21
+ * concurrent task and this module must not couple to it mid-flight.
22
+ */
23
+
24
+ import { createServer, request as httpRequest } from 'node:http';
25
+ import { isIP } from 'node:net';
26
+ import { randomBytes } from 'node:crypto';
27
+ import { homedir } from 'node:os';
28
+ import {
29
+ mkdir, open, readFile, rename, rm, writeFile,
30
+ } from 'node:fs/promises';
31
+ import path from 'node:path';
32
+ import { domainToASCII } from 'node:url';
33
+
34
+ import {
35
+ applyBridgeHubRegistration, BRIDGE_HUB_HEARTBEAT_TTL_MS, BridgeHubProtocolError,
36
+ projectBridgeHubRegistry, validateBridgeHubRegistryEntry,
37
+ } from './bridge-hub-protocol.mjs';
38
+
39
+ const LOOPBACK = '127.0.0.1';
40
+ const HUB_HTTP_ERROR_SCHEMA = 'lattice.bridge_hub_http_error.v1';
41
+ const HUB_REGISTRY_DOCUMENT_SCHEMA = 'lattice.bridge_hub_registry_document.v1';
42
+ const HUB_PUBLIC_PROJECT_SCHEMA = 'lattice.bridge_hub_public_project.v1';
43
+ const MAX_REGISTRATION_BODY_BYTES = 65_536;
44
+ const LOCK_ATTEMPTS = 240;
45
+ const LOCK_WAIT_MS = 25;
46
+ const LOCK_STALE_MS = 30_000;
47
+ const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store',
48
+ 'x-content-type-options': 'nosniff' };
49
+ const HOP_BY_HOP = new Set([
50
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
51
+ 'te', 'trailer', 'transfer-encoding', 'upgrade',
52
+ ]);
53
+ const UNTRUSTED_CLIENT_IP_HEADERS = new Set([
54
+ 'cf-connecting-ip', 'client-ip', 'fastly-client-ip', 'true-client-ip',
55
+ 'x-cluster-client-ip', 'x-proxyuser-ip',
56
+ ]);
57
+ const PROJECT_ROUTE = /^\/projects\/([^/]+)(?:\/.*)?$/u;
58
+
59
+ export class BridgeHubServerError extends Error {
60
+ constructor(code, message, detail = undefined, cause = undefined) {
61
+ super(message, { cause });
62
+ this.name = 'BridgeHubServerError';
63
+ this.code = code;
64
+ if (detail !== undefined) this.detail = detail;
65
+ }
66
+ }
67
+
68
+ function escapeHtml(value) {
69
+ return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;')
70
+ .replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;');
71
+ }
72
+
73
+ // --- Host allow-list validation (equivalent to bridge-server.mjs's
74
+ // validatedRequestHost/normalizeBridgeAllowedHost, reimplemented locally so
75
+ // this module does not depend on a file a concurrent task is editing). ---
76
+
77
+ function normalizeHubAllowedHost(value) {
78
+ if (typeof value !== 'string' || value.length === 0 || value !== value.trim()) {
79
+ throw new BridgeHubServerError('BRIDGE_HOST_INVALID', 'allowed host is invalid');
80
+ }
81
+ if (isIP(value) !== 0) return value.toLowerCase();
82
+ const withoutDot = value.endsWith('.') ? value.slice(0, -1) : value;
83
+ const ascii = domainToASCII(withoutDot).toLowerCase();
84
+ if (ascii.length === 0 || ascii.length > 253
85
+ || !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(ascii)
86
+ || ascii.split('.').some((label) => label.length === 0 || label.length > 63
87
+ || label.startsWith('-') || label.endsWith('-'))) {
88
+ throw new BridgeHubServerError('BRIDGE_HOST_INVALID', 'allowed host is invalid');
89
+ }
90
+ return ascii;
91
+ }
92
+
93
+ function validatedHubHost(value) {
94
+ if (typeof value !== 'string' || value.length === 0 || value.length > 512
95
+ || /[\s\\/@,]/u.test(value)) {
96
+ throw new BridgeHubServerError('BRIDGE_HOST_INVALID', 'request Host is invalid');
97
+ }
98
+ let parsed;
99
+ try { parsed = new URL(`http://${value}`); } catch {
100
+ throw new BridgeHubServerError('BRIDGE_HOST_INVALID', 'request Host is invalid');
101
+ }
102
+ if (parsed.username !== '' || parsed.password !== '' || parsed.pathname !== '/'
103
+ || parsed.search !== '' || parsed.hash !== '') {
104
+ throw new BridgeHubServerError('BRIDGE_HOST_INVALID', 'request Host is invalid');
105
+ }
106
+ const rawHostname = parsed.hostname.startsWith('[') && parsed.hostname.endsWith(']')
107
+ ? parsed.hostname.slice(1, -1) : parsed.hostname;
108
+ const hostname = normalizeHubAllowedHost(rawHostname);
109
+ const authorityHost = hostname.includes(':') ? `[${hostname}]` : hostname;
110
+ return { hostname, authority: parsed.port === '' ? authorityHost : `${authorityHost}:${parsed.port}` };
111
+ }
112
+
113
+ // --- Origin-form request-target validation (equivalent to bridge-server.mjs's
114
+ // upstreamUrl guard against absolute-form targets, encoded path bytes, and
115
+ // dot-segment escapes). Applied to every route, not only the proxy path. ---
116
+
117
+ function validatedHubRequestTarget(requestUrl) {
118
+ if (typeof requestUrl !== 'string' || requestUrl.includes('#') || !/^\/(?!\/)[^\s]*$/u.test(requestUrl)) {
119
+ throw new BridgeHubServerError('BRIDGE_HUB_REQUEST_TARGET_INVALID', 'hub requires an origin-form request target');
120
+ }
121
+ const rawPath = requestUrl.split('?', 1)[0];
122
+ if (rawPath.includes('\\') || rawPath.includes('%')) {
123
+ throw new BridgeHubServerError('BRIDGE_HUB_REQUEST_TARGET_INVALID',
124
+ 'hub request target contains encoded path bytes or a backslash');
125
+ }
126
+ if (rawPath.split('/').some((segment) => segment === '.' || segment === '..')) {
127
+ throw new BridgeHubServerError('BRIDGE_HUB_REQUEST_TARGET_INVALID', 'hub request target contains a dot segment');
128
+ }
129
+ return requestUrl;
130
+ }
131
+
132
+ // --- Proxy header handling (equivalent to bridge-server.mjs's
133
+ // forwardHeaders/forwardedHeaders: strip hop-by-hop and untrusted
134
+ // client-ip headers, then regenerate Forwarded/X-Forwarded-* from the
135
+ // connection hub itself observed). ---
136
+
137
+ function hubConnectionTokens(headers) {
138
+ const value = headers.connection;
139
+ return new Set((Array.isArray(value) ? value.join(',') : value ?? '')
140
+ .split(',').map((entry) => entry.trim().toLowerCase()).filter(Boolean));
141
+ }
142
+
143
+ function hubForwardHeaders(headers) {
144
+ const nominated = hubConnectionTokens(headers);
145
+ return Object.fromEntries(Object.entries(headers)
146
+ .filter(([name, value]) => value !== undefined && name !== 'host'
147
+ && name.toLowerCase() !== 'forwarded' && name.toLowerCase() !== 'x-real-ip'
148
+ && !name.toLowerCase().startsWith('x-forwarded-')
149
+ && !UNTRUSTED_CLIENT_IP_HEADERS.has(name.toLowerCase())
150
+ && !HOP_BY_HOP.has(name.toLowerCase()) && !nominated.has(name.toLowerCase())));
151
+ }
152
+
153
+ function hubForwardedHeaders(incoming, host) {
154
+ const remote = incoming.socket.remoteAddress ?? 'unknown';
155
+ const forwardedFor = remote.includes(':') ? `"[${remote}]"` : remote;
156
+ return {
157
+ forwarded: `for=${forwardedFor};host="${host.authority}";proto=http`,
158
+ 'x-forwarded-for': remote,
159
+ 'x-forwarded-host': host.authority,
160
+ 'x-forwarded-proto': 'http',
161
+ 'x-real-ip': remote,
162
+ };
163
+ }
164
+
165
+ // --- Response rendering. /projects/ listing negotiates JSON on an explicit
166
+ // `Accept: application/json` and defaults to HTML (spec: humans browsing the
167
+ // index). The per-project 404/503 negotiate the other way — JSON unless
168
+ // `Accept` explicitly asks for text/html — matching todo-gantt-live.mjs's
169
+ // notFoundHtml convention, since a bridge/monitoring client is the more
170
+ // likely caller there. ---
171
+
172
+ function respondError(response, status, code, detail = null) {
173
+ if (response.headersSent) { response.destroy(); return; }
174
+ const body = detail === null ? { schema: HUB_HTTP_ERROR_SCHEMA, code } : { schema: HUB_HTTP_ERROR_SCHEMA, code, detail };
175
+ response.writeHead(status, JSON_HEADERS);
176
+ response.end(`${JSON.stringify(body)}\n`);
177
+ }
178
+
179
+ function hubIndexHtml(view) {
180
+ const rows = view.map((project) => {
181
+ const href = `/projects/${encodeURIComponent(project.project_id)}/`;
182
+ const statusLabel = project.status === 'online' ? 'オンライン' : 'オフライン';
183
+ const identity = project.display_name === project.project_id ? '' : `<code>${escapeHtml(project.project_id)}</code>`;
184
+ return `<li><a href="${escapeHtml(href)}"><strong>${escapeHtml(project.display_name)}</strong>`
185
+ + `${identity}<span>${escapeHtml(statusLabel)}</span></a></li>`;
186
+ }).join('');
187
+ const content = rows.length === 0 ? '<p>登録されているプロジェクトはありません。</p>' : `<ul>${rows}</ul>`;
188
+ return `<!doctype html><html lang="ja"><head><meta charset="utf-8">`
189
+ + `<meta name="viewport" content="width=device-width,initial-scale=1">`
190
+ + `<title>登録済みプロジェクト — Lattice hub</title></head>`
191
+ + `<body><h1>登録済みプロジェクト</h1>${content}</body></html>`;
192
+ }
193
+
194
+ function hubProjectStatusHtml(code, projectId, requestPath, message) {
195
+ return `<!doctype html><html lang="ja"><head><meta charset="utf-8">`
196
+ + `<title>${escapeHtml(code)} — Lattice hub</title></head>`
197
+ + `<body><h1>${escapeHtml(message)}</h1><p>project_id: ${escapeHtml(projectId)}</p>`
198
+ + `<code>${escapeHtml(requestPath)}</code></body></html>`;
199
+ }
200
+
201
+ function respondProjectStatus(incoming, response, status, code, projectId, requestPath, message) {
202
+ const accept = String(incoming.headers.accept ?? '').toLowerCase();
203
+ if (!accept.includes('text/html')) {
204
+ response.writeHead(status, JSON_HEADERS);
205
+ response.end(`${JSON.stringify({ schema: HUB_HTTP_ERROR_SCHEMA, code, project_id: projectId, message })}\n`);
206
+ return;
207
+ }
208
+ const html = hubProjectStatusHtml(code, projectId, requestPath, message);
209
+ response.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store',
210
+ 'x-content-type-options': 'nosniff' });
211
+ response.end(html);
212
+ }
213
+
214
+ // --- A single async mutex guarding the registration critical section
215
+ // (read -> apply -> write) against two concurrent registration requests
216
+ // racing each other's read before either has written, which would lose one
217
+ // terminal's update. File-level durability (atomic rename, 0600, a lock
218
+ // file) is a separate concern owned by writeBridgeHubRegistry below. ---
219
+
220
+ function createMutex() {
221
+ let queue = Promise.resolve();
222
+ return (task) => {
223
+ const run = queue.then(task, task);
224
+ queue = run.then(() => {}, () => {});
225
+ return run;
226
+ };
227
+ }
228
+
229
+ function readRequestBody(incoming, maxBytes) {
230
+ return new Promise((resolve, reject) => {
231
+ const chunks = [];
232
+ let total = 0;
233
+ let settled = false;
234
+ const onData = (chunk) => {
235
+ if (settled) return;
236
+ total += chunk.length;
237
+ if (total > maxBytes) {
238
+ settled = true;
239
+ incoming.off('data', onData);
240
+ incoming.resume();
241
+ reject(new BridgeHubServerError('BRIDGE_HUB_REQUEST_BODY_TOO_LARGE', 'registration request body exceeds limit'));
242
+ return;
243
+ }
244
+ chunks.push(chunk);
245
+ };
246
+ incoming.on('data', onData);
247
+ incoming.once('end', () => { if (!settled) { settled = true; resolve(Buffer.concat(chunks)); } });
248
+ incoming.once('error', (error) => { if (!settled) { settled = true; reject(error); } });
249
+ });
250
+ }
251
+
252
+ // === File-based registry persistence ===
253
+ // Mirrors todo-dashboard-registry.mjs's withLock/atomicJson pattern
254
+ // (temp-file write + rename, 0600 mode, PID-liveness lock with stale
255
+ // takeover) without importing it — that module's `withLock` is private, and
256
+ // this hub registry lives in its own runtime directory keyed by
257
+ // LATTICE_HUB_RUNTIME_DIR rather than LATTICE_DASHBOARD_RUNTIME_DIR.
258
+
259
+ function hubRuntimeDir(env) {
260
+ const configured = env.LATTICE_HUB_RUNTIME_DIR;
261
+ return typeof configured === 'string' && path.isAbsolute(configured)
262
+ ? configured : path.join(homedir(), '.lattice', 'hub');
263
+ }
264
+
265
+ function hubRuntimePaths(env) {
266
+ const root = hubRuntimeDir(env);
267
+ return { root, registry: path.join(root, 'terminals.json'), lock: path.join(root, 'registry.lock') };
268
+ }
269
+
270
+ async function readJsonDocument(ref, missing) {
271
+ let bytes;
272
+ try { bytes = await readFile(ref, 'utf8'); } catch (error) {
273
+ if (error?.code === 'ENOENT') return missing;
274
+ throw error;
275
+ }
276
+ try { return JSON.parse(bytes); } catch {
277
+ throw new BridgeHubServerError('BRIDGE_HUB_REGISTRY_FILE_INVALID', `hub registry JSON is invalid: ${ref}`);
278
+ }
279
+ }
280
+
281
+ async function atomicJson(ref, value) {
282
+ const temporary = `${ref}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
283
+ await writeFile(temporary, `${JSON.stringify(value)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
284
+ await rename(temporary, ref);
285
+ }
286
+
287
+ async function withFileLock(lockRef, action) {
288
+ for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt += 1) {
289
+ let handle;
290
+ try {
291
+ handle = await open(lockRef, 'wx', 0o600);
292
+ await handle.writeFile(`${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`);
293
+ try { return await action(); } finally {
294
+ await handle.close();
295
+ await rm(lockRef, { force: true });
296
+ }
297
+ } catch (error) {
298
+ if (error?.code !== 'EEXIST') throw error;
299
+ try {
300
+ const lock = JSON.parse(await readFile(lockRef, 'utf8'));
301
+ let alive = Number.isSafeInteger(lock.pid) && lock.pid > 0;
302
+ if (alive) { try { process.kill(lock.pid, 0); } catch { alive = false; } }
303
+ if (!alive || Date.now() - Date.parse(lock.created_at) > LOCK_STALE_MS) {
304
+ await rm(lockRef, { force: true });
305
+ continue;
306
+ }
307
+ } catch (lockError) {
308
+ if (lockError?.code === 'ENOENT') continue;
309
+ }
310
+ await new Promise((resolve) => setTimeout(resolve, LOCK_WAIT_MS));
311
+ }
312
+ }
313
+ throw new BridgeHubServerError('BRIDGE_HUB_REGISTRY_BUSY', 'hub registry lock timed out');
314
+ }
315
+
316
+ function validRegistryDocument(value) {
317
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
318
+ && value.schema === HUB_REGISTRY_DOCUMENT_SCHEMA && Array.isArray(value.entries)
319
+ && value.entries.every(validateBridgeHubRegistryEntry);
320
+ }
321
+
322
+ /** Read the persisted registry. A missing file is an empty registry, not an error. */
323
+ export async function readBridgeHubRegistry({ env = process.env } = {}) {
324
+ const refs = hubRuntimePaths(env);
325
+ const document = await readJsonDocument(refs.registry, { schema: HUB_REGISTRY_DOCUMENT_SCHEMA, entries: [] });
326
+ if (!validRegistryDocument(document)) {
327
+ throw new BridgeHubServerError('BRIDGE_HUB_REGISTRY_FILE_INVALID', 'hub registry file is invalid');
328
+ }
329
+ return document.entries;
330
+ }
331
+
332
+ /** Persist a registry snapshot atomically (temp file + rename, 0600, locked). */
333
+ export async function writeBridgeHubRegistry({ env = process.env, entries }) {
334
+ if (!Array.isArray(entries) || !entries.every(validateBridgeHubRegistryEntry)) {
335
+ throw new BridgeHubServerError('BRIDGE_HUB_REGISTRY_FILE_INVALID', 'hub registry entries are invalid');
336
+ }
337
+ const refs = hubRuntimePaths(env);
338
+ await mkdir(refs.root, { recursive: true, mode: 0o700 });
339
+ await withFileLock(refs.lock, async () => {
340
+ await atomicJson(refs.registry, { schema: HUB_REGISTRY_DOCUMENT_SCHEMA, entries });
341
+ });
342
+ }
343
+
344
+ // === HTTP server ===
345
+
346
+ export async function startBridgeHubServer({
347
+ registryStore, port = 0, allowedHosts, env = process.env, fetchImpl = fetch,
348
+ now = () => new Date(), ttlMs = BRIDGE_HUB_HEARTBEAT_TTL_MS, listenAddress = LOOPBACK,
349
+ } = {}) {
350
+ // fetchImpl is accepted for DI parity with the rest of this codebase's bridge
351
+ // modules (see bridge-server.mjs's resolveUpstream) but this server's proxy
352
+ // path streams via node:http (see handleProjectProxy) so SSE is never
353
+ // buffered; fetchImpl has no current call site here.
354
+ void fetchImpl;
355
+ if (!(allowedHosts instanceof Set) || allowedHosts.size === 0) {
356
+ throw new BridgeHubServerError('BRIDGE_HUB_CONFIG_INVALID', 'allowedHosts must be a non-empty Set');
357
+ }
358
+ // Defaults to loopback, matching every other bridge/dashboard server in this
359
+ // codebase. bh5 needs this configurable: on the deployment host, Caddy runs
360
+ // in a Docker bridge network and can only reach the host's docker-bridge
361
+ // gateway address (e.g. 172.18.0.1), never a literal 127.0.0.1 bind — the
362
+ // same reason bridge-server.mjs's own listen address is configurable rather
363
+ // than hardcoded. The safety boundary stays `allowedHosts`, not the bind
364
+ // address; unlike bridge-config.mjs's DHCP-following `resolveBridgeListenAddress`,
365
+ // the hub's own host does not move, so that reconciliation machinery is not
366
+ // ported here.
367
+ if (typeof listenAddress !== 'string' || isIP(listenAddress) === 0) {
368
+ throw new BridgeHubServerError('BRIDGE_HUB_CONFIG_INVALID', 'listenAddress must be an IP literal');
369
+ }
370
+ const normalizedAllowedHosts = new Set([...allowedHosts].map(normalizeHubAllowedHost));
371
+ const store = registryStore ?? {
372
+ read: () => readBridgeHubRegistry({ env }),
373
+ write: (entries) => writeBridgeHubRegistry({ env, entries }),
374
+ };
375
+ const registrationLock = createMutex();
376
+
377
+ async function handleRegister(incoming, response) {
378
+ if (incoming.method !== 'POST') {
379
+ response.setHeader('allow', 'POST');
380
+ respondError(response, 405, 'BRIDGE_HUB_METHOD_NOT_ALLOWED');
381
+ return;
382
+ }
383
+ let body;
384
+ try { body = await readRequestBody(incoming, MAX_REGISTRATION_BODY_BYTES); } catch (error) {
385
+ respondError(response, error?.code === 'BRIDGE_HUB_REQUEST_BODY_TOO_LARGE' ? 413 : 400,
386
+ error?.code ?? 'BRIDGE_HUB_REQUEST_BODY_INVALID');
387
+ return;
388
+ }
389
+ let request;
390
+ try { request = JSON.parse(body.toString('utf8')); } catch {
391
+ respondError(response, 400, 'BRIDGE_HUB_REQUEST_BODY_INVALID');
392
+ return;
393
+ }
394
+ const remoteAddress = incoming.socket.remoteAddress;
395
+ if (typeof remoteAddress !== 'string' || remoteAddress.length === 0) {
396
+ respondError(response, 500, 'BRIDGE_HUB_REMOTE_ADDRESS_UNAVAILABLE');
397
+ return;
398
+ }
399
+ let result;
400
+ try {
401
+ result = await registrationLock(async () => {
402
+ const entries = await store.read();
403
+ const applied = applyBridgeHubRegistration({ registry: entries, request, remoteAddress, now: now() });
404
+ await store.write(applied.registry);
405
+ return applied.result;
406
+ });
407
+ } catch (error) {
408
+ if (error instanceof BridgeHubProtocolError) {
409
+ const status = error.code === 'BRIDGE_HUB_PROJECT_CONFLICT' ? 409
410
+ : error.code === 'BRIDGE_HUB_REGISTRATION_INVALID' ? 400 : 500;
411
+ respondError(response, status, error.code, error.detail ?? null);
412
+ return;
413
+ }
414
+ respondError(response, 500, error?.code ?? 'BRIDGE_HUB_REGISTRATION_FAILED');
415
+ return;
416
+ }
417
+ response.writeHead(200, JSON_HEADERS);
418
+ response.end(`${JSON.stringify(result)}\n`);
419
+ }
420
+
421
+ async function handleProjectsIndex(incoming, response) {
422
+ if (incoming.method !== 'GET') {
423
+ response.setHeader('allow', 'GET');
424
+ respondError(response, 405, 'BRIDGE_HUB_METHOD_NOT_ALLOWED');
425
+ return;
426
+ }
427
+ const entries = await store.read();
428
+ const projected = projectBridgeHubRegistry({ registry: entries, now: now(), ttlMs });
429
+ // Only the fields humans need for the public index are exposed; internal
430
+ // routing fields (terminal_id, address, port) stay server-side, matching
431
+ // the codebase's existing posture of not leaking topology to public
432
+ // responses (bridge-server.mjs's public bridge-health strips pid/address
433
+ // the same way).
434
+ const view = projected.map((entry) => ({
435
+ schema: HUB_PUBLIC_PROJECT_SCHEMA,
436
+ project_id: entry.project_id,
437
+ display_name: entry.display_name,
438
+ status: entry.status,
439
+ last_seen_at: entry.last_seen_at,
440
+ }));
441
+ const accept = String(incoming.headers.accept ?? '').toLowerCase();
442
+ if (accept.includes('application/json')) {
443
+ response.writeHead(200, JSON_HEADERS);
444
+ response.end(`${JSON.stringify(view)}\n`);
445
+ return;
446
+ }
447
+ const html = hubIndexHtml(view);
448
+ response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store',
449
+ 'x-content-type-options': 'nosniff' });
450
+ response.end(html);
451
+ }
452
+
453
+ async function handleProjectProxy(incoming, response, requestUrl, encodedProjectId, host) {
454
+ let projectId;
455
+ try { projectId = decodeURIComponent(encodedProjectId); } catch {
456
+ respondError(response, 400, 'BRIDGE_HUB_PROJECT_ID_INVALID');
457
+ return;
458
+ }
459
+ const entries = await store.read();
460
+ const projected = projectBridgeHubRegistry({ registry: entries, now: now(), ttlMs });
461
+ const entry = projected.find((candidate) => candidate.project_id === projectId);
462
+ if (entry === undefined) {
463
+ respondProjectStatus(incoming, response, 404, 'BRIDGE_HUB_PROJECT_NOT_FOUND', projectId, requestUrl,
464
+ '指定されたプロジェクトはhubに登録されていません。');
465
+ return;
466
+ }
467
+ if (entry.status === 'offline') {
468
+ respondProjectStatus(incoming, response, 503, 'BRIDGE_HUB_PROJECT_OFFLINE', projectId, requestUrl,
469
+ '配信元の端末が現在オフラインです。heartbeatの再送または端末の起動状態を確認してください。');
470
+ return;
471
+ }
472
+ const targetHost = entry.address.includes(':') ? `[${entry.address}]` : entry.address;
473
+ const target = new URL(requestUrl, `http://${targetHost}:${entry.port}/`);
474
+ let upstreamResponse = null;
475
+ const proxyRequest = httpRequest(target, {
476
+ method: incoming.method,
477
+ headers: { ...hubForwardHeaders(incoming.headers), ...hubForwardedHeaders(incoming, host), host: target.host },
478
+ }, (incomingResponse) => {
479
+ upstreamResponse = incomingResponse;
480
+ response.writeHead(incomingResponse.statusCode ?? 502, hubForwardHeaders(incomingResponse.headers));
481
+ incomingResponse.once('error', () => response.destroy());
482
+ // Stream, do not buffer: this is the SSE-safety requirement (plan's
483
+ // "known trap" section). incomingResponse.pipe forwards each chunk as
484
+ // it arrives rather than waiting for the upstream response to end.
485
+ incomingResponse.pipe(response);
486
+ });
487
+ proxyRequest.once('error', (error) => respondError(response, 502,
488
+ error?.code === 'ECONNREFUSED' ? 'BRIDGE_HUB_UPSTREAM_REFUSED' : 'BRIDGE_HUB_PROXY_FAILED'));
489
+ incoming.once('aborted', () => proxyRequest.destroy());
490
+ response.once('close', () => {
491
+ if (!response.writableFinished) {
492
+ proxyRequest.destroy();
493
+ upstreamResponse?.destroy();
494
+ }
495
+ });
496
+ incoming.pipe(proxyRequest);
497
+ }
498
+
499
+ const handleRequest = async (incoming, response) => {
500
+ let host;
501
+ try { host = validatedHubHost(incoming.headers.host); } catch (error) {
502
+ respondError(response, 400, error?.code ?? 'BRIDGE_HOST_INVALID');
503
+ return;
504
+ }
505
+ if (!normalizedAllowedHosts.has(host.hostname)) {
506
+ respondError(response, 421, 'BRIDGE_HOST_NOT_ALLOWED');
507
+ return;
508
+ }
509
+ let requestUrl;
510
+ try { requestUrl = validatedHubRequestTarget(incoming.url); } catch (error) {
511
+ respondError(response, 400, error?.code ?? 'BRIDGE_HUB_REQUEST_TARGET_INVALID');
512
+ return;
513
+ }
514
+ const rawPath = requestUrl.split('?', 1)[0];
515
+ if (rawPath === '/__lattice/hub/register') { await handleRegister(incoming, response); return; }
516
+ if (rawPath === '/projects/') { await handleProjectsIndex(incoming, response); return; }
517
+ const match = PROJECT_ROUTE.exec(rawPath);
518
+ if (match !== null) { await handleProjectProxy(incoming, response, requestUrl, match[1], host); return; }
519
+ respondError(response, 404, 'BRIDGE_HUB_ROUTE_NOT_FOUND');
520
+ };
521
+
522
+ const server = createServer((incoming, response) => {
523
+ handleRequest(incoming, response).catch((error) => respondError(response, 500,
524
+ error?.code ?? 'BRIDGE_HUB_REQUEST_FAILED'));
525
+ });
526
+ await new Promise((resolve, reject) => {
527
+ server.once('error', reject);
528
+ server.listen({ host: listenAddress, port }, resolve);
529
+ });
530
+ const boundAddress = server.address();
531
+ const actualPort = typeof boundAddress === 'object' && boundAddress !== null ? boundAddress.port : port;
532
+ let closed = false;
533
+ return Object.freeze({
534
+ host: listenAddress,
535
+ port: actualPort,
536
+ close: async () => {
537
+ if (closed) return;
538
+ closed = true;
539
+ const completion = new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
540
+ server.closeAllConnections?.();
541
+ await completion;
542
+ },
543
+ });
544
+ }