@quolu/lattice 0.52.4 → 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 (59) 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/bridge-cli.mjs +11 -5
  55. package/src/bridge-config.mjs +41 -5
  56. package/src/bridge-hub-heartbeat.mjs +170 -0
  57. package/src/bridge-hub-server.mjs +544 -0
  58. package/src/cli-help.mjs +4 -4
  59. package/src/todo-store.mjs +1 -1
@@ -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
+ }
package/src/cli-help.mjs CHANGED
@@ -169,8 +169,8 @@ Commands:
169
169
  bridge: `Usage: lattice bridge <command> [options] --json
170
170
 
171
171
  Commands:
172
- setup --listen <IP> [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--allow-host <host>...]
173
- reconfigure [--listen <IP>] [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--allow-host <host>...]
172
+ setup --listen <IP> [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--hub <URL>|none] [--allow-host <host>...]
173
+ reconfigure [--listen <IP>] [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--hub <URL>|none] [--allow-host <host>...]
174
174
  status
175
175
  disable
176
176
  register # 現在のlisten portをreverse proxy hostへ自己登録する
@@ -266,8 +266,8 @@ const SUBCOMMAND_USAGE = Object.freeze({
266
266
  'runtime-errors resolve': 'runtime-errors resolve <fingerprint> --json',
267
267
  'runtime-errors reopen': 'runtime-errors reopen <fingerprint> --json',
268
268
  'runtime-errors compact': 'runtime-errors compact --json',
269
- 'bridge setup': 'bridge setup --listen <IP> [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--allow-host <host>...] --json',
270
- 'bridge reconfigure': 'bridge reconfigure [--listen <IP>] [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--allow-host <host>...] --json',
269
+ 'bridge setup': 'bridge setup --listen <IP> [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--hub <URL>|none] [--allow-host <host>...] --json',
270
+ 'bridge reconfigure': 'bridge reconfigure [--listen <IP>] [--port <49152..65535|auto>] [--dashboard|--upstream <URL>] [--hub <URL>|none] [--allow-host <host>...] --json',
271
271
  'bridge status': 'bridge status --json',
272
272
  'bridge disable': 'bridge disable --json',
273
273
  'bridge register': 'bridge register --json',
@@ -1063,7 +1063,7 @@ const EVIDENCE_BLOB_CACHE_LIMIT = 512;
1063
1063
  const evidenceBlobCache = new Map();
1064
1064
 
1065
1065
  function readEvidenceBlob(absoluteRepo, oid) {
1066
- const key = `${absoluteRepo}${oid}`;
1066
+ const key = `${absoluteRepo}\0${oid}`;
1067
1067
  const cached = evidenceBlobCache.get(key);
1068
1068
  if (cached !== undefined) return cached;
1069
1069
  const [entry] = gitCatFileBatch([oid], {