@lensmcp/bridge 1.18.2 → 1.18.4

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.
package/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { createBridgeServer, makePublisher, handleClientEnvelope, findFreePort, type BridgeServer, type BridgeServerOptions, type BridgeDeps, } from './lib/server.js';
2
2
  export { envelopeToEvent } from './lib/event-mappers.js';
3
+ export { redactUrl, redactText, redactDeep, isSecretParam, clip, safeText, safeTitle, REDACTED, MAX_TITLE_CHARS, MAX_MESSAGE_CHARS, MAX_URL_CHARS, } from './lib/redact.js';
3
4
  export { resolveEnvSink, resetEnvSinkCache, udsEnvSink } from './lib/env-sink.js';
4
5
  export { DEFAULT_WS_HOST, DEFAULT_WS_PORT, ENV_WS_HOST, ENV_WS_PORT, ENV_SESSION_ID, } from './lib/constants.js';
5
6
  export type { ClientEnvelope, EnvSink } from './lib/types.js';
6
- //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { createBridgeServer, makePublisher, handleClientEnvelope, findFreePort, } from './lib/server.js';
2
2
  export { envelopeToEvent } from './lib/event-mappers.js';
3
+ export { redactUrl, redactText, redactDeep, isSecretParam, clip, safeText, safeTitle, REDACTED, MAX_TITLE_CHARS, MAX_MESSAGE_CHARS, MAX_URL_CHARS, } from './lib/redact.js';
3
4
  export { resolveEnvSink, resetEnvSinkCache, udsEnvSink } from './lib/env-sink.js';
4
5
  export { DEFAULT_WS_HOST, DEFAULT_WS_PORT, ENV_WS_HOST, ENV_WS_PORT, ENV_SESSION_ID, } from './lib/constants.js';
@@ -22,4 +22,3 @@ export declare const ENV_WS_HOST = "LENSMCP_WS_HOST";
22
22
  export declare const ENV_WS_PORT = "LENSMCP_WS_PORT";
23
23
  /** Env var: session id stamped onto every event the bridge publishes. */
24
24
  export declare const ENV_SESSION_ID = "LENSMCP_SESSION_ID";
25
- //# sourceMappingURL=constants.d.ts.map
package/lib/env-sink.d.ts CHANGED
@@ -4,4 +4,3 @@ export declare function resolveEnvSink(): EnvSink | null;
4
4
  export declare function resetEnvSinkCache(): void;
5
5
  /** Reconnecting NDJSON client over a Unix domain socket (see session/uds.ts). */
6
6
  export declare function udsEnvSink(path: string): EnvSink;
7
- //# sourceMappingURL=env-sink.d.ts.map
@@ -5,6 +5,23 @@ import type { ClientEnvelope } from './types.js';
5
5
  * `BaseEvent` consumed by the LensMCP event bus. The bridge publishes
6
6
  * these; reducers in `@lensmcp/<app>` materialise them into
7
7
  * `runtime://browser/*` resources.
8
+ *
9
+ * ── Substance is the point ──────────────────────────────────────────────────
10
+ * This mapper decides what a reader of `runtime://browser/errors` can actually
11
+ * learn. Measured on foodguard (2026-07-30) an entry looked like:
12
+ *
13
+ * { id, timestamp, severity: 'error', source: 'client-runtime',
14
+ * fingerprint: 'runtime:52331dbd', tabId: 'tab-gal0r3bs',
15
+ * raw: { kind: 'error', at: 1785450203325, tabId: 'tab-gal0r3bs' } }
16
+ *
17
+ * — no message, no stack, no URL, no status. The resource could report that
18
+ * two errors happened and nothing whatsoever about what they were. A signal
19
+ * that is correct but undiagnosable is only half a signal, so every branch
20
+ * below now carries identity: a title that names the failure, a bounded
21
+ * message/stack, and for a request its method, URL and STATUS.
22
+ *
23
+ * Every string that reaches `title`/`message` is redacted and bounded first
24
+ * (`./redact.js`) — enrichment is exactly what creates the leak risk, so the
25
+ * two land together. See that module for what is and is not scrubbed.
8
26
  */
9
27
  export declare function envelopeToEvent(env: ClientEnvelope, sessionId?: string): BaseEvent;
10
- //# sourceMappingURL=event-mappers.d.ts.map
@@ -1,16 +1,48 @@
1
1
  import { fingerprint, ulid } from '@lensmcp/core';
2
+ import { redactDeep, redactText, redactUrl, safeText, safeTitle } from './redact.js';
2
3
  const SESSION_ID_PLACEHOLDER = 'pending';
3
4
  /**
4
5
  * Translate a single client-runtime envelope into the normalised
5
6
  * `BaseEvent` consumed by the LensMCP event bus. The bridge publishes
6
7
  * these; reducers in `@lensmcp/<app>` materialise them into
7
8
  * `runtime://browser/*` resources.
9
+ *
10
+ * ── Substance is the point ──────────────────────────────────────────────────
11
+ * This mapper decides what a reader of `runtime://browser/errors` can actually
12
+ * learn. Measured on foodguard (2026-07-30) an entry looked like:
13
+ *
14
+ * { id, timestamp, severity: 'error', source: 'client-runtime',
15
+ * fingerprint: 'runtime:52331dbd', tabId: 'tab-gal0r3bs',
16
+ * raw: { kind: 'error', at: 1785450203325, tabId: 'tab-gal0r3bs' } }
17
+ *
18
+ * — no message, no stack, no URL, no status. The resource could report that
19
+ * two errors happened and nothing whatsoever about what they were. A signal
20
+ * that is correct but undiagnosable is only half a signal, so every branch
21
+ * below now carries identity: a title that names the failure, a bounded
22
+ * message/stack, and for a request its method, URL and STATUS.
23
+ *
24
+ * Every string that reaches `title`/`message` is redacted and bounded first
25
+ * (`./redact.js`) — enrichment is exactly what creates the leak risk, so the
26
+ * two land together. See that module for what is and is not scrubbed.
8
27
  */
9
28
  export function envelopeToEvent(env, sessionId = SESSION_ID_PLACEHOLDER) {
10
29
  // Per-tab identity stamped by the client runtime — keys browser://tabs.
11
30
  const tab = env.tabId ? { tabId: env.tabId } : {};
12
31
  switch (env.kind) {
13
- case 'error':
32
+ case 'error': {
33
+ // A cross-origin script yields the opaque "Script error." with no stack
34
+ // and no file. Saying so is more use than a bare message, because the
35
+ // fix is a `crossorigin` attribute rather than anything in the app.
36
+ const opaque = !env.stack && !env.source && (!env.message || env.message === 'Script error.');
37
+ const name = env.name && env.name !== 'Error' ? `${env.name}: ` : '';
38
+ // Opaque FIRST: the browser's own "Script error." is a message, but a
39
+ // useless one — naming the cause points at the actual fix (a
40
+ // `crossorigin` attribute on the script tag), which the raw string does not.
41
+ const title = opaque
42
+ ? 'Uncaught cross-origin script error (no detail available — add crossorigin to the script tag)'
43
+ : env.message
44
+ ? `${name}${env.message}`
45
+ : `Uncaught ${env.name ?? 'error'}`;
14
46
  return {
15
47
  id: ulid(),
16
48
  sessionId,
@@ -23,16 +55,51 @@ export function envelopeToEvent(env, sessionId = SESSION_ID_PLACEHOLDER) {
23
55
  kind: 'runtime',
24
56
  identity: `error:${normaliseStackTopFrame(env.stack)}`,
25
57
  file: env.source,
26
- detail: env.message,
58
+ // The message is the discriminator between two errors thrown from
59
+ // the same frame. Without it every anonymous error collapsed into
60
+ // one fingerprint, so a count of them meant nothing.
61
+ detail: env.message ?? env.name,
27
62
  }),
28
- title: env.message,
29
- message: env.stack ?? env.message,
63
+ title: safeTitle(title),
64
+ message: safeText(env.stack ?? env.message),
30
65
  location: env.source
31
- ? { file: env.source, line: env.lineno, column: env.colno }
66
+ ? { file: redactUrl(env.source), line: env.lineno, column: env.colno }
32
67
  : undefined,
33
- raw: env,
68
+ // `raw` is read too — redact it as thoroughly as the rendered fields,
69
+ // or the credential just moves one key deeper.
70
+ raw: {
71
+ ...env,
72
+ ...(env.message ? { message: redactText(env.message) } : {}),
73
+ ...(env.stack ? { stack: safeText(env.stack) } : {}),
74
+ ...(env.source ? { source: redactUrl(env.source) } : {}),
75
+ },
76
+ };
77
+ }
78
+ case 'resource-error': {
79
+ const url = env.url ? redactUrl(env.url) : undefined;
80
+ return {
81
+ id: ulid(),
82
+ sessionId,
83
+ timestamp: env.at,
84
+ source: 'client-runtime',
85
+ category: 'runtime',
86
+ severity: 'error',
87
+ context: { sessionId, ...tab },
88
+ fingerprint: fingerprint({
89
+ kind: 'runtime',
90
+ // Path-normalised so a cache-busted asset does not mint a new
91
+ // fingerprint on every reload, while two different failing assets
92
+ // stay distinct.
93
+ identity: `resource-error:${env.tagName}:${normaliseUrl(env.url ?? '')}`,
94
+ }),
95
+ title: safeTitle(`Failed to load <${env.tagName}>${url ? ` ${url}` : ''}`),
96
+ ...(url ? { relatedUrls: [url] } : {}),
97
+ raw: { ...env, ...(url ? { url } : {}) },
34
98
  };
35
- case 'unhandledrejection':
99
+ }
100
+ case 'unhandledrejection': {
101
+ const name = env.name && env.name !== 'Error' ? `${env.name}: ` : '';
102
+ const summary = env.reason.split('\n')[0] ?? env.reason;
36
103
  return {
37
104
  id: ulid(),
38
105
  sessionId,
@@ -45,13 +112,27 @@ export function envelopeToEvent(env, sessionId = SESSION_ID_PLACEHOLDER) {
45
112
  kind: 'runtime',
46
113
  identity: `unhandledrejection:${env.reason.slice(0, 80)}`,
47
114
  }),
48
- title: 'Unhandled promise rejection',
49
- message: env.reason,
50
- raw: env,
115
+ // The reason belongs in the title. "Unhandled promise rejection" on
116
+ // its own is a category, not a finding — a reader had to open `raw` to
117
+ // learn anything, and the status-line summary showed only the category.
118
+ title: safeTitle(`Unhandled promise rejection: ${name}${summary}`),
119
+ message: safeText(env.stack ?? env.reason),
120
+ raw: {
121
+ ...env,
122
+ reason: redactText(env.reason),
123
+ ...(env.stack ? { stack: safeText(env.stack) } : {}),
124
+ },
51
125
  };
126
+ }
52
127
  case 'console': {
53
128
  const severity = env.level === 'error' ? 'error' : env.level === 'warn' ? 'warning' : 'info';
54
- const text = env.args.map(stringify).join(' ');
129
+ // Redact the args FIRST, then join. Joining first and scrubbing the
130
+ // result only catches credential SHAPES (a JWT, a `Bearer …`) — it
131
+ // cannot know that a key literally named `apiKey` holds one, so
132
+ // `console.log({ apiKey })` leaked into `message` while `raw.args` was
133
+ // correctly redacted. Redacting at the structured end catches both.
134
+ const args = redactDeep(env.args);
135
+ const text = args.map(stringify).join(' ');
55
136
  return {
56
137
  id: ulid(),
57
138
  sessionId,
@@ -65,12 +146,18 @@ export function envelopeToEvent(env, sessionId = SESSION_ID_PLACEHOLDER) {
65
146
  identity: `console:${env.level}:${text.slice(0, 80)}`,
66
147
  }),
67
148
  title: `console.${env.level}`,
68
- message: text,
69
- raw: env,
149
+ message: safeText(text),
150
+ // Console args are the one genuinely unstructured captured payload —
151
+ // `console.log('auth', { token })` is ordinary debugging code.
152
+ raw: { ...env, args },
70
153
  };
71
154
  }
72
155
  case 'fetch': {
73
156
  const failed = env.error !== undefined || (env.status !== undefined && env.status >= 400);
157
+ const url = redactUrl(env.url);
158
+ const status = env.status !== undefined
159
+ ? ` → ${env.status}${env.statusText ? ` ${env.statusText}` : ''}`
160
+ : '';
74
161
  return {
75
162
  id: ulid(),
76
163
  sessionId,
@@ -83,10 +170,10 @@ export function envelopeToEvent(env, sessionId = SESSION_ID_PLACEHOLDER) {
83
170
  kind: 'network',
84
171
  identity: `${env.method}:${normaliseUrl(env.url)}:${env.status ?? 'err'}`,
85
172
  }),
86
- title: `${env.method} ${env.url}${env.status !== undefined ? ` → ${env.status}` : ''}`,
87
- message: env.error,
88
- relatedUrls: [env.url],
89
- raw: env,
173
+ title: safeTitle(`${env.method} ${url}${status}`),
174
+ message: safeText(env.error),
175
+ relatedUrls: [url],
176
+ raw: { ...env, url },
90
177
  };
91
178
  }
92
179
  case 'route':
@@ -116,27 +203,70 @@ export function envelopeToEvent(env, sessionId = SESSION_ID_PLACEHOLDER) {
116
203
  message: env.userAgent,
117
204
  raw: env,
118
205
  };
119
- case 'publish':
206
+ case 'publish': {
120
207
  // Pre-formed publish envelope from in-page instrumentation. Trust
121
208
  // its source/category (the producer is our own injected code); the
122
209
  // reducers gate on those, and the context carries the flow id so
123
210
  // React renders correlate with the originating user action.
211
+ const net = networkDetails(env.category, env.raw);
212
+ // A FAILED REQUEST IS AN ERROR, wherever it was published from.
213
+ //
214
+ // `flow-fetch` classified every non-ok response as `warning`, so a 4xx
215
+ // or 5xx never reached `runtime://browser/failed-requests` (the runtime
216
+ // reducer routes only `severity === 'error'` there). On foodguard that
217
+ // silently hid 116 real failures — 98×401, 10×503, 8×502. Normalising
218
+ // here rather than only at the publisher means the invariant holds for
219
+ // any producer, including an app pinned to an older instrumentation
220
+ // build.
221
+ const severity = net && net.status !== undefined && net.status >= 400
222
+ ? 'error'
223
+ : (env.severity ?? 'info');
224
+ // The publisher embedded the un-redacted URL in its own title AND its
225
+ // own fingerprint (flow-fetch builds both from the raw URL); swap in the
226
+ // redacted form rather than dropping either. The substitution is
227
+ // deterministic, so the fingerprint stays a stable dedupe key.
228
+ const scrub = (s) => net && net.url !== net.redactedUrl ? s.split(net.url).join(net.redactedUrl) : s;
229
+ const title = scrub(env.title);
124
230
  return {
125
231
  id: ulid(),
126
232
  sessionId,
127
233
  timestamp: env.at,
128
234
  source: env.source,
129
235
  category: env.category,
130
- severity: (env.severity ?? 'info'),
236
+ severity,
131
237
  context: { sessionId, ...tab, ...(env.context ?? {}) },
132
- fingerprint: env.fingerprint ?? fingerprint({ kind: 'publish', identity: env.title }),
133
- title: env.title,
134
- message: env.message,
135
- raw: env.raw,
238
+ fingerprint: env.fingerprint
239
+ ? scrub(env.fingerprint)
240
+ : fingerprint({ kind: 'publish', identity: title }),
241
+ title: safeTitle(title),
242
+ message: safeText(env.message),
243
+ ...(net ? { relatedUrls: [net.redactedUrl] } : {}),
244
+ raw: net
245
+ ? { ...redactDeep(env.raw), url: net.redactedUrl }
246
+ : redactDeep(env.raw),
136
247
  };
248
+ }
137
249
  }
138
250
  }
139
251
  // --- helpers ---
252
+ /**
253
+ * Pull `{ url, status }` off a network `publish` envelope's `raw` (the shape
254
+ * `flow-fetch` emits: `fetch-start` / `fetch-done` / `fetch-error`). Returns
255
+ * undefined for anything that is not a network publish carrying a URL, so
256
+ * every other publish passes through untouched.
257
+ */
258
+ function networkDetails(category, raw) {
259
+ if (category !== 'network' || !raw || typeof raw !== 'object')
260
+ return undefined;
261
+ const r = raw;
262
+ if (typeof r.url !== 'string' || r.url.length === 0)
263
+ return undefined;
264
+ return {
265
+ url: r.url,
266
+ redactedUrl: redactUrl(r.url),
267
+ ...(typeof r.status === 'number' ? { status: r.status } : {}),
268
+ };
269
+ }
140
270
  function normaliseStackTopFrame(stack) {
141
271
  if (!stack)
142
272
  return 'unknown';
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Redaction + bounding for browser-originated event text.
3
+ *
4
+ * ── Why this exists ─────────────────────────────────────────────────────────
5
+ * Until now the browser taps recorded almost nothing about an error, so there
6
+ * was nothing to leak. Enriching them (message, stack, method/URL/status)
7
+ * changes that: a request URL routinely carries a credential in its query
8
+ * string (`?access_token=…`, `?code=…` on an OAuth callback, `?key=…` on a
9
+ * Google API), and an error message can quote a JWT or an `Authorization`
10
+ * header. Those values then land in `runtime://browser/*`, which an agent
11
+ * reads and may quote into a transcript.
12
+ *
13
+ * So enrichment and redaction ship together, in the same place. This module is
14
+ * applied in `envelopeToEvent` — the ONE function every browser envelope
15
+ * crosses on its way to the bus — rather than at each tap, so a tap cannot
16
+ * forget it and a hand-rolled client cannot bypass it.
17
+ *
18
+ * `nx.json` has carried a `redaction: { headers, paths }` block since the init
19
+ * generator was written, with no reader anywhere in the repo. This is the
20
+ * first code to honour that intent; the lists below are a superset of it.
21
+ *
22
+ * ── Deliberately NOT done here ──────────────────────────────────────────────
23
+ * • Request/response HEADERS are never captured in the first place (the taps
24
+ * read none), which is the strongest possible defence — nothing to redact.
25
+ * Keep it that way; do not add header capture without extending this module.
26
+ * • Request/response BODIES are likewise never captured.
27
+ * • Free text is scrubbed for credential SHAPES (JWT, `Bearer …`), not parsed
28
+ * for arbitrary URLs. A URL embedded in a stack frame keeps its query
29
+ * string. Widening that means running the URL redactor over every message,
30
+ * which is a cost/false-positive trade not worth making blind.
31
+ */
32
+ /** Replacement for a redacted value. Short, obvious, greppable. */
33
+ export declare const REDACTED = "REDACTED";
34
+ /**
35
+ * Bounds. `raw` is structurally clamped to 32KiB by the bus transform
36
+ * (`createEventClamp`, wired in `@lensmcp/session`), but `title`/`message` are
37
+ * NOT — they are plain strings on `BaseEvent` and reach every reader
38
+ * unclamped. A stack from a deep React tree is routinely tens of KB, so these
39
+ * are the bounds that actually keep an event small.
40
+ */
41
+ export declare const MAX_TITLE_CHARS = 300;
42
+ export declare const MAX_MESSAGE_CHARS = 4096;
43
+ export declare const MAX_URL_CHARS = 1024;
44
+ /** Is this query-parameter name carrying a credential? */
45
+ export declare function isSecretParam(name: string): boolean;
46
+ /**
47
+ * Strip credentials from a URL, preserving every other byte.
48
+ *
49
+ * Implemented as a regex rewrite rather than `new URL(...)` on purpose:
50
+ * `URL` re-encodes as it round-trips (`+` vs `%20`, dropped default ports,
51
+ * sorted-looking output), which would mean the URL an agent reads is not the
52
+ * URL the app requested. It also throws on relative inputs, which is most of
53
+ * what a SPA fetches. This keeps the string byte-identical apart from the
54
+ * redacted values, and never throws.
55
+ *
56
+ * Redacts, in order: userinfo, then any query OR fragment parameter whose name
57
+ * looks like a credential. The fragment matters — an OAuth implicit-flow
58
+ * callback puts the access token there (`#access_token=…`).
59
+ */
60
+ export declare function redactUrl(url: string): string;
61
+ /**
62
+ * Scrub credential SHAPES out of free text (an error message, a stack, a
63
+ * console line). Cheap, linear, and tuned for precision over recall — a false
64
+ * positive destroys the diagnostic value this whole change is adding.
65
+ */
66
+ export declare function redactText(text: string): string;
67
+ /**
68
+ * Bound a string, marking what was removed. Marker text matches
69
+ * `@lensmcp/storage`'s `clampEventRaw` so the two truncations read the same in
70
+ * a transcript.
71
+ */
72
+ export declare function clip(value: string, max: number): string;
73
+ /** Redact then bound — the standard treatment for any captured free text. */
74
+ export declare function safeText(value: string | undefined, max?: number): string | undefined;
75
+ /** Redact then bound — the standard treatment for a captured title. */
76
+ export declare function safeTitle(value: string): string;
77
+ /**
78
+ * Scrub credential shapes out of every string leaf of an arbitrary value.
79
+ *
80
+ * Needed for `console` arguments, which are the one captured payload that is
81
+ * genuinely unstructured — `console.log('auth', { token })` is ordinary
82
+ * debugging code, and the whole point of this change is that those payloads
83
+ * now reach a transcript. Bounded on depth and array length so a pathological
84
+ * object cannot make the bridge the slow part of a page's logging.
85
+ *
86
+ * Values are returned by reference when nothing changed, so the common case
87
+ * (no credential anywhere) allocates nothing.
88
+ */
89
+ export declare function redactDeep(value: unknown, depth?: number): unknown;
package/lib/redact.js ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Redaction + bounding for browser-originated event text.
3
+ *
4
+ * ── Why this exists ─────────────────────────────────────────────────────────
5
+ * Until now the browser taps recorded almost nothing about an error, so there
6
+ * was nothing to leak. Enriching them (message, stack, method/URL/status)
7
+ * changes that: a request URL routinely carries a credential in its query
8
+ * string (`?access_token=…`, `?code=…` on an OAuth callback, `?key=…` on a
9
+ * Google API), and an error message can quote a JWT or an `Authorization`
10
+ * header. Those values then land in `runtime://browser/*`, which an agent
11
+ * reads and may quote into a transcript.
12
+ *
13
+ * So enrichment and redaction ship together, in the same place. This module is
14
+ * applied in `envelopeToEvent` — the ONE function every browser envelope
15
+ * crosses on its way to the bus — rather than at each tap, so a tap cannot
16
+ * forget it and a hand-rolled client cannot bypass it.
17
+ *
18
+ * `nx.json` has carried a `redaction: { headers, paths }` block since the init
19
+ * generator was written, with no reader anywhere in the repo. This is the
20
+ * first code to honour that intent; the lists below are a superset of it.
21
+ *
22
+ * ── Deliberately NOT done here ──────────────────────────────────────────────
23
+ * • Request/response HEADERS are never captured in the first place (the taps
24
+ * read none), which is the strongest possible defence — nothing to redact.
25
+ * Keep it that way; do not add header capture without extending this module.
26
+ * • Request/response BODIES are likewise never captured.
27
+ * • Free text is scrubbed for credential SHAPES (JWT, `Bearer …`), not parsed
28
+ * for arbitrary URLs. A URL embedded in a stack frame keeps its query
29
+ * string. Widening that means running the URL redactor over every message,
30
+ * which is a cost/false-positive trade not worth making blind.
31
+ */
32
+ /** Replacement for a redacted value. Short, obvious, greppable. */
33
+ export const REDACTED = 'REDACTED';
34
+ /**
35
+ * Bounds. `raw` is structurally clamped to 32KiB by the bus transform
36
+ * (`createEventClamp`, wired in `@lensmcp/session`), but `title`/`message` are
37
+ * NOT — they are plain strings on `BaseEvent` and reach every reader
38
+ * unclamped. A stack from a deep React tree is routinely tens of KB, so these
39
+ * are the bounds that actually keep an event small.
40
+ */
41
+ export const MAX_TITLE_CHARS = 300;
42
+ export const MAX_MESSAGE_CHARS = 4096;
43
+ export const MAX_URL_CHARS = 1024;
44
+ /**
45
+ * Substrings that make a query-parameter name a credential regardless of how
46
+ * it is spelled or delimited. These are safe as substrings — no ordinary
47
+ * parameter name contains them innocently.
48
+ */
49
+ const SECRET_SUBSTRINGS = [
50
+ 'token',
51
+ 'secret',
52
+ 'password',
53
+ 'passwd',
54
+ 'apikey',
55
+ 'credential',
56
+ 'assertion',
57
+ 'signature',
58
+ ];
59
+ /**
60
+ * Whole delimiter-separated words that make a parameter name a credential.
61
+ * Matched per-word (splitting on `-`, `_`, `.`) rather than as substrings,
62
+ * because the short ones collide: `auth` is a substring of `author`, `key` of
63
+ * `keywords`, `sig` of `sign_up_date`. Word matching keeps `?author=jane` and
64
+ * `?keywords=x` intact while still catching `?api_key=`/`?x-auth=`.
65
+ */
66
+ const SECRET_WORDS = new Set([
67
+ 'key',
68
+ 'auth',
69
+ 'authorization',
70
+ 'jwt',
71
+ 'code',
72
+ 'sig',
73
+ 'pwd',
74
+ 'pass',
75
+ 'session',
76
+ 'sid',
77
+ 'bearer',
78
+ 'otp',
79
+ // The `headers` half of the `nx.json` redaction contract. Relevant to
80
+ // `redactDeep` (a headers object logged to console) as much as to a query
81
+ // string.
82
+ 'cookie',
83
+ 'set-cookie',
84
+ ]);
85
+ /** Is this query-parameter name carrying a credential? */
86
+ export function isSecretParam(name) {
87
+ const n = name.toLowerCase();
88
+ if (SECRET_SUBSTRINGS.some((s) => n.includes(s)))
89
+ return true;
90
+ for (const word of n.split(/[-_.]+/)) {
91
+ if (word && SECRET_WORDS.has(word))
92
+ return true;
93
+ }
94
+ return false;
95
+ }
96
+ /**
97
+ * `scheme://user:pass@host` — credentials in the authority component.
98
+ * Anchored at the scheme so it cannot match a `@` inside a path or query.
99
+ */
100
+ const USERINFO_RE = /^([a-z][a-z0-9+.-]*:\/\/)[^/?#@\s]*@/i;
101
+ /**
102
+ * One `?name=value` / `&name=value` / `#name=value` pair. Values are matched
103
+ * lazily up to the next delimiter, so the walk is linear and cannot backtrack
104
+ * catastrophically on a long URL.
105
+ */
106
+ const PARAM_RE = /([?&#])([^=&#\s]+)=([^&#\s]*)/g;
107
+ /**
108
+ * Strip credentials from a URL, preserving every other byte.
109
+ *
110
+ * Implemented as a regex rewrite rather than `new URL(...)` on purpose:
111
+ * `URL` re-encodes as it round-trips (`+` vs `%20`, dropped default ports,
112
+ * sorted-looking output), which would mean the URL an agent reads is not the
113
+ * URL the app requested. It also throws on relative inputs, which is most of
114
+ * what a SPA fetches. This keeps the string byte-identical apart from the
115
+ * redacted values, and never throws.
116
+ *
117
+ * Redacts, in order: userinfo, then any query OR fragment parameter whose name
118
+ * looks like a credential. The fragment matters — an OAuth implicit-flow
119
+ * callback puts the access token there (`#access_token=…`).
120
+ */
121
+ export function redactUrl(url) {
122
+ if (!url)
123
+ return url;
124
+ const withoutUserinfo = url.replace(USERINFO_RE, `$1${REDACTED}@`);
125
+ const withoutSecrets = withoutUserinfo.replace(PARAM_RE, (match, delim, name) => isSecretParam(name) ? `${delim}${name}=${REDACTED}` : match);
126
+ return clip(withoutSecrets, MAX_URL_CHARS);
127
+ }
128
+ /** A JSON Web Token: three base64url segments, the first starting `eyJ` (`{"`). */
129
+ const JWT_RE = /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*/g;
130
+ /** `Bearer <token>` / `Basic <creds>`, however it was quoted or spaced. */
131
+ const SCHEME_CREDENTIAL_RE = /\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi;
132
+ /**
133
+ * A credential-bearing header quoted into free text — `authorization: xyz`,
134
+ * `"x-api-key": "xyz"`, `cookie=xyz`. Only the VALUE is replaced so the reader
135
+ * still learns which header was involved.
136
+ */
137
+ const HEADER_CREDENTIAL_RE = /\b(authorization|x-api-key|api-key|cookie|set-cookie|proxy-authorization)\b(\s*[:=]\s*)(["']?)([^\s"',;]{4,})\3/gi;
138
+ /**
139
+ * Scrub credential SHAPES out of free text (an error message, a stack, a
140
+ * console line). Cheap, linear, and tuned for precision over recall — a false
141
+ * positive destroys the diagnostic value this whole change is adding.
142
+ */
143
+ export function redactText(text) {
144
+ return text
145
+ .replace(HEADER_CREDENTIAL_RE, (_m, header, sep, quote) => `${header}${sep}${quote}${REDACTED}${quote}`)
146
+ .replace(JWT_RE, REDACTED)
147
+ .replace(SCHEME_CREDENTIAL_RE, (_m, scheme) => `${scheme} ${REDACTED}`);
148
+ }
149
+ /**
150
+ * Bound a string, marking what was removed. Marker text matches
151
+ * `@lensmcp/storage`'s `clampEventRaw` so the two truncations read the same in
152
+ * a transcript.
153
+ */
154
+ export function clip(value, max) {
155
+ if (value.length <= max)
156
+ return value;
157
+ return `${value.slice(0, max)}…[lensmcp truncated: ${value.length - max} more chars]`;
158
+ }
159
+ /** Redact then bound — the standard treatment for any captured free text. */
160
+ export function safeText(value, max = MAX_MESSAGE_CHARS) {
161
+ if (value === undefined)
162
+ return undefined;
163
+ return clip(redactText(value), max);
164
+ }
165
+ /** Redact then bound — the standard treatment for a captured title. */
166
+ export function safeTitle(value) {
167
+ return clip(redactText(value), MAX_TITLE_CHARS);
168
+ }
169
+ /** Depth ceiling for {@link redactDeep}; doubles as an allocation-free cycle guard. */
170
+ const MAX_REDACT_DEPTH = 6;
171
+ /** Array ceiling for {@link redactDeep}. Beyond this the tail is left as-is for the raw clamp to shed. */
172
+ const MAX_REDACT_ITEMS = 64;
173
+ /**
174
+ * Scrub credential shapes out of every string leaf of an arbitrary value.
175
+ *
176
+ * Needed for `console` arguments, which are the one captured payload that is
177
+ * genuinely unstructured — `console.log('auth', { token })` is ordinary
178
+ * debugging code, and the whole point of this change is that those payloads
179
+ * now reach a transcript. Bounded on depth and array length so a pathological
180
+ * object cannot make the bridge the slow part of a page's logging.
181
+ *
182
+ * Values are returned by reference when nothing changed, so the common case
183
+ * (no credential anywhere) allocates nothing.
184
+ */
185
+ export function redactDeep(value, depth = 0) {
186
+ if (typeof value === 'string') {
187
+ return redactText(value);
188
+ }
189
+ if (value === null || typeof value !== 'object' || depth >= MAX_REDACT_DEPTH) {
190
+ return value;
191
+ }
192
+ if (Array.isArray(value)) {
193
+ let changed = false;
194
+ const out = value.map((item, i) => {
195
+ if (i >= MAX_REDACT_ITEMS)
196
+ return item;
197
+ const next = redactDeep(item, depth + 1);
198
+ if (next !== item)
199
+ changed = true;
200
+ return next;
201
+ });
202
+ return changed ? out : value;
203
+ }
204
+ let changed = false;
205
+ const out = {};
206
+ for (const [key, item] of Object.entries(value)) {
207
+ // A key named like a credential is redacted whatever its value's shape —
208
+ // this is the `paths` half of the `nx.json` redaction contract.
209
+ const next = isSecretParam(key) && item !== undefined && item !== null ? REDACTED : redactDeep(item, depth + 1);
210
+ if (next !== item)
211
+ changed = true;
212
+ out[key] = next;
213
+ }
214
+ return changed ? out : value;
215
+ }
package/lib/server.d.ts CHANGED
@@ -53,4 +53,3 @@ export declare function handleClientEnvelope(raw: string, publish: (event: BaseE
53
53
  export declare function createBridgeServer(options?: BridgeServerOptions): Promise<BridgeServer>;
54
54
  /** Allocate an ephemeral free port (used when `port: 0` is requested). */
55
55
  export declare function findFreePort(): Promise<number>;
56
- //# sourceMappingURL=server.d.ts.map
package/lib/types.d.ts CHANGED
@@ -8,18 +8,52 @@
8
8
  * no-build) shares one definition. The Vite plugin re-exports it for
9
9
  * backwards compatibility.
10
10
  */
11
- export type ClientEnvelope = ({
11
+ export type ClientEnvelope = (
12
+ /**
13
+ * An uncaught JS error (`window.onerror`). `message` is OPTIONAL because it
14
+ * genuinely can be absent: a synthesised or cross-origin `ErrorEvent`
15
+ * carries none, and 3 of the 7 real browser errors captured on foodguard
16
+ * (2026-07-30) had no message at all. `name` is the constructor name of the
17
+ * thrown value, which survives when a cross-origin policy strips the rest.
18
+ */
19
+ {
12
20
  kind: 'error';
13
21
  at: number;
14
- message: string;
22
+ message?: string;
23
+ name?: string;
15
24
  stack?: string;
16
25
  source?: string;
17
26
  lineno?: number;
18
27
  colno?: number;
19
- } | {
28
+ }
29
+ /**
30
+ * A SUBRESOURCE that failed to load — `<img>`, `<script>`, `<link>`,
31
+ * `<video>`. The browser delivers these through the SAME `error` listener as
32
+ * an uncaught exception, but as a plain `Event`, not an `ErrorEvent`: no
33
+ * message, no filename, no line. Its only identity is the target element and
34
+ * its `src`/`href`, which the tap previously never read — so every one of
35
+ * them serialised to a bare `{ kind: 'error', at, tabId }` and arrived
36
+ * completely anonymous. Splitting the kind is what makes that identity
37
+ * representable.
38
+ */
39
+ | {
40
+ kind: 'resource-error';
41
+ at: number;
42
+ tagName: string;
43
+ url?: string;
44
+ }
45
+ /**
46
+ * An unhandled promise rejection. The reason is decomposed rather than
47
+ * pre-stringified: a rejected non-Error object (`{ status: 401 }`, a
48
+ * `Response`) used to be flattened by `String(reason)` into the useless
49
+ * `"[object Object]"`.
50
+ */
51
+ | {
20
52
  kind: 'unhandledrejection';
21
53
  at: number;
22
54
  reason: string;
55
+ name?: string;
56
+ stack?: string;
23
57
  } | {
24
58
  kind: 'console';
25
59
  at: number;
@@ -31,6 +65,7 @@ export type ClientEnvelope = ({
31
65
  method: string;
32
66
  url: string;
33
67
  status?: number;
68
+ statusText?: string;
34
69
  durationMs?: number;
35
70
  ok?: boolean;
36
71
  error?: string;
@@ -61,4 +96,3 @@ export type ClientEnvelope = ({
61
96
  };
62
97
  /** A cross-process event sink: takes one normalised event and forwards it onward. */
63
98
  export type EnvSink = (event: import('@lensmcp/protocol-types').BaseEvent) => void;
64
- //# sourceMappingURL=types.d.ts.map
package/main.d.ts CHANGED
@@ -1,2 +1 @@
1
1
  export {};
2
- //# sourceMappingURL=main.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lensmcp/bridge",
3
- "version": "1.18.2",
3
+ "version": "1.18.4",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "module": "./index.js",
@@ -19,8 +19,8 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@lensmcp/core": "1.18.2",
23
- "@lensmcp/protocol-types": "1.18.2",
22
+ "@lensmcp/core": "1.18.4",
23
+ "@lensmcp/protocol-types": "1.18.4",
24
24
  "tslib": "^2.3.0",
25
25
  "ws": "^8.21.0"
26
26
  },
package/index.d.ts.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,aAAa,EACb,oBAAoB,EACpB,YAAY,EACZ,KAAK,YAAY,EACjB,KAAK,mBAAmB,EACxB,KAAK,UAAU,GAChB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAClF,OAAO,EACL,eAAe,EACf,eAAe,EACf,WAAW,EACX,WAAW,EACX,cAAc,GACf,MAAM,oBAAoB,CAAC;AAC5B,YAAY,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../src/lib/constants.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,eAAe,cAAc,CAAC;AAE3C,4FAA4F;AAC5F,eAAO,MAAM,eAAe,OAAO,CAAC;AAEpC,oDAAoD;AACpD,eAAO,MAAM,WAAW,oBAAoB,CAAC;AAC7C,oFAAoF;AACpF,eAAO,MAAM,WAAW,oBAAoB,CAAC;AAC7C,yEAAyE;AACzE,eAAO,MAAM,cAAc,uBAAuB,CAAC"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"env-sink.d.ts","sourceRoot":"","sources":["../../src/lib/env-sink.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAwF1C,wBAAgB,cAAc,IAAI,OAAO,GAAG,IAAI,CA6C/C;AAED,oFAAoF;AACpF,wBAAgB,iBAAiB,IAAI,IAAI,CAExC;AAED,iFAAiF;AACjF,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAgDhD"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"event-mappers.d.ts","sourceRoot":"","sources":["../../src/lib/event-mappers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAEzD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAIjD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,cAAc,EAAE,SAAS,SAAyB,GAAG,SAAS,CAuIlG"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/lib/server.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AACzD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAI9C,OAAO,KAAK,EAAkB,OAAO,EAAE,MAAM,YAAY,CAAC;AAE1D;;;;;;;;GAQG;AACH,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,EAAE,QAAQ,CAAC;IACf,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,0CAA0C;IAC1C,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,mBAAoB,SAAQ,UAAU;IACrD,sCAAsC;IACtC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iFAAiF;IACjF,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,YAAY;IAC3B,0EAA0E;IAC1E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,uDAAuD;IACvD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,GAAG,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAsB1E;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,EACnC,SAAS,CAAC,EAAE,MAAM,GACjB,IAAI,CASN;AAED;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CAAC,OAAO,GAAE,mBAAwB,GAAG,OAAO,CAAC,YAAY,CAAC,CAmCjG;AAED,0EAA0E;AAC1E,wBAAgB,YAAY,IAAI,OAAO,CAAC,MAAM,CAAC,CAgB9C"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/lib/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,MAAM,MAAM,cAAc,GAAG,CACzB;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAChH;IAAE,IAAI,EAAE,oBAAoB,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC1D;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,KAAK,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC;IAAC,IAAI,EAAE,OAAO,EAAE,CAAA;CAAE,GACpG;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,EAAE,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAC9H;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GACzD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAK7D;IACE,IAAI,EAAE,SAAS,CAAC;IAChB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,GAAG,CAAC,EAAE,OAAO,CAAC;CACf,CACJ,GAAG;IACF,0GAA0G;IAC1G,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,qFAAqF;AACrF,MAAM,MAAM,OAAO,GAAG,CAAC,KAAK,EAAE,OAAO,yBAAyB,EAAE,SAAS,KAAK,IAAI,CAAC"}
package/main.d.ts.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":""}