@lightworkai.official/debug-capture 0.6.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 (65) hide show
  1. package/README.md +241 -0
  2. package/dist/budget.d.ts +22 -0
  3. package/dist/bundle.d.ts +20 -0
  4. package/dist/capture/actionTrail.d.ts +7 -0
  5. package/dist/capture/cause.d.ts +3 -0
  6. package/dist/capture/consoleBuffer.d.ts +4 -0
  7. package/dist/capture/crashWatcher.d.ts +1 -0
  8. package/dist/capture/networkBuffer.d.ts +4 -0
  9. package/dist/capture/redact.d.ts +9 -0
  10. package/dist/capture/stepCorrelation.d.ts +41 -0
  11. package/dist/config.d.ts +217 -0
  12. package/dist/context.d.ts +2 -0
  13. package/dist/debug-capture.js +116 -0
  14. package/dist/embed.d.ts +1 -0
  15. package/dist/index.d.ts +45 -0
  16. package/dist/index.mjs +129 -0
  17. package/dist/install.d.ts +5 -0
  18. package/dist/mytickets/api.d.ts +115 -0
  19. package/dist/mytickets/format.d.ts +84 -0
  20. package/dist/mytickets/sanitize.d.ts +66 -0
  21. package/dist/mytickets/strings.d.ts +74 -0
  22. package/dist/mytickets/toolbar.d.ts +17 -0
  23. package/dist/reporter.d.ts +27 -0
  24. package/dist/screenshot.d.ts +7 -0
  25. package/dist/signature.d.ts +18 -0
  26. package/dist/submit.d.ts +8 -0
  27. package/dist/types.d.ts +175 -0
  28. package/dist/ui/annotator.d.ts +34 -0
  29. package/dist/ui/arrow.d.ts +25 -0
  30. package/dist/ui/element.d.ts +9 -0
  31. package/dist/ui/strings.d.ts +42 -0
  32. package/dist/ui/styles.d.ts +13 -0
  33. package/dist/ui/toast.d.ts +11 -0
  34. package/package.json +53 -0
  35. package/src/budget.ts +62 -0
  36. package/src/bundle.ts +274 -0
  37. package/src/capture/actionTrail.ts +693 -0
  38. package/src/capture/cause.ts +49 -0
  39. package/src/capture/consoleBuffer.ts +80 -0
  40. package/src/capture/crashWatcher.ts +61 -0
  41. package/src/capture/networkBuffer.ts +315 -0
  42. package/src/capture/redact.ts +117 -0
  43. package/src/capture/stepCorrelation.ts +160 -0
  44. package/src/config.ts +299 -0
  45. package/src/context.ts +81 -0
  46. package/src/embed.ts +35 -0
  47. package/src/index.ts +109 -0
  48. package/src/install.ts +59 -0
  49. package/src/mytickets/api.ts +226 -0
  50. package/src/mytickets/format.ts +191 -0
  51. package/src/mytickets/sanitize.ts +221 -0
  52. package/src/mytickets/strings.ts +217 -0
  53. package/src/mytickets/toolbar.ts +48 -0
  54. package/src/reporter.ts +53 -0
  55. package/src/screenshot.ts +238 -0
  56. package/src/signature.ts +40 -0
  57. package/src/styles.css +400 -0
  58. package/src/submit.ts +143 -0
  59. package/src/types.ts +169 -0
  60. package/src/ui/annotator.ts +698 -0
  61. package/src/ui/arrow.ts +62 -0
  62. package/src/ui/element.ts +362 -0
  63. package/src/ui/strings.ts +113 -0
  64. package/src/ui/styles.ts +96 -0
  65. package/src/ui/toast.ts +138 -0
@@ -0,0 +1,49 @@
1
+ // Derives the "smoking gun" summary from the captured buffers: failed requests,
2
+ // uncaught errors, and errored React Query keys. Surfaced at the top of the
3
+ // report so a developer sees the likely cause immediately.
4
+
5
+ import type {
6
+ CapturedConsoleEntry,
7
+ CapturedRequest,
8
+ CauseSummary,
9
+ ErroredQuery,
10
+ } from '../types';
11
+
12
+ const MAX_ITEMS = 20;
13
+
14
+ export function computeCause(
15
+ network: CapturedRequest[],
16
+ consoleLog: CapturedConsoleEntry[],
17
+ erroredQueries: ErroredQuery[],
18
+ ): CauseSummary {
19
+ const failedRequests = network
20
+ .filter((req) => Boolean(req.error) || (req.status != null && req.status >= 400))
21
+ .slice(-MAX_ITEMS)
22
+ .map((req) => ({
23
+ method: req.method,
24
+ url: req.url,
25
+ status: req.status,
26
+ error: req.error,
27
+ at: req.startedAt,
28
+ }));
29
+
30
+ const errors = consoleLog
31
+ .filter(
32
+ (entry) =>
33
+ entry.level === 'error' ||
34
+ entry.level === 'exception' ||
35
+ entry.level === 'unhandledrejection',
36
+ )
37
+ .slice(-MAX_ITEMS)
38
+ .map((entry) => entry.message);
39
+
40
+ return {
41
+ failedRequests,
42
+ errors,
43
+ erroredQueries: erroredQueries.slice(0, MAX_ITEMS),
44
+ };
45
+ }
46
+
47
+ export function causeCount(cause: CauseSummary): number {
48
+ return cause.failedRequests.length + cause.errors.length + cause.erroredQueries.length;
49
+ }
@@ -0,0 +1,80 @@
1
+ // Rolling console + error capture. Patches console methods and the global
2
+ // error/unhandledrejection events into a bounded ring buffer, so when the user
3
+ // reports an issue we have the recent log tail that led up to it.
4
+
5
+ import { captureOption } from '../config';
6
+ import type { CapturedConsoleEntry, ConsoleEntryKind, ConsoleLevel } from '../types';
7
+
8
+ const MAX_MSG_LEN = 2000;
9
+
10
+ let buffer: CapturedConsoleEntry[] = [];
11
+ let counter = 0;
12
+ let installed = false;
13
+
14
+ function push(level: ConsoleEntryKind, message: string): void {
15
+ counter += 1;
16
+ buffer.push({
17
+ id: `log_${counter}`,
18
+ at: new Date().toISOString(),
19
+ level,
20
+ message: message.length > MAX_MSG_LEN ? `${message.slice(0, MAX_MSG_LEN)}…` : message,
21
+ });
22
+ while (buffer.length > captureOption('maxConsoleEntries')) buffer.shift();
23
+ }
24
+
25
+ function safeReplacer(): (key: string, value: unknown) => unknown {
26
+ const seen = new WeakSet<object>();
27
+ return (_key, value) => {
28
+ if (typeof value === 'object' && value !== null) {
29
+ if (seen.has(value)) return '[Circular]';
30
+ seen.add(value);
31
+ }
32
+ if (typeof value === 'bigint') return value.toString();
33
+ return value;
34
+ };
35
+ }
36
+
37
+ function stringifyArg(arg: unknown): string {
38
+ if (typeof arg === 'string') return arg;
39
+ if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
40
+ try {
41
+ return JSON.stringify(arg, safeReplacer()) ?? String(arg);
42
+ } catch {
43
+ return String(arg);
44
+ }
45
+ }
46
+
47
+ export function installConsoleCapture(): void {
48
+ if (installed || typeof window === 'undefined') return;
49
+ installed = true;
50
+
51
+ const levels: ConsoleLevel[] = ['log', 'info', 'warn', 'error', 'debug'];
52
+ for (const level of levels) {
53
+ const original = console[level].bind(console) as (...args: unknown[]) => void;
54
+ console[level] = ((...args: unknown[]) => {
55
+ push(level, args.map(stringifyArg).join(' '));
56
+ original(...args);
57
+ }) as Console[ConsoleLevel];
58
+ }
59
+
60
+ window.addEventListener('error', (event: ErrorEvent) => {
61
+ const where = event.filename ? ` (${event.filename}:${event.lineno}:${event.colno})` : '';
62
+ push('exception', `${event.message}${where}`);
63
+ });
64
+
65
+ window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
66
+ const reason =
67
+ event.reason instanceof Error
68
+ ? `${event.reason.name}: ${event.reason.message}`
69
+ : stringifyArg(event.reason);
70
+ push('unhandledrejection', reason);
71
+ });
72
+ }
73
+
74
+ export function getConsoleLog(): CapturedConsoleEntry[] {
75
+ return buffer.map((entry) => ({ ...entry }));
76
+ }
77
+
78
+ export function clearConsoleLog(): void {
79
+ buffer = [];
80
+ }
@@ -0,0 +1,61 @@
1
+ // Auto-catch uncaught runtime errors and promise rejections, and announce them
2
+ // on the crash channel so the host can invite the user to file a report. This
3
+ // catches bugs users would otherwise never mention.
4
+
5
+ import { emitCrash } from '../reporter';
6
+ import type { DebugReason } from '../types';
7
+
8
+ const THROTTLE_MS = 15000;
9
+
10
+ // Benign / noisy errors that shouldn't nag the user.
11
+ const IGNORED = [
12
+ 'ResizeObserver loop',
13
+ 'Script error.',
14
+ 'Non-Error promise rejection captured',
15
+ ];
16
+
17
+ let installed = false;
18
+ let lastPromptAt = 0;
19
+
20
+ function isIgnored(message: string): boolean {
21
+ return IGNORED.some((needle) => message.includes(needle));
22
+ }
23
+
24
+ function promptReport(reason: DebugReason): void {
25
+ const message = reason.message ?? '';
26
+ if (!message || isIgnored(message)) return;
27
+ // Manual throttle (Date.now avoided elsewhere, but fine in app code).
28
+ const now = Date.now();
29
+ if (now - lastPromptAt < THROTTLE_MS) return;
30
+ lastPromptAt = now;
31
+
32
+ // Announce it and stop. Whether that becomes a toast, a banner or nothing is
33
+ // the host's call — and it must NOT open the reporter by itself: screen
34
+ // capture needs a real click behind it.
35
+ emitCrash({ ...reason, message });
36
+ }
37
+
38
+ export function installCrashWatcher(): void {
39
+ if (installed || typeof window === 'undefined') return;
40
+ installed = true;
41
+
42
+ window.addEventListener('error', (event: ErrorEvent) => {
43
+ // Resource-load errors (img/script) have no message/error — ignore them.
44
+ if (!event.message && !event.error) return;
45
+ const error = event.error instanceof Error ? event.error : null;
46
+ promptReport({
47
+ type: 'runtime-error',
48
+ message: event.message || (error ? error.message : 'Unknown error'),
49
+ stack: error?.stack,
50
+ });
51
+ });
52
+
53
+ window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
54
+ const reason = event.reason;
55
+ promptReport({
56
+ type: 'unhandledrejection',
57
+ message: reason instanceof Error ? reason.message : String(reason),
58
+ stack: reason instanceof Error ? reason.stack : undefined,
59
+ });
60
+ });
61
+ }
@@ -0,0 +1,315 @@
1
+ // Rolling network capture. The app has no axios — every API call funnels
2
+ // through native `fetch` (a custom client) and a few integrations use `fetch`
3
+ // directly, so patching `window.fetch` (plus XHR for completeness) is the one
4
+ // chokepoint that records everything. Entries land in a bounded ring buffer;
5
+ // headers/urls/bodies are redacted at capture time.
6
+
7
+ import { captureOption, mayCaptureBody } from '../config';
8
+ import { redactBody, redactHeaders, redactUrl } from './redact';
9
+ import type { CapturedRequest } from '../types';
10
+
11
+
12
+ const MAX_REQ_BODY = 4000;
13
+ // Per-response body cap — generous enough to store virtually any API response in
14
+ // FULL (most are < 100 KB), so the developer reads the whole body, not a snippet.
15
+ // Skip reading responses larger than this (by Content-Length) to bound memory.
16
+ const MAX_RES_BYTES = 1_000_000; // 1 MB
17
+ // Total budget across ALL captured response bodies, enforced at snapshot time
18
+ // (getNetworkLog). The whole bundle ships as ONE multipart part capped at 15 MB
19
+ // by the backend — keep well under it so a report never fails to submit. Newest
20
+ // responses keep their full body; once the budget is spent, older bodies are
21
+ // replaced with a small marker.
22
+ const MAX_TOTAL_RES_BYTES = 10_000_000; // 10 MB
23
+
24
+ let buffer: CapturedRequest[] = [];
25
+ let counter = 0;
26
+ let installed = false;
27
+
28
+ function push(entry: CapturedRequest): void {
29
+ buffer.push(entry);
30
+ while (buffer.length > captureOption('maxRequests')) buffer.shift();
31
+ }
32
+
33
+ function nextId(): string {
34
+ counter += 1;
35
+ return `req_${counter}`;
36
+ }
37
+
38
+ function headersToObject(input: HeadersInit | undefined): Record<string, string> {
39
+ const out: Record<string, string> = {};
40
+ if (!input) return out;
41
+ if (input instanceof Headers) {
42
+ input.forEach((value, key) => {
43
+ out[key] = value;
44
+ });
45
+ } else if (Array.isArray(input)) {
46
+ for (const [key, value] of input) out[key] = value;
47
+ } else {
48
+ Object.assign(out, input);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ function responseHeadersToObject(headers: Headers): Record<string, string> {
54
+ const out: Record<string, string> = {};
55
+ headers.forEach((value, key) => {
56
+ out[key] = value;
57
+ });
58
+ return out;
59
+ }
60
+
61
+ function bodyToString(body: BodyInit | null | undefined): string | null {
62
+ if (body == null) return null;
63
+ if (typeof body === 'string') return body;
64
+ if (body instanceof URLSearchParams) return body.toString();
65
+ if (typeof FormData !== 'undefined' && body instanceof FormData) {
66
+ return `[FormData: ${Array.from(body.keys()).join(', ')}]`;
67
+ }
68
+ if (typeof Blob !== 'undefined' && body instanceof Blob) return `[Blob ${body.size} bytes]`;
69
+ if (body instanceof ArrayBuffer) return `[ArrayBuffer ${body.byteLength} bytes]`;
70
+ return '[binary]';
71
+ }
72
+
73
+ function resolveRequestMeta(
74
+ input: RequestInfo | URL,
75
+ init?: RequestInit,
76
+ ): { method: string; url: string; headers: Record<string, string>; body: string | null } {
77
+ let url = '';
78
+ let method = 'GET';
79
+ let headers: Record<string, string> = {};
80
+ let body: string | null = null;
81
+
82
+ if (typeof input === 'string') {
83
+ url = input;
84
+ } else if (input instanceof URL) {
85
+ url = input.toString();
86
+ } else {
87
+ // Request object
88
+ url = input.url;
89
+ method = input.method || method;
90
+ headers = headersToObject(input.headers);
91
+ }
92
+ if (init) {
93
+ if (init.method) method = init.method;
94
+ if (init.headers) headers = { ...headers, ...headersToObject(init.headers) };
95
+ if (init.body !== undefined) body = bodyToString(init.body);
96
+ }
97
+ return { method: method.toUpperCase(), url, headers, body };
98
+ }
99
+
100
+ /**
101
+ * Endpoints whose response BODY must never be stored.
102
+ *
103
+ * A support ticket's row carries the whole debug bundle, so capturing the
104
+ * ticket API's responses makes every report contain the previous reports. A
105
+ * widget's own `/ingest/*` calls are this library talking to itself, and a
106
+ * third party's body is not ours to collect unless the host named its origin.
107
+ *
108
+ * NOT the support host's whole origin — see `mayCaptureBody`. Excluding that
109
+ * left a self-hosted support app unable to capture any body at all.
110
+ *
111
+ * Only the body is dropped. Method, URL, status and duration are still
112
+ * recorded, so the request remains visible in the timeline and still counts
113
+ * toward a step's correlation — the part a reader actually uses.
114
+ */
115
+ function bodyExcluded(url: string): boolean {
116
+ return !mayCaptureBody(url);
117
+ }
118
+
119
+ function captureResponseSnippet(response: Response, entry: CapturedRequest): void {
120
+ if (bodyExcluded(entry.url)) {
121
+ entry.responseSnippet = '[body not captured for this origin]';
122
+ return;
123
+ }
124
+ const contentType = response.headers.get('content-type') ?? '';
125
+ const contentLength = Number(response.headers.get('content-length') ?? '0');
126
+ const sizeNote = contentLength ? `, ~${contentLength} bytes` : '';
127
+ // Binary bodies (pdf/images/blobs): record a descriptor, don't read bytes.
128
+ if (!/json|text|xml|html|javascript|urlencoded/i.test(contentType)) {
129
+ entry.responseSnippet = `[binary ${contentType || 'unknown'}${sizeNote}]`;
130
+ return;
131
+ }
132
+ if (contentLength > MAX_RES_BYTES) {
133
+ entry.responseSnippet = `[response ${contentType || 'text'}${sizeNote} — omitted]`;
134
+ return;
135
+ }
136
+ try {
137
+ // Fire-and-forget on a clone so we never delay or consume the app's body.
138
+ response
139
+ .clone()
140
+ .text()
141
+ .then((text) => {
142
+ entry.responseSnippet = redactBody(text, captureOption('maxBodyChars'));
143
+ })
144
+ .catch(() => {
145
+ /* ignore — best effort */
146
+ });
147
+ } catch {
148
+ /* clone can throw if the body was already read — ignore */
149
+ }
150
+ }
151
+
152
+ // Next.js App Router runs client navigation + <Link> prefetching through
153
+ // `fetch` — every such request carries a `_rsc` cache-busting query param and/or
154
+ // `RSC` / `Next-Router-Prefetch` request headers, and returns an RSC flight
155
+ // payload (not JSON the app consumes). It's framework chatter, not the app's
156
+ // API calls: it buries the real Fetch/XHR and, worse, evicts genuine API calls
157
+ // from the bounded ring buffer. Skip it at the source.
158
+ function isFrameworkNoise(url: string, headers: Record<string, string>): boolean {
159
+ if (/[?&]_rsc=/.test(url)) return true;
160
+ for (const key of Object.keys(headers)) {
161
+ const k = key.toLowerCase();
162
+ if (k === 'rsc' || k === 'next-router-prefetch' || k === 'next-router-state-tree') {
163
+ return true;
164
+ }
165
+ }
166
+ return false;
167
+ }
168
+
169
+ function installFetch(): void {
170
+ if (typeof window === 'undefined' || typeof window.fetch !== 'function') return;
171
+ const original = window.fetch.bind(window);
172
+
173
+ // Object.assign keeps whatever statics the platform hangs off fetch
174
+ // (`preconnect` in newer runtimes). Replacing it with a bare function drops
175
+ // them, and something else on the page may be relying on one.
176
+ window.fetch = Object.assign(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
177
+ const meta = resolveRequestMeta(input, init);
178
+ // Don't record Next.js RSC navigation/prefetch — capture real requests only.
179
+ if (isFrameworkNoise(meta.url, meta.headers)) return original(input, init);
180
+ const entry: CapturedRequest = {
181
+ id: nextId(),
182
+ startedAt: new Date().toISOString(),
183
+ method: meta.method,
184
+ url: redactUrl(meta.url),
185
+ requestHeaders: redactHeaders(meta.headers),
186
+ requestBody: redactBody(meta.body, MAX_REQ_BODY),
187
+ status: null,
188
+ statusText: null,
189
+ durationMs: null,
190
+ via: 'fetch',
191
+ };
192
+ push(entry);
193
+
194
+ const start = performance.now();
195
+ try {
196
+ const response = await original(input, init);
197
+ entry.status = response.status;
198
+ entry.statusText = response.statusText;
199
+ entry.durationMs = Math.round(performance.now() - start);
200
+ entry.responseHeaders = redactHeaders(responseHeadersToObject(response.headers));
201
+ captureResponseSnippet(response, entry);
202
+ return response;
203
+ } catch (err) {
204
+ entry.durationMs = Math.round(performance.now() - start);
205
+ entry.error = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
206
+ throw err;
207
+ }
208
+ }, original);
209
+ }
210
+
211
+ interface PatchedXhr extends XMLHttpRequest {
212
+ __cibEntry?: CapturedRequest;
213
+ __cibStart?: number;
214
+ }
215
+
216
+ function installXhr(): void {
217
+ if (typeof window === 'undefined' || typeof window.XMLHttpRequest !== 'function') return;
218
+ const proto = XMLHttpRequest.prototype;
219
+ const originalOpen = proto.open;
220
+ const originalSend = proto.send;
221
+
222
+ const patchedOpen = function (this: PatchedXhr, method: string, url: string | URL): void {
223
+ const urlStr = typeof url === 'string' ? url : url.toString();
224
+ // Skip framework chatter (headers aren't set yet at open() — URL is enough).
225
+ this.__cibEntry = isFrameworkNoise(urlStr, {})
226
+ ? undefined
227
+ : {
228
+ id: nextId(),
229
+ startedAt: '',
230
+ method: (method || 'GET').toUpperCase(),
231
+ url: redactUrl(urlStr),
232
+ requestHeaders: {},
233
+ requestBody: null,
234
+ status: null,
235
+ statusText: null,
236
+ durationMs: null,
237
+ via: 'xhr',
238
+ };
239
+ // eslint-disable-next-line prefer-rest-params
240
+ return originalOpen.apply(this, arguments as unknown as Parameters<typeof originalOpen>);
241
+ };
242
+
243
+ const patchedSend = function (
244
+ this: PatchedXhr,
245
+ body?: Document | XMLHttpRequestBodyInit | null,
246
+ ): void {
247
+ const entry = this.__cibEntry;
248
+ if (entry) {
249
+ entry.startedAt = new Date().toISOString();
250
+ entry.requestBody =
251
+ typeof body === 'string'
252
+ ? redactBody(body, MAX_REQ_BODY)
253
+ : body == null
254
+ ? null
255
+ : '[non-string body]';
256
+ const start = performance.now();
257
+ push(entry);
258
+ this.addEventListener('loadend', () => {
259
+ entry.status = this.status || null;
260
+ entry.statusText = this.statusText || null;
261
+ entry.durationMs = Math.round(performance.now() - start);
262
+ // Only string responseText is readable ('' / 'text' response types);
263
+ // for blob/arraybuffer/json response types record a descriptor.
264
+ if (bodyExcluded(entry.url)) {
265
+ entry.responseSnippet = '[body not captured for this origin]';
266
+ } else if (this.responseType === '' || this.responseType === 'text') {
267
+ try {
268
+ entry.responseSnippet = redactBody(this.responseText || '', captureOption('maxBodyChars'));
269
+ } catch {
270
+ /* responseText may throw for some response types — ignore */
271
+ }
272
+ } else {
273
+ const ct = this.getResponseHeader('content-type') ?? this.responseType;
274
+ entry.responseSnippet = `[binary ${ct}]`;
275
+ }
276
+ });
277
+ }
278
+ // eslint-disable-next-line prefer-rest-params
279
+ return originalSend.apply(this, arguments as unknown as Parameters<typeof originalSend>);
280
+ };
281
+
282
+ proto.open = patchedOpen as typeof proto.open;
283
+ proto.send = patchedSend as typeof proto.send;
284
+ }
285
+
286
+ export function installNetworkCapture(): void {
287
+ if (installed) return;
288
+ installed = true;
289
+ installFetch();
290
+ installXhr();
291
+ }
292
+
293
+ export function getNetworkLog(): CapturedRequest[] {
294
+ // Shallow clones so consumers can't mutate the live buffer.
295
+ const clones = buffer.map((entry) => ({ ...entry }));
296
+ // Enforce the total response-body budget newest-first: keep full bodies until
297
+ // the budget is spent, then replace older ones with a marker so the assembled
298
+ // report stays comfortably under the backend's 15 MB multipart part cap.
299
+ let used = 0;
300
+ for (let i = clones.length - 1; i >= 0; i -= 1) {
301
+ const clone = clones[i];
302
+ const body = clone?.responseSnippet;
303
+ if (!clone || typeof body !== 'string' || body.length === 0) continue;
304
+ if (used + body.length > MAX_TOTAL_RES_BYTES) {
305
+ clone.responseSnippet = `[response omitted — report size budget reached (${body.length.toLocaleString()} chars)]`;
306
+ } else {
307
+ used += body.length;
308
+ }
309
+ }
310
+ return clones;
311
+ }
312
+
313
+ export function clearNetworkLog(): void {
314
+ buffer = [];
315
+ }
@@ -0,0 +1,117 @@
1
+ // Redaction — strips secrets before anything is shown, copied, or downloaded.
2
+ // A debug bundle is meant to be pasted into a chat / ticket, so tokens,
3
+ // cookies, and credentials must never survive into it.
4
+ //
5
+ // The built-in lists cover what is standard. They cannot cover what is not:
6
+ // every app has a header or a body key that is a credential only there, so a
7
+ // consumer extends these through `redact` in the config. A library that only
8
+ // knew Authorization and Cookie would be a library that leaks.
9
+
10
+ import { getRedactOptions } from '../config';
11
+
12
+ export const REDACTED = '‹redacted›';
13
+
14
+ const SENSITIVE_HEADER_RE =
15
+ /^(authorization|proxy-authorization|cookie|set-cookie|x-[a-z-]*token|x-csrf[a-z-]*|x-xsrf[a-z-]*)$/i;
16
+
17
+ const SENSITIVE_QUERY_KEYS = new Set([
18
+ 'access_token',
19
+ 'token',
20
+ 'id_token',
21
+ 'refresh_token',
22
+ 'code',
23
+ 'secret',
24
+ 'client_secret',
25
+ 'password',
26
+ 'apikey',
27
+ 'api_key',
28
+ 'signature',
29
+ 'x-amz-signature',
30
+ 'x-amz-credential',
31
+ ]);
32
+
33
+ const SENSITIVE_BODY_KEY_RE =
34
+ /(password|passwd|token|secret|authorization|refresh|client_secret|otp|pin)/i;
35
+
36
+ // PII patterns for action-trail LABELS (visible UI text captured from clicks /
37
+ // field names). Conservative on purpose — only high-confidence identifiers, so
38
+ // meaningful labels ("2 รายการ", "16:35", "2569") are never masked:
39
+ // • email addresses
40
+ // • long digit runs (≥9) — phone / national-id / account numbers (allowing
41
+ // spaces or dashes as separators, e.g. "081-234-5678", "1 2345 67890 12 3")
42
+ const EMAIL_RE = /[\w.+-]+@[\w-]+\.[\w.-]+/g;
43
+ const LONG_NUMBER_RE = /\d(?:[\d\s-]{7,})\d/g;
44
+
45
+ /** Strip likely PII (emails, phone/ID number runs) from a captured UI label.
46
+ * Labels come from visible text, so they rarely hold secrets — but a container's
47
+ * text can carry a reporter's name/number, and tickets are shown to admins. */
48
+ export function redactLabel(text: string): string {
49
+ return text.replace(EMAIL_RE, REDACTED).replace(LONG_NUMBER_RE, REDACTED);
50
+ }
51
+
52
+ function isSensitiveHeader(key: string): boolean {
53
+ if (SENSITIVE_HEADER_RE.test(key)) return true;
54
+ const extra = getRedactOptions().headers;
55
+ return extra.some((name) => name.toLowerCase() === key.toLowerCase());
56
+ }
57
+
58
+ export function redactHeaders(headers: Record<string, string>): Record<string, string> {
59
+ const out: Record<string, string> = {};
60
+ for (const [key, value] of Object.entries(headers)) {
61
+ out[key] = isSensitiveHeader(key) ? REDACTED : value;
62
+ }
63
+ return out;
64
+ }
65
+
66
+ export function redactUrl(rawUrl: string): string {
67
+ try {
68
+ const isAbsolute = /^https?:\/\//i.test(rawUrl);
69
+ // Relative URLs need a base to parse; the placeholder origin is discarded
70
+ // on output so we return the original relative shape.
71
+ const url = new URL(rawUrl, isAbsolute ? undefined : 'http://local.invalid');
72
+ let changed = false;
73
+ url.searchParams.forEach((_value, key) => {
74
+ if (SENSITIVE_QUERY_KEYS.has(key.toLowerCase()) || getRedactOptions().queryKeys.includes(key.toLowerCase())) {
75
+ url.searchParams.set(key, REDACTED);
76
+ changed = true;
77
+ }
78
+ });
79
+ if (!changed) return rawUrl;
80
+ return isAbsolute ? url.toString() : `${url.pathname}${url.search}`;
81
+ } catch {
82
+ return rawUrl;
83
+ }
84
+ }
85
+
86
+ /** Deep-redact object values whose keys look sensitive. Returns a new value. */
87
+ function redactValue(value: unknown, depth: number): unknown {
88
+ if (depth > 6 || value === null || typeof value !== 'object') return value;
89
+ if (Array.isArray(value)) return value.map((item) => redactValue(item, depth + 1));
90
+ const out: Record<string, unknown> = {};
91
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
92
+ const sensitive =
93
+ SENSITIVE_BODY_KEY_RE.test(key) ||
94
+ getRedactOptions().bodyKeys.some((needle) => key.toLowerCase().includes(needle));
95
+ out[key] = sensitive ? REDACTED : redactValue(val, depth + 1);
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /** Redact + truncate a request/response body string. */
101
+ export function redactBody(body: string | null, maxLen: number): string | null {
102
+ if (body == null) return null;
103
+ let text = body;
104
+ const trimmed = body.trim();
105
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
106
+ try {
107
+ const parsed: unknown = JSON.parse(trimmed);
108
+ text = JSON.stringify(redactValue(parsed, 0));
109
+ } catch {
110
+ // Not JSON — keep the raw text (still truncated below).
111
+ }
112
+ }
113
+ if (text.length > maxLen) {
114
+ return `${text.slice(0, maxLen)}… [truncated ${text.length - maxLen} chars]`;
115
+ }
116
+ return text;
117
+ }