@harness-fe/mcp-server 4.0.0-next.2 → 4.0.0-next.3

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 (100) hide show
  1. package/dist/bin.d.ts +2 -0
  2. package/dist/bin.js +15 -0
  3. package/dist/daemon.d.ts +3 -3
  4. package/dist/daemon.js +1 -1
  5. package/dist/index.d.ts +4 -4
  6. package/dist/index.js +3 -3
  7. package/dist/mcp.d.ts +2 -2
  8. package/dist/mcp.js +42 -16
  9. package/dist/mcpHttp.d.ts +2 -2
  10. package/dist/mcpHttp.js +8 -2
  11. package/package.json +5 -7
  12. package/src/bin.ts +19 -0
  13. package/src/daemon.ts +3 -3
  14. package/src/experimental.test.ts +2 -2
  15. package/src/index.ts +4 -4
  16. package/src/mcp.ts +44 -20
  17. package/src/mcpHttp.test.ts +3 -3
  18. package/src/mcpHttp.ts +10 -4
  19. package/src/mcpLayer.e2e.test.ts +2 -2
  20. package/src/newCapabilities.e2e.test.ts +3 -3
  21. package/dist/auth.d.ts +0 -53
  22. package/dist/auth.js +0 -212
  23. package/dist/bridge.d.ts +0 -323
  24. package/dist/bridge.js +0 -1618
  25. package/dist/cli.d.ts +0 -18
  26. package/dist/cli.js +0 -293
  27. package/dist/dashboardApi.d.ts +0 -40
  28. package/dist/dashboardApi.js +0 -142
  29. package/dist/dashboardSpa.d.ts +0 -18
  30. package/dist/dashboardSpa.js +0 -180
  31. package/dist/dashboardUrl.d.ts +0 -13
  32. package/dist/dashboardUrl.js +0 -18
  33. package/dist/eventsHandler.d.ts +0 -24
  34. package/dist/eventsHandler.js +0 -114
  35. package/dist/identity.d.ts +0 -90
  36. package/dist/identity.js +0 -123
  37. package/dist/openBrowser.d.ts +0 -33
  38. package/dist/openBrowser.js +0 -63
  39. package/dist/remoteBridge.d.ts +0 -61
  40. package/dist/remoteBridge.js +0 -307
  41. package/dist/replayCreate.d.ts +0 -36
  42. package/dist/replayCreate.js +0 -156
  43. package/dist/replayViewer.d.ts +0 -20
  44. package/dist/replayViewer.js +0 -168
  45. package/dist/sessionRouter.d.ts +0 -45
  46. package/dist/sessionRouter.js +0 -88
  47. package/dist/store/JsonMemoryStore.d.ts +0 -52
  48. package/dist/store/JsonMemoryStore.js +0 -119
  49. package/dist/store/JsonTaskStore.d.ts +0 -21
  50. package/dist/store/JsonTaskStore.js +0 -53
  51. package/dist/store/JsonlStore.d.ts +0 -128
  52. package/dist/store/JsonlStore.js +0 -1172
  53. package/dist/store/MemoryEventStore.d.ts +0 -47
  54. package/dist/store/MemoryEventStore.js +0 -111
  55. package/dist/store/WriteQueue.d.ts +0 -51
  56. package/dist/store/WriteQueue.js +0 -142
  57. package/dist/store/index.d.ts +0 -6
  58. package/dist/store/index.js +0 -5
  59. package/dist/store/types.d.ts +0 -427
  60. package/dist/store/types.js +0 -19
  61. package/dist/visitorTimeline.d.ts +0 -24
  62. package/dist/visitorTimeline.js +0 -68
  63. package/src/auth.test.ts +0 -90
  64. package/src/auth.ts +0 -248
  65. package/src/bridge-auth.test.ts +0 -196
  66. package/src/bridge.test.ts +0 -1708
  67. package/src/bridge.ts +0 -1854
  68. package/src/cli.ts +0 -338
  69. package/src/dashboardApi.test.ts +0 -235
  70. package/src/dashboardApi.ts +0 -184
  71. package/src/dashboardSpa.test.ts +0 -239
  72. package/src/dashboardSpa.ts +0 -195
  73. package/src/dashboardUrl.test.ts +0 -46
  74. package/src/dashboardUrl.ts +0 -28
  75. package/src/eventsHandler.test.ts +0 -247
  76. package/src/eventsHandler.ts +0 -136
  77. package/src/identity.test.ts +0 -109
  78. package/src/identity.ts +0 -137
  79. package/src/openBrowser.test.ts +0 -103
  80. package/src/openBrowser.ts +0 -81
  81. package/src/remoteBridge.test.ts +0 -119
  82. package/src/remoteBridge.ts +0 -404
  83. package/src/replay.test.ts +0 -271
  84. package/src/replayCreate.ts +0 -194
  85. package/src/replayViewer.ts +0 -173
  86. package/src/sessionRouter.ts +0 -119
  87. package/src/store/JsonMemoryStore.test.ts +0 -175
  88. package/src/store/JsonMemoryStore.ts +0 -128
  89. package/src/store/JsonTaskStore.test.ts +0 -212
  90. package/src/store/JsonTaskStore.ts +0 -59
  91. package/src/store/JsonlStore.test.ts +0 -1538
  92. package/src/store/JsonlStore.ts +0 -1325
  93. package/src/store/MemoryEventStore.test.ts +0 -119
  94. package/src/store/MemoryEventStore.ts +0 -151
  95. package/src/store/WriteQueue.ts +0 -165
  96. package/src/store/identityTagging.test.ts +0 -67
  97. package/src/store/index.ts +0 -29
  98. package/src/store/types.ts +0 -532
  99. package/src/visitorTimeline.test.ts +0 -197
  100. package/src/visitorTimeline.ts +0 -89
package/src/auth.ts DELETED
@@ -1,248 +0,0 @@
1
- /**
2
- * Token-based auth for the bridge's HTTP + WS surfaces.
3
- *
4
- * Loopback (127.*, localhost, ::1) — auth disabled by default; the daemon
5
- * trusts everything that can reach the loopback socket. As soon as the
6
- * daemon is bound to a non-loopback host (e.g. 0.0.0.0 for LAN debugging),
7
- * the CLI requires a token and this module enforces it on every HTTP route
8
- * and WS upgrade.
9
- *
10
- * Why a single module: dashboard / replay viewer / events handler /
11
- * MCP HTTP transport all live behind the same bridge HTTP server. Bridge
12
- * wraps requests with `isAuthorized` once, so individual handlers never
13
- * see unauthenticated traffic and don't carry auth code.
14
- */
15
-
16
- import { createHash, timingSafeEqual } from 'node:crypto';
17
- import type { IncomingMessage, ServerResponse } from 'node:http';
18
-
19
- export const DEFAULT_COOKIE_NAME = 'harness_fe_token';
20
- export const DEFAULT_LOGIN_PATH = '/__auth';
21
- const WS_SUBPROTOCOL_PREFIX = 'harness-fe.token.';
22
-
23
- export interface AuthOptions {
24
- /** Expected token. Empty/undefined disables token auth. */
25
- token?: string;
26
- /**
27
- * Custom authorization predicate. When supplied, runs *instead of* the
28
- * token check on every HTTP request and WS upgrade. Synchronous: the
29
- * WS upgrade handshake completes inline. For host-injected auth that
30
- * needs an async lookup, cache the result in a cookie via the host's
31
- * own middleware and have `authorize` read the cookie.
32
- */
33
- authorize?: (req: IncomingMessage) => boolean;
34
- /** Cookie name set after a successful login. Default: harness_fe_token. */
35
- cookieName?: string;
36
- /** POST path that consumes the login form. Default: /__auth. */
37
- loginPath?: string;
38
- }
39
-
40
- export function isAuthEnabled(opts: AuthOptions): boolean {
41
- return !!(opts.token || opts.authorize);
42
- }
43
-
44
- /** Pull token from header / cookie / query / WS subprotocol (first match wins). */
45
- export function extractToken(req: IncomingMessage, opts: AuthOptions = {}): string | undefined {
46
- const auth = req.headers.authorization;
47
- if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
48
- const v = auth.slice(7).trim();
49
- if (v) return v;
50
- }
51
-
52
- const cookieName = opts.cookieName ?? DEFAULT_COOKIE_NAME;
53
- const cookies = parseCookieHeader(req.headers.cookie);
54
- if (cookies[cookieName]) return decodeURIComponent(cookies[cookieName]);
55
-
56
- const url = req.url ?? '';
57
- const qi = url.indexOf('?');
58
- if (qi >= 0) {
59
- const params = new URLSearchParams(url.slice(qi + 1));
60
- const t = params.get('token');
61
- if (t) return t;
62
- }
63
-
64
- const subproto = req.headers['sec-websocket-protocol'];
65
- if (typeof subproto === 'string') {
66
- for (const p of subproto.split(',')) {
67
- const trimmed = p.trim();
68
- if (trimmed.startsWith(WS_SUBPROTOCOL_PREFIX)) {
69
- return trimmed.slice(WS_SUBPROTOCOL_PREFIX.length);
70
- }
71
- }
72
- }
73
-
74
- return undefined;
75
- }
76
-
77
- /**
78
- * Constant-time token compare. Hashing both sides first means we always
79
- * compare equal-length buffers, sidestepping the length-leak that a raw
80
- * timingSafeEqual on user input would have.
81
- */
82
- export function verifyToken(provided: string | undefined, expected: string): boolean {
83
- if (!provided || !expected) return false;
84
- const a = createHash('sha256').update(provided).digest();
85
- const b = createHash('sha256').update(expected).digest();
86
- return timingSafeEqual(a, b);
87
- }
88
-
89
- /** True if request is allowed (auth disabled, custom predicate accepts, or token matches). */
90
- export function isAuthorized(req: IncomingMessage, opts: AuthOptions): boolean {
91
- if (!isAuthEnabled(opts)) return true;
92
- // Custom predicate wins when supplied. Hosts that embed the daemon pass
93
- // their own check here (e.g. JWT verification reading from a cookie).
94
- if (opts.authorize) return opts.authorize(req);
95
- return verifyToken(extractToken(req, opts), opts.token!);
96
- }
97
-
98
- /**
99
- * Write a 401 response. Browsers (Accept: text/html) get a minimal login
100
- * form they can post the token through; everything else gets JSON.
101
- */
102
- export function sendUnauthorized(
103
- req: IncomingMessage,
104
- res: ServerResponse,
105
- opts: AuthOptions,
106
- ): void {
107
- // Custom-authorize mode is for host apps that own their own login UX —
108
- // the built-in token form is never the right answer there. Always 401
109
- // as JSON and let the host redirect.
110
- const wantsLoginForm = !opts.authorize;
111
- const accept = (req.headers.accept ?? '').toLowerCase();
112
- const wantsHtml = accept.includes('text/html') && wantsLoginForm;
113
- if (wantsHtml) {
114
- res.statusCode = 401;
115
- res.setHeader('content-type', 'text/html; charset=utf-8');
116
- res.setHeader('cache-control', 'no-store');
117
- res.end(renderLoginPage(opts, req.url ?? '/'));
118
- return;
119
- }
120
- res.statusCode = 401;
121
- res.setHeader('content-type', 'application/json; charset=utf-8');
122
- res.setHeader('www-authenticate', 'Bearer realm="harness-fe"');
123
- res.end(
124
- JSON.stringify({
125
- error: 'unauthorized',
126
- message:
127
- 'Missing or invalid token. Provide Authorization: Bearer <token>, ?token=<token>, or the harness_fe_token cookie.',
128
- }),
129
- );
130
- }
131
-
132
- /**
133
- * Handle POST {loginPath}: read form body, verify token, set cookie, 303 → next.
134
- */
135
- export async function handleLoginPost(
136
- req: IncomingMessage,
137
- res: ServerResponse,
138
- opts: AuthOptions,
139
- ): Promise<void> {
140
- if (!isAuthEnabled(opts) || opts.authorize) {
141
- // Auth disabled, or the host owns auth via a custom predicate — the
142
- // built-in login form isn't meaningful here. Redirect home.
143
- res.statusCode = 303;
144
- res.setHeader('location', '/');
145
- res.end();
146
- return;
147
- }
148
-
149
- const chunks: Buffer[] = [];
150
- let total = 0;
151
- const MAX = 4096;
152
- for await (const c of req) {
153
- const buf = c as Buffer;
154
- total += buf.length;
155
- if (total > MAX) {
156
- res.statusCode = 413;
157
- res.setHeader('content-type', 'text/plain; charset=utf-8');
158
- res.end('payload too large');
159
- return;
160
- }
161
- chunks.push(buf);
162
- }
163
- const body = Buffer.concat(chunks).toString('utf8');
164
- const form = new URLSearchParams(body);
165
- const token = form.get('token') ?? '';
166
- const next = safeNext(form.get('next') ?? '/');
167
-
168
- if (!verifyToken(token, opts.token!)) {
169
- res.statusCode = 401;
170
- res.setHeader('content-type', 'text/html; charset=utf-8');
171
- res.setHeader('cache-control', 'no-store');
172
- res.end(renderLoginPage(opts, next, 'Invalid token. Try again.'));
173
- return;
174
- }
175
-
176
- const cookieName = opts.cookieName ?? DEFAULT_COOKIE_NAME;
177
- // 30 days. HttpOnly so JS can't read it; SameSite=Lax so cross-tab nav works.
178
- const cookie = `${cookieName}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`;
179
- res.statusCode = 303;
180
- res.setHeader('set-cookie', cookie);
181
- res.setHeader('location', next);
182
- res.end();
183
- }
184
-
185
- /**
186
- * Allow only same-origin relative paths as the post-login redirect. Anything
187
- * else degrades to "/" so a crafted form can't redirect to an external site.
188
- */
189
- function safeNext(next: string): string {
190
- if (typeof next !== 'string') return '/';
191
- if (!next.startsWith('/')) return '/';
192
- if (next.startsWith('//')) return '/';
193
- return next;
194
- }
195
-
196
- function parseCookieHeader(raw: string | undefined): Record<string, string> {
197
- if (!raw) return {};
198
- const out: Record<string, string> = {};
199
- for (const part of raw.split(';')) {
200
- const eq = part.indexOf('=');
201
- if (eq < 0) continue;
202
- const k = part.slice(0, eq).trim();
203
- const v = part.slice(eq + 1).trim();
204
- if (k) out[k] = v;
205
- }
206
- return out;
207
- }
208
-
209
- function escapeHtml(s: string): string {
210
- return s.replace(/[&<>"']/g, (c) => {
211
- switch (c) {
212
- case '&': return '&amp;';
213
- case '<': return '&lt;';
214
- case '>': return '&gt;';
215
- case '"': return '&quot;';
216
- default: return '&#39;';
217
- }
218
- });
219
- }
220
-
221
- function renderLoginPage(opts: AuthOptions, next: string, error?: string): string {
222
- const loginPath = opts.loginPath ?? DEFAULT_LOGIN_PATH;
223
- const safeN = escapeHtml(safeNext(next));
224
- const errBlock = error
225
- ? `<p style="color:#c0392b;margin:0 0 12px">${escapeHtml(error)}</p>`
226
- : '';
227
- return `<!doctype html>
228
- <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
229
- <title>harness-fe — sign in</title>
230
- <style>
231
- body{font:14px/1.4 -apple-system,BlinkMacSystemFont,system-ui,sans-serif;background:#fafafa;color:#222;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
232
- form{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:24px;max-width:360px;width:100%;box-shadow:0 4px 12px rgba(0,0,0,.04)}
233
- h1{font-size:16px;margin:0 0 12px}
234
- input[type=password]{display:block;width:100%;box-sizing:border-box;padding:10px;border:1px solid #d1d5db;border-radius:6px;font-size:14px;margin-bottom:12px}
235
- button{display:block;width:100%;padding:10px;background:#111;color:#fff;border:0;border-radius:6px;font-size:14px;cursor:pointer}
236
- .muted{color:#666;font-size:12px;margin-top:12px}
237
- </style></head>
238
- <body>
239
- <form method="post" action="${escapeHtml(loginPath)}" autocomplete="off">
240
- <h1>harness-fe</h1>
241
- ${errBlock}
242
- <input type="password" name="token" placeholder="token" autofocus required>
243
- <input type="hidden" name="next" value="${safeN}">
244
- <button type="submit">Sign in</button>
245
- <p class="muted">Paste the token from the daemon startup banner.</p>
246
- </form>
247
- </body></html>`;
248
- }
@@ -1,196 +0,0 @@
1
- import { afterEach, describe, expect, it } from 'vitest';
2
- import { WebSocket } from 'ws';
3
- import { Bridge } from './bridge.js';
4
-
5
- const cleanups: Array<() => Promise<void> | void> = [];
6
-
7
- afterEach(async () => {
8
- while (cleanups.length) {
9
- const fn = cleanups.shift();
10
- if (fn) await fn();
11
- }
12
- });
13
-
14
- async function startBridge(opts: Parameters<typeof Bridge.prototype.constructor>[0] = {}): Promise<{
15
- bridge: Bridge;
16
- baseUrl: string;
17
- }> {
18
- const bridge = new Bridge({
19
- port: 0,
20
- host: '127.0.0.1',
21
- store: null,
22
- taskStore: null,
23
- autoPurge: { enabled: false },
24
- ...opts,
25
- });
26
- await bridge.start();
27
- cleanups.push(() => bridge.stop());
28
- const port = bridge.getBoundPort()!;
29
- return { bridge, baseUrl: `http://127.0.0.1:${port}` };
30
- }
31
-
32
- async function request(
33
- url: string,
34
- init: { method?: string; headers?: Record<string, string>; body?: string } = {},
35
- ): Promise<{ status: number; headers: Record<string, string>; text: string }> {
36
- const res = await fetch(url, init);
37
- const headers: Record<string, string> = {};
38
- res.headers.forEach((v, k) => {
39
- headers[k] = v;
40
- });
41
- const text = await res.text();
42
- return { status: res.status, headers, text };
43
- }
44
-
45
- describe('Bridge — token auth on HTTP routes', () => {
46
- it('serves requests without token when auth disabled', async () => {
47
- const { baseUrl } = await startBridge();
48
- const res = await request(baseUrl + '/');
49
- // dashboard isn't wired (store=null), so 404 — but it's a 404 from
50
- // the bridge handler, not 401.
51
- expect(res.status).toBe(404);
52
- });
53
-
54
- it('returns 401 JSON to API clients when token missing', async () => {
55
- const { baseUrl } = await startBridge({ auth: { token: 's3cret' } });
56
- const res = await request(baseUrl + '/');
57
- expect(res.status).toBe(401);
58
- expect(res.headers['www-authenticate']).toContain('Bearer');
59
- expect(res.headers['content-type']).toContain('application/json');
60
- });
61
-
62
- it('returns HTML login page to browsers', async () => {
63
- const { baseUrl } = await startBridge({ auth: { token: 's3cret' } });
64
- const res = await request(baseUrl + '/', { headers: { accept: 'text/html' } });
65
- expect(res.status).toBe(401);
66
- expect(res.headers['content-type']).toContain('text/html');
67
- expect(res.text).toContain('<form');
68
- expect(res.text).toContain('name="token"');
69
- });
70
-
71
- it('accepts request with valid Bearer token', async () => {
72
- const { baseUrl } = await startBridge({ auth: { token: 's3cret' } });
73
- const res = await request(baseUrl + '/', { headers: { authorization: 'Bearer s3cret' } });
74
- // Falls through to 404 (no httpHandler since store=null) — but NOT 401.
75
- expect(res.status).not.toBe(401);
76
- });
77
-
78
- it('accepts request with valid cookie', async () => {
79
- const { baseUrl } = await startBridge({ auth: { token: 's3cret' } });
80
- const res = await request(baseUrl + '/', { headers: { cookie: 'harness_fe_token=s3cret' } });
81
- expect(res.status).not.toBe(401);
82
- });
83
-
84
- it('accepts request with valid ?token= query', async () => {
85
- const { baseUrl } = await startBridge({ auth: { token: 's3cret' } });
86
- const res = await request(baseUrl + '/?token=s3cret');
87
- expect(res.status).not.toBe(401);
88
- });
89
-
90
- it('POST /__auth with valid token sets cookie and 303-redirects', async () => {
91
- const { baseUrl } = await startBridge({ auth: { token: 's3cret' } });
92
- const res = await fetch(baseUrl + '/__auth', {
93
- method: 'POST',
94
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
95
- body: 'token=s3cret&next=/dashboard',
96
- redirect: 'manual',
97
- });
98
- expect(res.status).toBe(303);
99
- const setCookie = res.headers.get('set-cookie') ?? '';
100
- expect(setCookie).toMatch(/harness_fe_token=s3cret/);
101
- expect(setCookie).toMatch(/HttpOnly/);
102
- expect(setCookie).toMatch(/SameSite=Lax/);
103
- expect(res.headers.get('location')).toBe('/dashboard');
104
- });
105
-
106
- it('POST /__auth with wrong token re-renders login with error', async () => {
107
- const { baseUrl } = await startBridge({ auth: { token: 's3cret' } });
108
- const res = await fetch(baseUrl + '/__auth', {
109
- method: 'POST',
110
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
111
- body: 'token=wrong&next=/',
112
- redirect: 'manual',
113
- });
114
- expect(res.status).toBe(401);
115
- const text = await res.text();
116
- expect(text).toContain('Invalid token');
117
- });
118
-
119
- it('POST /__auth ignores external "next" — falls back to /', async () => {
120
- const { baseUrl } = await startBridge({ auth: { token: 's3cret' } });
121
- const res = await fetch(baseUrl + '/__auth', {
122
- method: 'POST',
123
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
124
- body: 'token=s3cret&next=//evil.example/',
125
- redirect: 'manual',
126
- });
127
- expect(res.status).toBe(303);
128
- expect(res.headers.get('location')).toBe('/');
129
- });
130
- });
131
-
132
- describe('Bridge — WS upgrade auth', () => {
133
- it('rejects WS upgrade when token missing', async () => {
134
- const { bridge } = await startBridge({ auth: { token: 's3cret' } });
135
- const wsUrl = `ws://127.0.0.1:${bridge.getBoundPort()}/`;
136
- const err: { code?: string; message?: string } = await new Promise((resolve) => {
137
- const ws = new WebSocket(wsUrl);
138
- ws.once('error', (e: NodeJS.ErrnoException) => resolve({ code: e.code, message: e.message }));
139
- ws.once('open', () => {
140
- ws.close();
141
- resolve({ message: 'unexpectedly opened' });
142
- });
143
- });
144
- // ws library surfaces the 401 as "Unexpected server response: 401".
145
- expect(err.message ?? '').toMatch(/401|Unauthorized|unexpected/i);
146
- });
147
-
148
- it('accepts WS upgrade with valid Bearer header', async () => {
149
- const { bridge } = await startBridge({ auth: { token: 's3cret' } });
150
- const wsUrl = `ws://127.0.0.1:${bridge.getBoundPort()}/`;
151
- const opened: boolean = await new Promise((resolve) => {
152
- const ws = new WebSocket(wsUrl, { headers: { authorization: 'Bearer s3cret' } });
153
- const t = setTimeout(() => resolve(false), 2000);
154
- ws.once('open', () => {
155
- clearTimeout(t);
156
- ws.close();
157
- resolve(true);
158
- });
159
- ws.once('error', () => {
160
- clearTimeout(t);
161
- resolve(false);
162
- });
163
- });
164
- expect(opened).toBe(true);
165
- });
166
-
167
- it('accepts WS upgrade with valid ?token= query', async () => {
168
- const { bridge } = await startBridge({ auth: { token: 's3cret' } });
169
- const wsUrl = `ws://127.0.0.1:${bridge.getBoundPort()}/?token=s3cret`;
170
- const opened: boolean = await new Promise((resolve) => {
171
- const ws = new WebSocket(wsUrl);
172
- const t = setTimeout(() => resolve(false), 2000);
173
- ws.once('open', () => {
174
- clearTimeout(t);
175
- ws.close();
176
- resolve(true);
177
- });
178
- ws.once('error', () => {
179
- clearTimeout(t);
180
- resolve(false);
181
- });
182
- });
183
- expect(opened).toBe(true);
184
- });
185
- });
186
-
187
- describe('Bridge — viewer base URL with non-loopback host', () => {
188
- it('rewrites 0.0.0.0 binds to a routable LAN IP for outbound URLs', async () => {
189
- const { bridge } = await startBridge({ host: '0.0.0.0', auth: { token: 'x' } });
190
- const url = bridge.getViewerBaseUrl();
191
- expect(url).toBeDefined();
192
- // Either a real LAN IP, or 127.0.0.1 if the test machine has no
193
- // non-internal interfaces — never the literal 0.0.0.0.
194
- expect(url).not.toContain('0.0.0.0');
195
- });
196
- });