@paikko/widget 0.1.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.
package/src/capture.ts ADDED
@@ -0,0 +1,991 @@
1
+ /**
2
+ * paikko client capture machinery.
3
+ *
4
+ * Everything the agent needs to reproduce a bug, captured at the moment the user
5
+ * hits report. The guiding rule is "photograph, not live window": the buffers
6
+ * (console, network) accumulate continuously while the app runs, but the storage
7
+ * / client-state / DOM snapshots are taken on demand at report time, and the
8
+ * whole thing is frozen into immutable {@link ArtifactPayload}s by
9
+ * {@link snapshotArtifacts}.
10
+ *
11
+ * The frontend half of the trace spine lives here: every patched fetch/XHR mints
12
+ * a {@link TraceId}, records it on its {@link NetworkEntry}, and propagates it as
13
+ * the `x-paikko-trace` request header so the backend `withCapture()` wrapper can
14
+ * stitch its {@link TraceRequest} back to this exact call.
15
+ *
16
+ * Shape note: every record produced here is built to the contract types and
17
+ * validated through {@link ArtifactPayloadSchemas} before it leaves the client,
18
+ * so the API only ever sees contract-valid payloads.
19
+ */
20
+ import {
21
+ type ConsoleArtifact,
22
+ type ConsoleEntry,
23
+ type NetworkArtifact,
24
+ type NetworkEntry,
25
+ type ClientStateArtifact,
26
+ type StorageArtifact,
27
+ type DomArtifact,
28
+ type ScreenshotArtifact,
29
+ type ReportTarget,
30
+ type TraceId,
31
+ ArtifactPayloadSchemas,
32
+ } from "@paikko/contract";
33
+
34
+ /* ------------------------------------------------------------------ */
35
+ /* Config */
36
+ /* ------------------------------------------------------------------ */
37
+
38
+ /** Header that carries the frontend trace id to the backend capture wrapper. */
39
+ export const TRACE_HEADER = "x-paikko-trace";
40
+
41
+ /** Header that carries the stable capture session id to the backend. */
42
+ export const SESSION_HEADER = "x-paikko-session";
43
+
44
+ /**
45
+ * Header carrying the project's PUBLISHABLE api key (pk_...) on the report POST.
46
+ * Public by nature (it ships in the browser); the backend only honours it when
47
+ * auth is enforced (the default; off only via PAIKKO_AUTH=disabled) and relies on the CORS origin allowlist
48
+ * as the real defense. Never put a SECRET key (sk_...) here.
49
+ */
50
+ export const PUBLISHABLE_KEY_HEADER = "x-paikko-key";
51
+
52
+ /** sessionStorage key under which the stable session id is persisted. */
53
+ const SESSION_KEY = "paikko.sessionId";
54
+
55
+ /**
56
+ * Mint (once) and return the stable capture session id for this browsing session.
57
+ * Persisted in sessionStorage so every request and the final report bundle share
58
+ * the same id - that is what lets the server drain exactly this session's buffered
59
+ * backend requests into the `trace` artifact. Falls back to an in-memory id when
60
+ * sessionStorage is unavailable (SSR, sandboxed contexts).
61
+ */
62
+ let memorySessionId: string | null = null;
63
+ export function getSessionId(): string {
64
+ if (typeof window === "undefined" || typeof sessionStorage === "undefined") {
65
+ if (!memorySessionId) memorySessionId = genTraceId();
66
+ return memorySessionId;
67
+ }
68
+ try {
69
+ let id = sessionStorage.getItem(SESSION_KEY);
70
+ if (!id) {
71
+ id = genTraceId();
72
+ sessionStorage.setItem(SESSION_KEY, id);
73
+ }
74
+ return id;
75
+ } catch {
76
+ if (!memorySessionId) memorySessionId = genTraceId();
77
+ return memorySessionId;
78
+ }
79
+ }
80
+
81
+ /** Provenance attribute injected at build time (see PROVENANCE.md). */
82
+ const SRC_ATTR = "data-src";
83
+ const COMPONENT_ATTR = "data-paikko-component";
84
+
85
+ export interface CaptureConfig {
86
+ /** Console ring buffer capacity (lines kept, oldest evicted). */
87
+ consoleBufferSize: number;
88
+ /** Network ring buffer capacity (calls kept, oldest evicted). */
89
+ networkBufferSize: number;
90
+ /**
91
+ * Reader for the mandated client-state store. The store is owned by another
92
+ * seam, so capture depends on a getter rather than importing it directly.
93
+ * Returns the snapshot object, or `{}` if no store is wired.
94
+ */
95
+ getClientState: () => Record<string, unknown>;
96
+ /** Max serialized length of a captured request/response body, in chars. */
97
+ maxBodyChars: number;
98
+ }
99
+
100
+ const DEFAULT_CONFIG: CaptureConfig = {
101
+ consoleBufferSize: 200,
102
+ networkBufferSize: 100,
103
+ getClientState: () => ({}),
104
+ maxBodyChars: 16_384,
105
+ };
106
+
107
+ /* ------------------------------------------------------------------ */
108
+ /* Helpers */
109
+ /* ------------------------------------------------------------------ */
110
+
111
+ function now(): string {
112
+ return new Date().toISOString();
113
+ }
114
+
115
+ /**
116
+ * True when `url` resolves to the same origin as the page. Relative URLs are
117
+ * same-origin by definition; absolute URLs are compared by origin. Used to gate
118
+ * header injection: the paikko trace/session headers are custom request headers,
119
+ * so adding them to a CROSS-origin request forces a CORS preflight that the
120
+ * third party's `Access-Control-Allow-Headers` will reject - breaking the
121
+ * consumer's cross-origin script/wasm/media loads. We therefore only inject on
122
+ * same-origin calls (the consumer's own backend, which `withCapture()` wraps).
123
+ * The cross-origin report POST sets the session header explicitly itself.
124
+ */
125
+ function isSameOrigin(url: string): boolean {
126
+ if (typeof window === "undefined" || !window.location) return true;
127
+ try {
128
+ return new URL(url, window.location.href).origin === window.location.origin;
129
+ } catch {
130
+ // Unparseable - treat as same-origin (relative-ish); never block the request.
131
+ return true;
132
+ }
133
+ }
134
+
135
+ function genTraceId(): TraceId {
136
+ // crypto.randomUUID is available in all modern browsers; fall back just in case.
137
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
138
+ return crypto.randomUUID();
139
+ }
140
+ return `tr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
141
+ }
142
+
143
+ /** Best-effort serialize an arbitrary console arg / body to a JSON-able value. */
144
+ function safeSerialize(value: unknown, maxChars: number): unknown {
145
+ if (value == null) return value;
146
+ if (typeof value === "string") {
147
+ return value.length > maxChars ? value.slice(0, maxChars) + "…[truncated]" : value;
148
+ }
149
+ if (
150
+ typeof value === "number" ||
151
+ typeof value === "boolean"
152
+ ) {
153
+ return value;
154
+ }
155
+ try {
156
+ const seen = new WeakSet<object>();
157
+ const json = JSON.stringify(value, (_k, v) => {
158
+ if (typeof v === "bigint") return v.toString();
159
+ if (typeof v === "function") return `[Function ${v.name || "anonymous"}]`;
160
+ if (v instanceof Error) {
161
+ return { name: v.name, message: v.message, stack: v.stack };
162
+ }
163
+ if (typeof v === "object" && v !== null) {
164
+ if (seen.has(v)) return "[Circular]";
165
+ seen.add(v);
166
+ }
167
+ return v;
168
+ });
169
+ if (json === undefined) return String(value);
170
+ const parsed = JSON.parse(json);
171
+ // Bound size after the fact so we don't ship megabytes.
172
+ if (json.length > maxChars) {
173
+ return { __truncated: true, preview: json.slice(0, maxChars) };
174
+ }
175
+ return parsed;
176
+ } catch {
177
+ try {
178
+ return String(value);
179
+ } catch {
180
+ return "[unserializable]";
181
+ }
182
+ }
183
+ }
184
+
185
+ /** Format a console.* call's args into a single human-readable message line. */
186
+ function formatConsoleMessage(args: unknown[]): string {
187
+ return args
188
+ .map((a) => {
189
+ if (typeof a === "string") return a;
190
+ if (a instanceof Error) return `${a.name}: ${a.message}`;
191
+ try {
192
+ return JSON.stringify(a);
193
+ } catch {
194
+ return String(a);
195
+ }
196
+ })
197
+ .join(" ");
198
+ }
199
+
200
+ /* ------------------------------------------------------------------ */
201
+ /* Capture core */
202
+ /* ------------------------------------------------------------------ */
203
+
204
+ type ConsoleMethod = "log" | "info" | "warn" | "error" | "debug";
205
+ const CONSOLE_METHODS: ConsoleMethod[] = ["log", "info", "warn", "error", "debug"];
206
+
207
+ /**
208
+ * The live capture controller. Construct once, call {@link install} on mount to
209
+ * patch console/fetch/XHR, and call {@link snapshotArtifacts} at report time to
210
+ * freeze the immutable payloads. {@link uninstall} restores the originals.
211
+ */
212
+ export class Capture {
213
+ private readonly config: CaptureConfig;
214
+
215
+ private consoleBuffer: ConsoleEntry[] = [];
216
+ private networkBuffer: NetworkEntry[] = [];
217
+
218
+ private installed = false;
219
+ private originalConsole: Partial<Record<ConsoleMethod, (...a: unknown[]) => void>> = {};
220
+ private originalFetch: typeof fetch | null = null;
221
+ private originalXhrOpen: typeof XMLHttpRequest.prototype.open | null = null;
222
+ private originalXhrSend: typeof XMLHttpRequest.prototype.send | null = null;
223
+ private originalXhrSetHeader: typeof XMLHttpRequest.prototype.setRequestHeader | null = null;
224
+
225
+ constructor(config: Partial<CaptureConfig> = {}) {
226
+ this.config = { ...DEFAULT_CONFIG, ...config };
227
+ }
228
+
229
+ /* ---- lifecycle ---- */
230
+
231
+ install(): void {
232
+ if (this.installed || typeof window === "undefined") return;
233
+ this.installed = true;
234
+ this.patchConsole();
235
+ this.patchFetch();
236
+ this.patchXhr();
237
+ }
238
+
239
+ uninstall(): void {
240
+ if (!this.installed) return;
241
+ this.installed = false;
242
+ for (const m of CONSOLE_METHODS) {
243
+ const orig = this.originalConsole[m];
244
+ if (orig) (console as unknown as Record<string, unknown>)[m] = orig;
245
+ }
246
+ if (this.originalFetch) window.fetch = this.originalFetch;
247
+ if (this.originalXhrOpen) XMLHttpRequest.prototype.open = this.originalXhrOpen;
248
+ if (this.originalXhrSend) XMLHttpRequest.prototype.send = this.originalXhrSend;
249
+ if (this.originalXhrSetHeader) {
250
+ XMLHttpRequest.prototype.setRequestHeader = this.originalXhrSetHeader;
251
+ }
252
+ this.originalConsole = {};
253
+ this.originalFetch = null;
254
+ this.originalXhrOpen = null;
255
+ this.originalXhrSend = null;
256
+ this.originalXhrSetHeader = null;
257
+ }
258
+
259
+ /* ---- console ring buffer ---- */
260
+
261
+ private patchConsole(): void {
262
+ for (const method of CONSOLE_METHODS) {
263
+ const original = console[method] as ((...a: unknown[]) => void) | undefined;
264
+ if (!original) continue;
265
+ this.originalConsole[method] = original.bind(console);
266
+ (console as unknown as Record<string, unknown>)[method] = (...args: unknown[]) => {
267
+ try {
268
+ this.pushConsole(method, args);
269
+ } catch {
270
+ /* never let capture break the app's logging */
271
+ }
272
+ this.originalConsole[method]?.(...args);
273
+ };
274
+ }
275
+ }
276
+
277
+ private pushConsole(level: ConsoleMethod, args: unknown[]): void {
278
+ const entry: ConsoleEntry = {
279
+ level,
280
+ message: redactText(formatConsoleMessage(args)),
281
+ args: args.map((a) => safeSerialize(redactConsoleArg(a), this.config.maxBodyChars)),
282
+ at: now(),
283
+ };
284
+ this.consoleBuffer.push(entry);
285
+ if (this.consoleBuffer.length > this.config.consoleBufferSize) {
286
+ this.consoleBuffer.splice(
287
+ 0,
288
+ this.consoleBuffer.length - this.config.consoleBufferSize,
289
+ );
290
+ }
291
+ }
292
+
293
+ /* ---- network: fetch ---- */
294
+
295
+ private patchFetch(): void {
296
+ if (typeof window.fetch !== "function") return;
297
+ this.originalFetch = window.fetch.bind(window);
298
+ const original = this.originalFetch;
299
+ const self = this;
300
+
301
+ window.fetch = async function patchedFetch(
302
+ input: RequestInfo | URL,
303
+ init?: RequestInit,
304
+ ): Promise<Response> {
305
+ const traceId = genTraceId();
306
+ const method = (
307
+ init?.method ??
308
+ (input instanceof Request ? input.method : undefined) ??
309
+ "GET"
310
+ ).toUpperCase();
311
+ const url =
312
+ typeof input === "string"
313
+ ? input
314
+ : input instanceof URL
315
+ ? input.toString()
316
+ : input.url;
317
+
318
+ // Inject the trace/session headers ONLY on same-origin calls. Adding
319
+ // custom headers to a cross-origin fetch forces a CORS preflight the third
320
+ // party will reject, breaking the consumer's cross-origin loads; such a
321
+ // request is still recorded below, just with its headers untouched.
322
+ let callInit = init;
323
+ if (isSameOrigin(url)) {
324
+ const headers = new Headers(
325
+ init?.headers ?? (input instanceof Request ? input.headers : undefined),
326
+ );
327
+ headers.set(TRACE_HEADER, traceId);
328
+ headers.set(SESSION_HEADER, getSessionId());
329
+ callInit = { ...init, headers };
330
+ }
331
+
332
+ const reqBody = await self.readRequestBody(input, init);
333
+ const startedAt = now();
334
+ const startMs = performance.now();
335
+
336
+ const entry: NetworkEntry = {
337
+ traceId,
338
+ method,
339
+ url: redactUrl(url),
340
+ status: null,
341
+ reqBody,
342
+ resBody: null,
343
+ startedAt,
344
+ durationMs: null,
345
+ };
346
+ self.pushNetwork(entry);
347
+
348
+ try {
349
+ const res = await original(input, callInit);
350
+ entry.status = res.status;
351
+ entry.durationMs = Math.round(performance.now() - startMs);
352
+ // Clone so the app still consumes the body.
353
+ entry.resBody = await self.readResponseBody(res.clone());
354
+ return res;
355
+ } catch (err) {
356
+ entry.durationMs = Math.round(performance.now() - startMs);
357
+ entry.resBody = safeSerialize(err, self.config.maxBodyChars);
358
+ throw err;
359
+ }
360
+ };
361
+ }
362
+
363
+ private async readRequestBody(
364
+ input: RequestInfo | URL,
365
+ init?: RequestInit,
366
+ ): Promise<unknown> {
367
+ try {
368
+ const body =
369
+ init?.body ?? (input instanceof Request ? await input.clone().text() : undefined);
370
+ if (body == null) return null;
371
+ if (typeof body === "string") {
372
+ try {
373
+ return safeSerialize(redactDeep(JSON.parse(body)), this.config.maxBodyChars);
374
+ } catch {
375
+ return safeSerialize(redactBodyString(body), this.config.maxBodyChars);
376
+ }
377
+ }
378
+ // FormData / Blob / etc. - record a marker rather than the raw object.
379
+ return `[${(body as object).constructor?.name ?? typeof body}]`;
380
+ } catch {
381
+ return null;
382
+ }
383
+ }
384
+
385
+ private async readResponseBody(res: Response): Promise<unknown> {
386
+ try {
387
+ const ct = res.headers.get("content-type") ?? "";
388
+ const text = await res.text();
389
+ if (!text) return null;
390
+ if (ct.includes("application/json")) {
391
+ try {
392
+ return safeSerialize(redactDeep(JSON.parse(text)), this.config.maxBodyChars);
393
+ } catch {
394
+ return safeSerialize(redactBodyString(text), this.config.maxBodyChars);
395
+ }
396
+ }
397
+ return safeSerialize(redactBodyString(text), this.config.maxBodyChars);
398
+ } catch {
399
+ return null;
400
+ }
401
+ }
402
+
403
+ /* ---- network: XHR ---- */
404
+
405
+ private patchXhr(): void {
406
+ if (typeof XMLHttpRequest === "undefined") return;
407
+ const self = this;
408
+ const proto = XMLHttpRequest.prototype;
409
+
410
+ this.originalXhrOpen = proto.open;
411
+ this.originalXhrSend = proto.send;
412
+ this.originalXhrSetHeader = proto.setRequestHeader;
413
+
414
+ const META = Symbol.for("paikko.xhr.meta");
415
+ type Meta = {
416
+ traceId: TraceId;
417
+ method: string;
418
+ url: string;
419
+ entry?: NetworkEntry;
420
+ startMs?: number;
421
+ headerInjected?: boolean;
422
+ };
423
+
424
+ const openOrig = this.originalXhrOpen;
425
+ proto.open = function open(
426
+ this: XMLHttpRequest,
427
+ method: string,
428
+ url: string | URL,
429
+ ...rest: unknown[]
430
+ ) {
431
+ const meta: Meta = {
432
+ traceId: genTraceId(),
433
+ method: method.toUpperCase(),
434
+ url: url.toString(),
435
+ };
436
+ (this as unknown as Record<symbol, Meta>)[META] = meta;
437
+ // @ts-expect-error - forward through to the native signature
438
+ return openOrig.call(this, method, url, ...rest);
439
+ };
440
+
441
+ const setHeaderOrig = this.originalXhrSetHeader;
442
+ proto.setRequestHeader = function setRequestHeader(
443
+ this: XMLHttpRequest,
444
+ name: string,
445
+ value: string,
446
+ ) {
447
+ return setHeaderOrig.call(this, name, value);
448
+ };
449
+
450
+ const sendOrig = this.originalXhrSend;
451
+ proto.send = function send(this: XMLHttpRequest, body?: Document | XMLHttpRequestBodyInit | null) {
452
+ const meta = (this as unknown as Record<symbol, Meta>)[META];
453
+ if (meta) {
454
+ try {
455
+ // Same-origin only - see isSameOrigin: custom headers on a cross-origin
456
+ // XHR trip a CORS preflight the third party rejects.
457
+ if (!meta.headerInjected && isSameOrigin(meta.url)) {
458
+ setHeaderOrig.call(this, TRACE_HEADER, meta.traceId);
459
+ setHeaderOrig.call(this, SESSION_HEADER, getSessionId());
460
+ meta.headerInjected = true;
461
+ }
462
+ } catch {
463
+ /* setRequestHeader can throw if state is wrong; ignore */
464
+ }
465
+ const entry: NetworkEntry = {
466
+ traceId: meta.traceId,
467
+ method: meta.method,
468
+ url: redactUrl(meta.url),
469
+ status: null,
470
+ reqBody: self.serializeXhrBody(body),
471
+ resBody: null,
472
+ startedAt: now(),
473
+ durationMs: null,
474
+ };
475
+ meta.entry = entry;
476
+ meta.startMs = performance.now();
477
+ self.pushNetwork(entry);
478
+
479
+ this.addEventListener("loadend", () => {
480
+ if (!meta.entry) return;
481
+ meta.entry.status = this.status || null;
482
+ meta.entry.durationMs =
483
+ meta.startMs != null ? Math.round(performance.now() - meta.startMs) : null;
484
+ meta.entry.resBody = self.readXhrResponse(this);
485
+ });
486
+ }
487
+ return sendOrig.call(this, body ?? null);
488
+ };
489
+ }
490
+
491
+ private serializeXhrBody(body?: Document | XMLHttpRequestBodyInit | null): unknown {
492
+ if (body == null) return null;
493
+ if (typeof body === "string") {
494
+ try {
495
+ return safeSerialize(redactDeep(JSON.parse(body)), this.config.maxBodyChars);
496
+ } catch {
497
+ return safeSerialize(redactBodyString(body), this.config.maxBodyChars);
498
+ }
499
+ }
500
+ return `[${(body as object).constructor?.name ?? typeof body}]`;
501
+ }
502
+
503
+ private readXhrResponse(xhr: XMLHttpRequest): unknown {
504
+ try {
505
+ const type = xhr.responseType;
506
+ if (type === "" || type === "text") {
507
+ const text = xhr.responseText;
508
+ if (!text) return null;
509
+ try {
510
+ return safeSerialize(redactDeep(JSON.parse(text)), this.config.maxBodyChars);
511
+ } catch {
512
+ return safeSerialize(redactBodyString(text), this.config.maxBodyChars);
513
+ }
514
+ }
515
+ if (type === "json") return safeSerialize(redactDeep(xhr.response), this.config.maxBodyChars);
516
+ return `[responseType:${type}]`;
517
+ } catch {
518
+ return null;
519
+ }
520
+ }
521
+
522
+ private pushNetwork(entry: NetworkEntry): void {
523
+ this.networkBuffer.push(entry);
524
+ if (this.networkBuffer.length > this.config.networkBufferSize) {
525
+ this.networkBuffer.splice(
526
+ 0,
527
+ this.networkBuffer.length - this.config.networkBufferSize,
528
+ );
529
+ }
530
+ }
531
+
532
+ /* ---- on-demand snapshots ---- */
533
+
534
+ /** Console ring buffer, oldest first. Deep-cloned so the snapshot is frozen. */
535
+ snapshotConsole(): ConsoleArtifact {
536
+ return this.consoleBuffer.map((e) => ({ ...e, args: e.args ? [...e.args] : undefined }));
537
+ }
538
+
539
+ /** Network log, oldest first. Deep-cloned so later mutation can't leak in. */
540
+ snapshotNetwork(): NetworkArtifact {
541
+ return this.networkBuffer.map((e) => ({ ...e }));
542
+ }
543
+
544
+ /** Read the mandated client-state store via the injected getter. */
545
+ snapshotClientState(): ClientStateArtifact {
546
+ try {
547
+ const state = this.config.getClientState();
548
+ // Redact: app stores (Redux/Zustand) routinely hold access tokens / session.
549
+ return (
550
+ (safeSerialize(redactDeep(state), this.config.maxBodyChars) as ClientStateArtifact) ?? {}
551
+ );
552
+ } catch {
553
+ return {};
554
+ }
555
+ }
556
+
557
+ /** Returns true if there is anything in the network buffer. */
558
+ hasNetwork(): boolean {
559
+ return this.networkBuffer.length > 0;
560
+ }
561
+ }
562
+
563
+ /* ------------------------------------------------------------------ */
564
+ /* Stateless snapshots (no patching required) */
565
+ /* ------------------------------------------------------------------ */
566
+
567
+ /*
568
+ * Redaction. Capture ships storage, cookies, client state, console, and
569
+ * request/response bodies/URLs to the ticket backend (a possibly different
570
+ * origin), so credentials/PII are masked BEFORE they leave the page - the agent
571
+ * still sees the SHAPE, not the secret. Two complementary passes:
572
+ * 1. by KEY - a property/param/storage key whose name names a secret.
573
+ * 2. by VALUE - a string that LOOKS like a secret (JWT, known token prefixes,
574
+ * long high-entropy blobs) regardless of its key, since apps routinely store
575
+ * a token under a benign key like `data`.
576
+ * Key matching is token-based (split camelCase/snake/kebab and match whole
577
+ * tokens) so it does NOT over-redact benign keys like `author`, `dashboard`,
578
+ * `cardId`, `oauth_client_id` that merely contain a sensitive substring.
579
+ */
580
+ const REDACTED = "[redacted]";
581
+
582
+ /** Key name-segments that denote a secret/PII value. Matched as whole tokens. */
583
+ const SENSITIVE_TOKENS = new Set([
584
+ "password", "passwd", "pwd", "pass", "secret", "secrets", "token", "tokens",
585
+ "auth", "authorization", "jwt", "bearer", "apikey", "apitoken", "accesskey",
586
+ "accesstoken", "refreshtoken", "refresh", "session", "sessionid", "sid",
587
+ "cookie", "credential", "credentials", "privatekey", "clientsecret", "otp",
588
+ "pin", "ssn", "cvv", "cvc", "cardnumber", "creditcard", "pan",
589
+ ]);
590
+ /** Unambiguous compound substrings (no benign collisions) to also catch. */
591
+ const SENSITIVE_KEY_SUBSTR =
592
+ /password|passwd|secret|api[-_]?key|access[-_]?token|refresh[-_]?token|client[-_]?secret|private[-_]?key|credential/i;
593
+
594
+ /** Split a key into lowercase word tokens across camelCase / snake / kebab. */
595
+ function keyTokens(key: string): string[] {
596
+ return key
597
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
598
+ .split(/[^A-Za-z0-9]+/)
599
+ .map((t) => t.toLowerCase())
600
+ .filter(Boolean);
601
+ }
602
+
603
+ /** True when a key NAME denotes a secret (token-exact, or unambiguous substring). */
604
+ function isSensitiveKey(key: string): boolean {
605
+ if (keyTokens(key).some((t) => SENSITIVE_TOKENS.has(t))) return true;
606
+ return SENSITIVE_KEY_SUBSTR.test(key);
607
+ }
608
+
609
+ const JWT_RE = /eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/;
610
+ /**
611
+ * Known secret-token prefixes. Stripe/OpenAI `sk_`/`sk-` allow INTERNAL `_`/`-`
612
+ * in the run so `sk_live_...`/`sk_test_...` are caught (their second underscore
613
+ * would otherwise cut the run short). Also GitHub gh*_, AWS AKIA, Google AIza,
614
+ * Slack xox*.
615
+ */
616
+ const TOKEN_PREFIX_RE =
617
+ /\b(?:sk|rk)[-_][A-Za-z0-9][A-Za-z0-9_-]{10,}\b|\bgh[pousr]_[A-Za-z0-9]{16,}\b|\bAKIA[0-9A-Z]{16}\b|\bAIza[0-9A-Za-z_-]{20,}\b|\bxox[baprs]-[0-9A-Za-z-]{8,}\b/;
618
+
619
+ /** A canonical UUID - an entity id, not a secret; exempt from the entropy heuristic. */
620
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
621
+
622
+ /** True when a STRING VALUE looks like a secret regardless of the key it's under. */
623
+ function looksLikeSecretValue(s: unknown): boolean {
624
+ if (typeof s !== "string" || s.length < 16) return false;
625
+ if (JWT_RE.test(s) || TOKEN_PREFIX_RE.test(s)) return true;
626
+ // UUIDs are 36-char hyphenated entity ids - keep them so the agent can still
627
+ // follow `GET /orders/<uuid>` etc. (they'd otherwise trip the entropy branch).
628
+ if (UUID_RE.test(s)) return false;
629
+ // Long, spaceless, base64url/hex blob with >=2 character classes (or pure hex):
630
+ // catches opaque access tokens stored under benign keys, without masking prose.
631
+ if (s.length >= 32 && !/\s/.test(s) && /^[A-Za-z0-9._\-+/=]+$/.test(s)) {
632
+ const classes = [/[a-z]/, /[A-Z]/, /[0-9]/].filter((re) => re.test(s)).length;
633
+ return classes >= 2 || /^[0-9a-f]{32,}$/i.test(s);
634
+ }
635
+ return false;
636
+ }
637
+
638
+ /** Mask secret-shaped substrings inline within a free-text string (console, non-JSON bodies). */
639
+ function redactText(s: string): string {
640
+ if (!s) return s;
641
+ return s
642
+ .replace(new RegExp(JWT_RE, "g"), REDACTED)
643
+ .replace(new RegExp(TOKEN_PREFIX_RE, "g"), REDACTED)
644
+ .replace(
645
+ /\b(bearer|token|password|secret|api[-_]?key)\b(\s*[:=]\s*|\s+)([^\s"',;&)]{8,})/gi,
646
+ (_m, label, sep) => `${label}${sep}${REDACTED}`,
647
+ );
648
+ }
649
+
650
+ /** Mask a flat string map (storage / cookies) by key name OR value shape. */
651
+ function redactStringMap(map: Record<string, string>): Record<string, string> {
652
+ const out: Record<string, string> = {};
653
+ for (const k of Object.keys(map)) {
654
+ out[k] = isSensitiveKey(k) || looksLikeSecretValue(map[k]) ? REDACTED : map[k];
655
+ }
656
+ return out;
657
+ }
658
+
659
+ /** Recursively mask an arbitrary JSON value: by key name, and by leaf-value shape. */
660
+ function redactDeep(value: unknown, depth = 0): unknown {
661
+ if (depth > 8) return value;
662
+ if (typeof value === "string") return looksLikeSecretValue(value) ? REDACTED : value;
663
+ if (value == null || typeof value !== "object") return value;
664
+ if (Array.isArray(value)) return value.map((v) => redactDeep(v, depth + 1));
665
+ const out: Record<string, unknown> = {};
666
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
667
+ out[k] = isSensitiveKey(k) ? REDACTED : redactDeep(v, depth + 1);
668
+ }
669
+ return out;
670
+ }
671
+
672
+ /** Mask sensitive query params (by name or value shape) in a URL string. */
673
+ function redactUrl(url: string): string {
674
+ try {
675
+ const base = typeof window !== "undefined" ? window.location?.href : undefined;
676
+ const u = new URL(url, base);
677
+ let changed = false;
678
+ for (const key of [...u.searchParams.keys()]) {
679
+ if (isSensitiveKey(key) || looksLikeSecretValue(u.searchParams.get(key) ?? "")) {
680
+ u.searchParams.set(key, REDACTED);
681
+ changed = true;
682
+ }
683
+ }
684
+ return changed ? u.toString() : url;
685
+ } catch {
686
+ return url;
687
+ }
688
+ }
689
+
690
+ /** Redact a non-JSON request/response body string: form-urlencoded params, else free text. */
691
+ function redactBodyString(s: string): string {
692
+ // form-urlencoded shape: key=value(&key=value)*, no whitespace.
693
+ if (/^[^=&\s]+=[^&\s]*(?:&[^=&\s]+=[^&\s]*)*$/.test(s)) {
694
+ try {
695
+ const p = new URLSearchParams(s);
696
+ let changed = false;
697
+ for (const key of [...p.keys()]) {
698
+ if (isSensitiveKey(key) || looksLikeSecretValue(p.get(key) ?? "")) {
699
+ p.set(key, REDACTED);
700
+ changed = true;
701
+ }
702
+ }
703
+ if (changed) return p.toString();
704
+ } catch {
705
+ /* fall through to text redaction */
706
+ }
707
+ }
708
+ return redactText(s);
709
+ }
710
+
711
+ /** Redact a single console argument: objects deep, strings by shape + inline. */
712
+ function redactConsoleArg(a: unknown): unknown {
713
+ if (typeof a === "string") return looksLikeSecretValue(a) ? REDACTED : redactText(a);
714
+ return redactDeep(a);
715
+ }
716
+
717
+ /** Snapshot localStorage / sessionStorage / cookies as flat string maps. */
718
+ export function snapshotStorage(): StorageArtifact {
719
+ const readWebStorage = (store: Storage | undefined): Record<string, string> => {
720
+ const out: Record<string, string> = {};
721
+ if (!store) return out;
722
+ try {
723
+ for (let i = 0; i < store.length; i++) {
724
+ const key = store.key(i);
725
+ if (key == null) continue;
726
+ out[key] = store.getItem(key) ?? "";
727
+ }
728
+ } catch {
729
+ /* storage access can throw in some sandboxed contexts */
730
+ }
731
+ return out;
732
+ };
733
+
734
+ const readCookies = (): Record<string, string> => {
735
+ const out: Record<string, string> = {};
736
+ if (typeof document === "undefined" || !document.cookie) return out;
737
+ for (const pair of document.cookie.split(";")) {
738
+ const idx = pair.indexOf("=");
739
+ if (idx === -1) continue;
740
+ const key = pair.slice(0, idx).trim();
741
+ if (!key) continue;
742
+ out[key] = decodeURIComponent(pair.slice(idx + 1).trim());
743
+ }
744
+ return out;
745
+ };
746
+
747
+ return {
748
+ local: redactStringMap(
749
+ readWebStorage(typeof window !== "undefined" ? window.localStorage : undefined),
750
+ ),
751
+ session: redactStringMap(
752
+ readWebStorage(typeof window !== "undefined" ? window.sessionStorage : undefined),
753
+ ),
754
+ cookies: redactStringMap(readCookies()),
755
+ };
756
+ }
757
+
758
+ /**
759
+ * Snapshot the DOM. `targetSelector` points back at the clicked element within
760
+ * the serialized `html`. Captures the full document outerHTML plus viewport.
761
+ */
762
+ export function snapshotDom(targetSelector: string | null): DomArtifact {
763
+ const html =
764
+ typeof document !== "undefined" && document.documentElement
765
+ ? document.documentElement.outerHTML
766
+ : "";
767
+ return {
768
+ html,
769
+ targetSelector,
770
+ viewport: {
771
+ width: typeof window !== "undefined" ? Math.round(window.innerWidth) : 0,
772
+ height: typeof window !== "undefined" ? Math.round(window.innerHeight) : 0,
773
+ },
774
+ };
775
+ }
776
+
777
+ /**
778
+ * Longest side (px) the captured screenshot is downscaled to. Keeps the
779
+ * base64 payload small - a JPEG at this cap lands well under ~1MB.
780
+ */
781
+ const SCREENSHOT_MAX_SIDE = 1280;
782
+
783
+ /** JPEG quality for the exported screenshot (0..1). Lower = smaller payload. */
784
+ const SCREENSHOT_JPEG_QUALITY = 0.7;
785
+
786
+ /**
787
+ * Render the page to an image at report time, so the agent (which can see images)
788
+ * and the human reviewer can directly judge visual / "looks wrong" reports that a
789
+ * DOM/CSS snapshot can't convey.
790
+ *
791
+ * Best-effort and non-blocking: html2canvas is loaded ONLY here, via a lazy
792
+ * dynamic import, so it never sits on the page-load critical path - it is fetched
793
+ * the first time a report is actually filed. Any failure (import failed, canvas
794
+ * tainted, unsupported context) returns `null` and the screenshot artifact is
795
+ * simply omitted; it must never break report submission.
796
+ *
797
+ * The widget's own UI (FAB / report form / nav) carries `data-paikko-ui`; we pass
798
+ * html2canvas's `ignoreElements` so none of it appears in the shot - the reviewer
799
+ * sees the page as the user saw it, not the open form.
800
+ *
801
+ * Size control: the longest side is capped to {@link SCREENSHOT_MAX_SIDE} via the
802
+ * `scale` option and the image is exported as JPEG at
803
+ * {@link SCREENSHOT_JPEG_QUALITY}.
804
+ */
805
+ export async function snapshotScreenshot(): Promise<ScreenshotArtifact | null> {
806
+ if (typeof document === "undefined" || typeof window === "undefined") {
807
+ return null;
808
+ }
809
+ try {
810
+ const target = document.body ?? document.documentElement;
811
+ if (!target) return null;
812
+
813
+ const mod = await import("html2canvas");
814
+ const html2canvas = (mod.default ?? mod) as typeof import("html2canvas").default;
815
+
816
+ // Downscale: cap the longest side. html2canvas renders at CSS px * scale, so
817
+ // a scale < 1 shrinks the output. Never upscale (cap scale at the device-ish
818
+ // baseline of 1) so small pages aren't blown up.
819
+ const longestSide = Math.max(
820
+ target.scrollWidth || window.innerWidth,
821
+ target.scrollHeight || window.innerHeight,
822
+ 1,
823
+ );
824
+ const scale = Math.min(1, SCREENSHOT_MAX_SIDE / longestSide);
825
+
826
+ const canvas = await html2canvas(target, {
827
+ scale,
828
+ logging: false,
829
+ useCORS: true,
830
+ // Exclude the paikko widget UI (FAB / form / nav) from the shot.
831
+ ignoreElements: (el: Element) =>
832
+ el instanceof Element && el.closest("[data-paikko-ui]") !== null,
833
+ });
834
+
835
+ const dataUrl = canvas.toDataURL("image/jpeg", SCREENSHOT_JPEG_QUALITY);
836
+ if (!dataUrl || !dataUrl.startsWith("data:image/")) return null;
837
+
838
+ return {
839
+ dataUrl,
840
+ width: canvas.width,
841
+ height: canvas.height,
842
+ format: "jpeg",
843
+ };
844
+ } catch {
845
+ // Best-effort: a failed screenshot must never block a report.
846
+ return null;
847
+ }
848
+ }
849
+
850
+ /* ------------------------------------------------------------------ */
851
+ /* Element resolution (point mode) */
852
+ /* ------------------------------------------------------------------ */
853
+
854
+ /**
855
+ * Resolve a clicked element to a {@link ReportTarget}: a CSS selector that finds
856
+ * it again, its build-time `data-src` provenance, and the nearest owning
857
+ * component's name (`data-component`). Walks ancestors for provenance because
858
+ * leaf nodes (a bare <span>) often don't carry the attribute themselves.
859
+ */
860
+ export function resolveTarget(el: Element | null): ReportTarget {
861
+ if (!el) return { selector: null, src: null, component: null };
862
+
863
+ let src: string | null = null;
864
+ let component: string | null = null;
865
+ let cursor: Element | null = el;
866
+ while (cursor && (src == null || component == null)) {
867
+ if (src == null) {
868
+ const s = cursor.getAttribute(SRC_ATTR);
869
+ if (s) src = s;
870
+ }
871
+ if (component == null) {
872
+ const c = cursor.getAttribute(COMPONENT_ATTR);
873
+ if (c) component = c;
874
+ }
875
+ cursor = cursor.parentElement;
876
+ }
877
+
878
+ return { selector: buildSelector(el), src, component };
879
+ }
880
+
881
+ /**
882
+ * Build a reasonably stable, reasonably unique CSS selector for an element.
883
+ * Prefers id, else builds a path of tag + nth-of-type from the nearest id'd
884
+ * ancestor (or document root).
885
+ */
886
+ export function buildSelector(el: Element): string {
887
+ if (el.id) return `#${cssEscape(el.id)}`;
888
+
889
+ const parts: string[] = [];
890
+ let cursor: Element | null = el;
891
+ while (cursor && cursor.nodeType === Node.ELEMENT_NODE) {
892
+ if (cursor.id) {
893
+ parts.unshift(`#${cssEscape(cursor.id)}`);
894
+ break;
895
+ }
896
+ const tag = cursor.tagName.toLowerCase();
897
+ if (tag === "html" || tag === "body") {
898
+ parts.unshift(tag);
899
+ break;
900
+ }
901
+ const parent: Element | null = cursor.parentElement;
902
+ if (!parent) {
903
+ parts.unshift(tag);
904
+ break;
905
+ }
906
+ const sameTag = Array.from(parent.children).filter(
907
+ (c) => c.tagName === cursor!.tagName,
908
+ );
909
+ if (sameTag.length > 1) {
910
+ const idx = sameTag.indexOf(cursor) + 1;
911
+ parts.unshift(`${tag}:nth-of-type(${idx})`);
912
+ } else {
913
+ parts.unshift(tag);
914
+ }
915
+ cursor = parent;
916
+ }
917
+ return parts.join(" > ");
918
+ }
919
+
920
+ function cssEscape(value: string): string {
921
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
922
+ return CSS.escape(value);
923
+ }
924
+ return value.replace(/([^\w-])/g, "\\$1");
925
+ }
926
+
927
+ /* ------------------------------------------------------------------ */
928
+ /* Bundle assembly */
929
+ /* ------------------------------------------------------------------ */
930
+
931
+ /** The set of artifacts captured at report time, ready to inline into a bundle. */
932
+ export interface CapturedArtifacts {
933
+ console?: ConsoleArtifact;
934
+ network?: NetworkArtifact;
935
+ clientState?: ClientStateArtifact;
936
+ storage?: StorageArtifact;
937
+ dom?: DomArtifact;
938
+ screenshot?: ScreenshotArtifact;
939
+ }
940
+
941
+ /**
942
+ * Freeze every available artifact into immutable, contract-valid payloads. This
943
+ * is the report-time photograph: the live buffers are read, the stateless
944
+ * snapshots are taken, and each payload is parsed through its contract schema so
945
+ * only valid data leaves the client. Note `trace` is intentionally absent - the
946
+ * backend trace artifact is produced server-side from the propagated traceIds.
947
+ *
948
+ * Async because the `screenshot` artifact is rendered lazily (html2canvas is
949
+ * dynamically imported only here, at report time) and is best-effort: if it fails
950
+ * or html2canvas isn't available, the screenshot is simply omitted and the rest of
951
+ * the snapshot is returned unchanged - a failed screenshot never breaks a report.
952
+ */
953
+ export async function snapshotArtifacts(
954
+ capture: Capture,
955
+ targetSelector: string | null,
956
+ ): Promise<CapturedArtifacts> {
957
+ const out: CapturedArtifacts = {};
958
+
959
+ const console_ = ArtifactPayloadSchemas.console.safeParse(capture.snapshotConsole());
960
+ if (console_.success && console_.data.length) out.console = console_.data;
961
+
962
+ const network = ArtifactPayloadSchemas.network.safeParse(capture.snapshotNetwork());
963
+ if (network.success && network.data.length) out.network = network.data;
964
+
965
+ const clientState = ArtifactPayloadSchemas.clientState.safeParse(
966
+ capture.snapshotClientState(),
967
+ );
968
+ if (clientState.success && Object.keys(clientState.data).length) {
969
+ out.clientState = clientState.data;
970
+ }
971
+
972
+ const storage = ArtifactPayloadSchemas.storage.safeParse(snapshotStorage());
973
+ if (storage.success) out.storage = storage.data;
974
+
975
+ const dom = ArtifactPayloadSchemas.dom.safeParse(snapshotDom(targetSelector));
976
+ if (dom.success && dom.data.html) out.dom = dom.data;
977
+
978
+ // Best-effort screenshot: lazily rendered, validated against the contract, and
979
+ // omitted on any failure so it can never block report submission.
980
+ try {
981
+ const shot = await snapshotScreenshot();
982
+ if (shot) {
983
+ const screenshot = ArtifactPayloadSchemas.screenshot.safeParse(shot);
984
+ if (screenshot.success) out.screenshot = screenshot.data;
985
+ }
986
+ } catch {
987
+ /* never let the screenshot break the snapshot */
988
+ }
989
+
990
+ return out;
991
+ }