@fixback/sdk-core 0.2.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/LICENSE +21 -0
- package/README.md +19 -0
- package/dist/annotation.js +18 -0
- package/dist/annotation.js.map +1 -0
- package/dist/backoff.js +79 -0
- package/dist/backoff.js.map +1 -0
- package/dist/breadcrumb.js +15 -0
- package/dist/breadcrumb.js.map +1 -0
- package/dist/fingerprint.js +108 -0
- package/dist/fingerprint.js.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/package.json +3 -0
- package/dist/scrub.js +216 -0
- package/dist/scrub.js.map +1 -0
- package/dist/wire.js +16 -0
- package/dist/wire.js.map +1 -0
- package/package.json +50 -0
- package/src/annotation.ts +104 -0
- package/src/backoff.test.ts +94 -0
- package/src/backoff.ts +83 -0
- package/src/breadcrumb.ts +169 -0
- package/src/fingerprint.test.ts +96 -0
- package/src/fingerprint.ts +112 -0
- package/src/index.ts +63 -0
- package/src/scrub.test.ts +215 -0
- package/src/scrub.ts +226 -0
- package/src/wire.ts +116 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@fixback/sdk-core` — the runtime-agnostic core shared by the Fixback capture
|
|
3
|
+
* SDKs (ADR-0028). Pure fingerprinting, scrubbing, and backoff logic plus the
|
|
4
|
+
* shared ingest wire shapes — no DOM, no Node built-ins, no rrweb.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Fingerprint (ADR-0027) — the cross-surface grouping key.
|
|
8
|
+
export { computeFingerprint, extractTopFrames, hashString, normalize } from "./fingerprint";
|
|
9
|
+
|
|
10
|
+
// Scrub choke point (spec 0003 §C).
|
|
11
|
+
export {
|
|
12
|
+
applyDefaultScrub,
|
|
13
|
+
type BeforeSend,
|
|
14
|
+
type BeforeSendOptions,
|
|
15
|
+
redactPii,
|
|
16
|
+
runBeforeSend,
|
|
17
|
+
scrubUrl,
|
|
18
|
+
} from "./scrub";
|
|
19
|
+
|
|
20
|
+
// Backoff (spec 0003 §E/§H) — honouring ingest's 429 / Retry-After.
|
|
21
|
+
export {
|
|
22
|
+
AutoReportBackoff,
|
|
23
|
+
type Clock,
|
|
24
|
+
DEFAULT_RETRY_AFTER_SECONDS,
|
|
25
|
+
parseRetryAfter,
|
|
26
|
+
} from "./backoff";
|
|
27
|
+
|
|
28
|
+
// Ingest wire shapes.
|
|
29
|
+
export type {
|
|
30
|
+
CaptureEnvironment,
|
|
31
|
+
CapturedFrame,
|
|
32
|
+
FeedbackSource,
|
|
33
|
+
Platform,
|
|
34
|
+
ReportContent,
|
|
35
|
+
} from "./wire";
|
|
36
|
+
|
|
37
|
+
// Annotation wire types (spec 0003 §D).
|
|
38
|
+
export type {
|
|
39
|
+
Annotation,
|
|
40
|
+
ArrowMark,
|
|
41
|
+
BoxMark,
|
|
42
|
+
DrawTool,
|
|
43
|
+
ElementRect,
|
|
44
|
+
Mark,
|
|
45
|
+
PenMark,
|
|
46
|
+
Point,
|
|
47
|
+
Rect,
|
|
48
|
+
SelectedElement,
|
|
49
|
+
TextMark,
|
|
50
|
+
} from "./annotation";
|
|
51
|
+
|
|
52
|
+
// Breadcrumb / trace wire types (spec #122).
|
|
53
|
+
export type {
|
|
54
|
+
Breadcrumb,
|
|
55
|
+
BreadcrumbCategory,
|
|
56
|
+
BreadcrumbData,
|
|
57
|
+
BreadcrumbLevel,
|
|
58
|
+
ConsoleArg,
|
|
59
|
+
ConsoleArgType,
|
|
60
|
+
NetworkApi,
|
|
61
|
+
NetworkOutcome,
|
|
62
|
+
SourceLocation,
|
|
63
|
+
} from "./breadcrumb";
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { Breadcrumb } from "./breadcrumb";
|
|
4
|
+
import type { ReportContent } from "./wire";
|
|
5
|
+
import { applyDefaultScrub, redactPii, runBeforeSend, scrubUrl } from "./scrub";
|
|
6
|
+
|
|
7
|
+
describe("scrubUrl", () => {
|
|
8
|
+
it("strips the query string entirely", () => {
|
|
9
|
+
expect(scrubUrl("https://acme.app/checkout?token=abc123&next=/pay")).toBe(
|
|
10
|
+
"https://acme.app/checkout",
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("strips userinfo credentials", () => {
|
|
15
|
+
expect(scrubUrl("https://user:s3cret@acme.app/data")).toBe(
|
|
16
|
+
"https://acme.app/data",
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("drops a token-bearing fragment but keeps a plain hash route", () => {
|
|
21
|
+
expect(scrubUrl("https://acme.app/#access_token=xyz&type=bearer")).toBe(
|
|
22
|
+
"https://acme.app/",
|
|
23
|
+
);
|
|
24
|
+
expect(scrubUrl("https://acme.app/dashboard#/checkout")).toBe(
|
|
25
|
+
"https://acme.app/dashboard#/checkout",
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("leaves a clean URL and relative paths untouched", () => {
|
|
30
|
+
expect(scrubUrl("https://acme.app/checkout")).toBe(
|
|
31
|
+
"https://acme.app/checkout",
|
|
32
|
+
);
|
|
33
|
+
expect(scrubUrl("/orders/42")).toBe("/orders/42");
|
|
34
|
+
expect(scrubUrl("/search?q=shoes")).toBe("/search");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("redacts an email in a path segment (#139)", () => {
|
|
38
|
+
expect(scrubUrl("https://acme.app/users/jane.doe@acme.app/orders")).toBe(
|
|
39
|
+
"https://acme.app/users/[redacted-email]/orders",
|
|
40
|
+
);
|
|
41
|
+
// Relative paths are swept too.
|
|
42
|
+
expect(scrubUrl("/u/jane@acme.app")).toBe("/u/[redacted-email]");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("redacts a long digit run in a path segment but keeps short ids (#139)", () => {
|
|
46
|
+
expect(scrubUrl("https://acme.app/card/4111111111111111/pay")).toBe(
|
|
47
|
+
"https://acme.app/card/[redacted-number]/pay",
|
|
48
|
+
);
|
|
49
|
+
// A short id (a page number, a small resource id) is left alone.
|
|
50
|
+
expect(scrubUrl("https://acme.app/orders/42")).toBe(
|
|
51
|
+
"https://acme.app/orders/42",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("never redacts the authority — a host or port with digits is preserved (#139)", () => {
|
|
56
|
+
expect(scrubUrl("https://api2.acme.app/data")).toBe(
|
|
57
|
+
"https://api2.acme.app/data",
|
|
58
|
+
);
|
|
59
|
+
// A host that is itself a long digit run is authority, not a path segment.
|
|
60
|
+
expect(scrubUrl("https://12345678.acme.app/data")).toBe(
|
|
61
|
+
"https://12345678.acme.app/data",
|
|
62
|
+
);
|
|
63
|
+
expect(scrubUrl("https://acme.app:8080/orders/99999999")).toBe(
|
|
64
|
+
"https://acme.app:8080/orders/[redacted-number]",
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("scrubs both the query string and path PII together (#139)", () => {
|
|
69
|
+
expect(
|
|
70
|
+
scrubUrl("https://acme.app/users/jane@acme.app/44444444?token=abc"),
|
|
71
|
+
).toBe("https://acme.app/users/[redacted-email]/[redacted-number]");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("redactPii", () => {
|
|
76
|
+
it("redacts email addresses", () => {
|
|
77
|
+
expect(redactPii("contact jane.doe@acme.app about it")).toBe(
|
|
78
|
+
"contact [redacted-email] about it",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("redacts long digit runs but keeps short ones", () => {
|
|
83
|
+
expect(redactPii("card 4111111111111111 failed")).toBe(
|
|
84
|
+
"card [redacted-number] failed",
|
|
85
|
+
);
|
|
86
|
+
// A short run (an HTTP status, a small count) is left alone.
|
|
87
|
+
expect(redactPii("returned 404 in 12ms")).toBe("returned 404 in 12ms");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("redacts bearer / token secrets", () => {
|
|
91
|
+
expect(redactPii("Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6")).toBe(
|
|
92
|
+
"Authorization: Bearer [redacted]",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const CRUMB_WITH_TOKENS: Breadcrumb = {
|
|
98
|
+
category: "fetch",
|
|
99
|
+
message: "GET https://acme.app/me?token=secret sent to jane@acme.app",
|
|
100
|
+
timestamp: 10,
|
|
101
|
+
data: { method: "GET", url: "https://acme.app/me?token=secret", status: 200 },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
describe("applyDefaultScrub", () => {
|
|
105
|
+
it("strips the page URL and scrubs crumb URLs + PII, but keeps the comment", () => {
|
|
106
|
+
const draft: ReportContent = {
|
|
107
|
+
comment: "email me at jane@acme.app — 4111111111111111",
|
|
108
|
+
url: "https://acme.app/checkout?token=abc",
|
|
109
|
+
trace: [CRUMB_WITH_TOKENS],
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const scrubbed = applyDefaultScrub(draft);
|
|
113
|
+
|
|
114
|
+
expect(scrubbed.url).toBe("https://acme.app/checkout");
|
|
115
|
+
expect(scrubbed.trace?.[0]?.data?.url).toBe("https://acme.app/me");
|
|
116
|
+
expect(scrubbed.trace?.[0]?.message).toBe(
|
|
117
|
+
"GET https://acme.app/me sent to [redacted-email]",
|
|
118
|
+
);
|
|
119
|
+
// The Reporter's own comment is authored on purpose — never scrubbed.
|
|
120
|
+
expect(scrubbed.comment).toBe("email me at jane@acme.app — 4111111111111111");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("does not mutate the input draft", () => {
|
|
124
|
+
const draft: ReportContent = {
|
|
125
|
+
url: "https://acme.app/x?token=abc",
|
|
126
|
+
trace: [CRUMB_WITH_TOKENS],
|
|
127
|
+
};
|
|
128
|
+
applyDefaultScrub(draft);
|
|
129
|
+
expect(draft.url).toBe("https://acme.app/x?token=abc");
|
|
130
|
+
expect(draft.trace?.[0]?.data?.url).toBe("https://acme.app/me?token=secret");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("applyDefaultScrub — structured console args (#122)", () => {
|
|
135
|
+
const CONSOLE_WITH_PII: Breadcrumb = {
|
|
136
|
+
category: "console",
|
|
137
|
+
level: "error",
|
|
138
|
+
message: "checkout failed",
|
|
139
|
+
timestamp: 20,
|
|
140
|
+
args: [
|
|
141
|
+
{ t: "string", v: "user jane@acme.app card 4111111111111111" },
|
|
142
|
+
{
|
|
143
|
+
t: "json",
|
|
144
|
+
v: { note: "call 5551234567", ref: { url: "https://acme.app/x?token=zzz" } },
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
t: "error",
|
|
148
|
+
v: {
|
|
149
|
+
name: "TypeError",
|
|
150
|
+
message: "failed for jane@acme.app",
|
|
151
|
+
stack: "at pay (https://acme.app/pay.js?token=q:4:2)",
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
it("redacts PII and strips URL query strings inside a console entry's args", () => {
|
|
158
|
+
const scrubbed = applyDefaultScrub({ trace: [CONSOLE_WITH_PII] });
|
|
159
|
+
const serialized = JSON.stringify(scrubbed.trace);
|
|
160
|
+
// Every free-text carrier inside the args is swept — string, json leaves, error.
|
|
161
|
+
expect(serialized).not.toContain("jane@acme.app");
|
|
162
|
+
expect(serialized).not.toContain("4111111111111111");
|
|
163
|
+
expect(serialized).not.toContain("5551234567");
|
|
164
|
+
expect(serialized).not.toContain("token=zzz");
|
|
165
|
+
expect(serialized).not.toContain("token=q");
|
|
166
|
+
expect(serialized).toContain("[redacted-email]");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("does not mutate the input args", () => {
|
|
170
|
+
const draft = { trace: [CONSOLE_WITH_PII] } as ReportContent;
|
|
171
|
+
applyDefaultScrub(draft);
|
|
172
|
+
expect((draft.trace?.[0]?.args?.[0] as { v: string }).v).toContain(
|
|
173
|
+
"jane@acme.app",
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("runBeforeSend", () => {
|
|
179
|
+
const draft: ReportContent = {
|
|
180
|
+
url: "https://acme.app/x?token=abc",
|
|
181
|
+
trace: [CRUMB_WITH_TOKENS],
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
it("applies the default scrubbers by default", () => {
|
|
185
|
+
const out = runBeforeSend(draft);
|
|
186
|
+
expect(out?.url).toBe("https://acme.app/x");
|
|
187
|
+
expect(out?.trace?.[0]?.data?.url).toBe("https://acme.app/me");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("relaxes the default scrubbers when scrub is false", () => {
|
|
191
|
+
const out = runBeforeSend(draft, { scrub: false });
|
|
192
|
+
expect(out?.url).toBe("https://acme.app/x?token=abc");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("lets a hook mutate the (already scrubbed) draft", () => {
|
|
196
|
+
const hook = vi.fn((d: ReportContent) => ({ ...d, comment: "added" }));
|
|
197
|
+
const out = runBeforeSend(draft, { hook });
|
|
198
|
+
// The hook sees the scrubbed draft, and its mutation survives.
|
|
199
|
+
expect(hook.mock.calls[0]?.[0].url).toBe("https://acme.app/x");
|
|
200
|
+
expect(out?.comment).toBe("added");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("drops the whole report when the hook returns null", () => {
|
|
204
|
+
expect(runBeforeSend(draft, { hook: () => null })).toBeNull();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("treats a throwing hook as a no-op, keeping the scrubbed draft", () => {
|
|
208
|
+
const out = runBeforeSend(draft, {
|
|
209
|
+
hook: () => {
|
|
210
|
+
throw new Error("boom");
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
expect(out?.url).toBe("https://acme.app/x");
|
|
214
|
+
});
|
|
215
|
+
});
|
package/src/scrub.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single client-side **scrub choke point** every report passes through
|
|
3
|
+
* before transport (spec 0003 §C; research `sentry-error-capture-findings.md`
|
|
4
|
+
* §7.4) — the SDK's `beforeSend` equivalent, shared runtime-agnostically across
|
|
5
|
+
* surfaces (ADR-0028).
|
|
6
|
+
*
|
|
7
|
+
* Masking is the SDK's job, done before anything leaves the process. The
|
|
8
|
+
* screenshot is masked at capture, and breadcrumbs never record a value or a
|
|
9
|
+
* body at the source; `runBeforeSend` is the **last** gate over the assembled
|
|
10
|
+
* report. Its default scrubbers are **on**: they strip credentials, query
|
|
11
|
+
* strings, and bearer tokens from URLs, and redact obvious PII (emails, long
|
|
12
|
+
* digit runs, bearer tokens) from crumb and error text. The result is then handed
|
|
13
|
+
* to an optional per-project hook that can mutate it further or drop the whole
|
|
14
|
+
* report by returning `null`.
|
|
15
|
+
*
|
|
16
|
+
* The hook is **synchronous and network-free** by contract, and both manual
|
|
17
|
+
* (overlay) and automatic (error-capture) reports run through the very same
|
|
18
|
+
* choke point. A project relaxes the defaults with `scrub: false`, or reshapes
|
|
19
|
+
* the draft in its own hook — never a silent raw send.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Breadcrumb, BreadcrumbData, ConsoleArg } from "./breadcrumb";
|
|
23
|
+
import type { ReportContent } from "./wire";
|
|
24
|
+
|
|
25
|
+
/** The per-project client scrub hook. Return `null` to drop the whole report. */
|
|
26
|
+
export type BeforeSend = (draft: ReportContent) => ReportContent | null;
|
|
27
|
+
|
|
28
|
+
/** Options for {@link runBeforeSend}. */
|
|
29
|
+
export interface BeforeSendOptions {
|
|
30
|
+
/** The per-project hook, run **after** the default scrubbers. */
|
|
31
|
+
readonly hook?: BeforeSend | null;
|
|
32
|
+
/** Run the built-in default scrubbers first. Defaults to `true`. */
|
|
33
|
+
readonly scrub?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A digit run at least this long is treated as sensitive (phone, card, id). */
|
|
37
|
+
const MIN_DIGIT_RUN = 7;
|
|
38
|
+
const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
|
|
39
|
+
const DIGIT_RUN_RE = new RegExp(`\\d{${MIN_DIGIT_RUN},}`, "g");
|
|
40
|
+
const BEARER_RE = /\b(bearer|token)\s+[\w.\-~+/]+=*/gi;
|
|
41
|
+
/** An http(s) URL embedded in free text (e.g. a console log line). */
|
|
42
|
+
const URL_IN_TEXT_RE = /\bhttps?:\/\/[^\s"'<>]+/gi;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Redact obvious PII from free text: email addresses, `Bearer <token>` /
|
|
46
|
+
* `token <value>` pairs, and long digit runs. Conservative by design — it keeps
|
|
47
|
+
* the shape of the message readable while removing the sensitive spans.
|
|
48
|
+
*/
|
|
49
|
+
export function redactPii(text: string): string {
|
|
50
|
+
if (typeof text !== "string" || text.length === 0) return text;
|
|
51
|
+
return text
|
|
52
|
+
.replace(EMAIL_RE, "[redacted-email]")
|
|
53
|
+
.replace(BEARER_RE, (_match, keyword: string) => `${keyword} [redacted]`)
|
|
54
|
+
.replace(DIGIT_RUN_RE, "[redacted-number]");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Redact PII from the **path** portion of a URL (spec #122 §E, ticket #139):
|
|
59
|
+
* emails and long digit runs in path (or fragment-route) segments — a network
|
|
60
|
+
* list shows URLs prominently, so an identifier baked into a path
|
|
61
|
+
* (`/users/jane@acme.app`, `/card/4111111111111111`) must not leak. Deliberately
|
|
62
|
+
* narrower than {@link redactPii}: no bearer/token rule (URL credentials are
|
|
63
|
+
* already stripped by {@link scrubUrl}) and it is only ever applied to the
|
|
64
|
+
* post-authority remainder, so a host or port with digits is never touched.
|
|
65
|
+
*/
|
|
66
|
+
function redactPathPii(pathAndBeyond: string): string {
|
|
67
|
+
return pathAndBeyond
|
|
68
|
+
.replace(EMAIL_RE, "[redacted-email]")
|
|
69
|
+
.replace(DIGIT_RUN_RE, "[redacted-number]");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Strip the sensitive parts of a URL: userinfo credentials
|
|
74
|
+
* (`scheme://user:pass@host`), the entire query string, a token-bearing
|
|
75
|
+
* fragment (one that carries `key=value`), and PII (emails, long digit runs) in
|
|
76
|
+
* the **path segments** (#139). Plain hash routes (`#/checkout`) are kept. Works
|
|
77
|
+
* on absolute and relative URLs alike, with no dependency and no throw. The host
|
|
78
|
+
* (authority) is never redacted — only the path and any surviving fragment route.
|
|
79
|
+
*/
|
|
80
|
+
export function scrubUrl(url: string): string {
|
|
81
|
+
if (typeof url !== "string" || url.length === 0) return url;
|
|
82
|
+
let out = url;
|
|
83
|
+
// Drop userinfo credentials: scheme://user:pass@host → scheme://host
|
|
84
|
+
out = out.replace(/(^[a-z][a-z0-9+.-]*:\/\/)[^/@?#]*@/i, "$1");
|
|
85
|
+
// Drop the query string entirely (everything from '?' up to a '#').
|
|
86
|
+
out = out.replace(/\?[^#]*/, "");
|
|
87
|
+
// Drop a token-bearing fragment (it carries '='); keep plain hash routes.
|
|
88
|
+
out = out.replace(/#.*$/, (fragment) =>
|
|
89
|
+
fragment.includes("=") ? "" : fragment,
|
|
90
|
+
);
|
|
91
|
+
// Redact PII in the path (and any kept fragment route), never in the authority.
|
|
92
|
+
// Split off `scheme://authority` (or a protocol-relative `//authority`) so a
|
|
93
|
+
// host/port that contains digits is preserved; the remainder is the path onward.
|
|
94
|
+
const absolute = out.match(/^([a-z][a-z0-9+.-]*:\/\/[^/?#]*)([\s\S]*)$/i);
|
|
95
|
+
if (absolute) return absolute[1] + redactPathPii(absolute[2] ?? "");
|
|
96
|
+
const protocolRelative = out.match(/^(\/\/[^/?#]*)([\s\S]*)$/);
|
|
97
|
+
if (protocolRelative) {
|
|
98
|
+
return protocolRelative[1] + redactPathPii(protocolRelative[2] ?? "");
|
|
99
|
+
}
|
|
100
|
+
// A relative URL is all path — redact the whole thing.
|
|
101
|
+
return redactPathPii(out);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type MutableBreadcrumbData = { -readonly [K in keyof BreadcrumbData]: BreadcrumbData[K] };
|
|
105
|
+
type MutableBreadcrumb = { -readonly [K in keyof Breadcrumb]: Breadcrumb[K] };
|
|
106
|
+
type MutableContent = { -readonly [K in keyof ReportContent]: ReportContent[K] };
|
|
107
|
+
|
|
108
|
+
function scrubCrumbData(data: BreadcrumbData): BreadcrumbData {
|
|
109
|
+
const next: MutableBreadcrumbData = { ...data };
|
|
110
|
+
if (typeof next.url === "string") next.url = scrubUrl(next.url);
|
|
111
|
+
if (typeof next.from === "string") next.from = scrubUrl(next.from);
|
|
112
|
+
if (typeof next.to === "string") next.to = scrubUrl(next.to);
|
|
113
|
+
return next;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Scrub a crumb's free-text message: strip URL query strings, then redact PII. */
|
|
117
|
+
function scrubMessage(message: string): string {
|
|
118
|
+
return redactPii(message.replace(URL_IN_TEXT_RE, (url) => scrubUrl(url)));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Scrub the string leaves of a structured console-arg value (spec #122 §C/§E) — the
|
|
123
|
+
* value a `json` arg carries. Walks arrays and plain objects, applying the same
|
|
124
|
+
* URL-strip + PII redaction as a crumb message to every nested string, so a value
|
|
125
|
+
* logged through `console.*` is swept just like the flattened preview.
|
|
126
|
+
*/
|
|
127
|
+
function scrubArgValue(value: unknown): unknown {
|
|
128
|
+
if (typeof value === "string") return scrubMessage(value);
|
|
129
|
+
if (Array.isArray(value)) return value.map(scrubArgValue);
|
|
130
|
+
if (value && typeof value === "object") {
|
|
131
|
+
const out: Record<string, unknown> = {};
|
|
132
|
+
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
|
|
133
|
+
out[key] = scrubArgValue(v);
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Scrub one structured console argument (spec #122 §C): redact PII / strip URL query
|
|
142
|
+
* strings from a `string` value, from every string leaf of a `json` value, and from an
|
|
143
|
+
* `error` value's `message` and `stack`. Other tags (`number`/`bool`/`null`) carry no
|
|
144
|
+
* free text and pass through unchanged.
|
|
145
|
+
*/
|
|
146
|
+
function scrubConsoleArg(arg: ConsoleArg): ConsoleArg {
|
|
147
|
+
switch (arg.t) {
|
|
148
|
+
case "string":
|
|
149
|
+
return typeof arg.v === "string" ? { t: "string", v: scrubMessage(arg.v) } : arg;
|
|
150
|
+
case "json":
|
|
151
|
+
return { t: "json", v: scrubArgValue(arg.v) };
|
|
152
|
+
case "error": {
|
|
153
|
+
if (!arg.v || typeof arg.v !== "object") return arg;
|
|
154
|
+
const v = arg.v as { message?: unknown; stack?: unknown };
|
|
155
|
+
const next: Record<string, unknown> = { ...v };
|
|
156
|
+
if (typeof next.message === "string") next.message = scrubMessage(next.message);
|
|
157
|
+
if (typeof next.stack === "string") next.stack = scrubMessage(next.stack);
|
|
158
|
+
return { t: "error", v: next };
|
|
159
|
+
}
|
|
160
|
+
default:
|
|
161
|
+
return arg;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function scrubCrumb(crumb: Breadcrumb): Breadcrumb {
|
|
166
|
+
const next: MutableBreadcrumb = { ...crumb };
|
|
167
|
+
if (typeof next.message === "string") next.message = scrubMessage(next.message);
|
|
168
|
+
if (next.data) next.data = scrubCrumbData(next.data);
|
|
169
|
+
// A rich network crumb (#139) carries its scrubbed URL at the top level — sweep it
|
|
170
|
+
// again here (idempotent) so the choke point holds whether the URL rode in `data`
|
|
171
|
+
// (a legacy thin crumb) or top-level, and path-PII redaction is never skipped.
|
|
172
|
+
if (typeof next.url === "string") next.url = scrubUrl(next.url);
|
|
173
|
+
// A console crumb's structured args carry the same free text as its preview — sweep
|
|
174
|
+
// them too, so PII redaction holds whether a value is read from `message` or `args`.
|
|
175
|
+
if (Array.isArray(next.args)) next.args = next.args.map(scrubConsoleArg);
|
|
176
|
+
return next;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Apply the built-in default scrubbers to a report draft: strip the page URL,
|
|
181
|
+
* and scrub every crumb's URLs and redact PII from its text. The Reporter's own
|
|
182
|
+
* `comment` is intentionally left untouched — it is authored on purpose, not
|
|
183
|
+
* scraped. The screenshot and input values are masked elsewhere (at capture and
|
|
184
|
+
* at crumb creation); this is the final URL/PII sweep.
|
|
185
|
+
*/
|
|
186
|
+
export function applyDefaultScrub(draft: ReportContent): ReportContent {
|
|
187
|
+
const next: MutableContent = { ...draft };
|
|
188
|
+
if (typeof next.url === "string") next.url = scrubUrl(next.url);
|
|
189
|
+
if (next.trace && next.trace.length > 0) {
|
|
190
|
+
next.trace = next.trace.map(scrubCrumb);
|
|
191
|
+
}
|
|
192
|
+
// The structured stack frames (#117) carry script URLs — sweep them again here
|
|
193
|
+
// (idempotent, like the crumb URLs) so the choke point holds regardless of
|
|
194
|
+
// where the frames were parsed.
|
|
195
|
+
if (next.errorFrames && next.errorFrames.length > 0) {
|
|
196
|
+
next.errorFrames = next.errorFrames.map((frame) => ({
|
|
197
|
+
...frame,
|
|
198
|
+
file: scrubUrl(frame.file),
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
return next;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Run the report draft through the client scrub choke point: the default
|
|
206
|
+
* scrubbers first (unless `scrub` is `false`), then the optional per-project
|
|
207
|
+
* hook. Returns the scrubbed (and possibly hook-mutated) draft, or `null` when
|
|
208
|
+
* the hook drops the report. A hook that throws is treated as a no-op — the
|
|
209
|
+
* already-scrubbed draft is kept, so a buggy hook never breaks the report path
|
|
210
|
+
* nor leaks unscrubbed data.
|
|
211
|
+
*/
|
|
212
|
+
export function runBeforeSend(
|
|
213
|
+
draft: ReportContent,
|
|
214
|
+
options: BeforeSendOptions = {},
|
|
215
|
+
): ReportContent | null {
|
|
216
|
+
const current =
|
|
217
|
+
options.scrub === false ? draft : applyDefaultScrub(draft);
|
|
218
|
+
const hook = options.hook;
|
|
219
|
+
if (!hook) return current;
|
|
220
|
+
try {
|
|
221
|
+
const result = hook(current);
|
|
222
|
+
return result ?? null;
|
|
223
|
+
} catch {
|
|
224
|
+
return current;
|
|
225
|
+
}
|
|
226
|
+
}
|
package/src/wire.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared **ingest wire shapes** (ADR-0028) — the value contract a captured
|
|
3
|
+
* report carries to `POST /api/ingest/feedback`, shared by every Fixback capture
|
|
4
|
+
* SDK so they all speak the same shape.
|
|
5
|
+
*
|
|
6
|
+
* Like the rest of the core these are runtime-agnostic value types: the browser
|
|
7
|
+
* and (future) backend SDKs both assemble a {@link ReportContent} from what they
|
|
8
|
+
* captured. Keep them in lock-step with the server: the JSON `payload` part the
|
|
9
|
+
* ingest controller parses (`apps/api/src/ingest/ingest.controller.ts`) and the
|
|
10
|
+
* `SubmissionContent` it maps to (`apps/api/src/ingest/reporter-identity.ts`).
|
|
11
|
+
* Each SDK keeps vendoring its own transport envelope (ticket #47) — the core
|
|
12
|
+
* carries the pure content shape, not the multipart envelope.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Annotation } from "./annotation";
|
|
16
|
+
import type { Breadcrumb } from "./breadcrumb";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Where a Feedback came from — a human in the overlay (`reporter`, the default) or
|
|
20
|
+
* a runtime error the SDK captured (`error`, ADR-0025: covers both an uncaught
|
|
21
|
+
* crash and a manual capture, on any runtime). Mirrors the server's
|
|
22
|
+
* `FEEDBACK_SOURCES`; the server derives trust independently and ignores anything
|
|
23
|
+
* else the client claims.
|
|
24
|
+
*/
|
|
25
|
+
export type FeedbackSource = "reporter" | "error";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The **Platform** a Feedback was captured on (ADR-0025): `browser` (the web SDK),
|
|
29
|
+
* `node` (the backend SDK), or `expo` (the mobile SDK). Additive — future runtimes
|
|
30
|
+
* become new values. Mirrors the server's `PLATFORMS` (`@fixback/shared`) by value;
|
|
31
|
+
* the server stamps it from the ingest route, so it is **not** carried on
|
|
32
|
+
* {@link ReportContent} (the client never declares its own platform).
|
|
33
|
+
*/
|
|
34
|
+
export type Platform = "browser" | "node" | "expo";
|
|
35
|
+
|
|
36
|
+
/** The capture environment recorded alongside a report. */
|
|
37
|
+
export interface CaptureEnvironment {
|
|
38
|
+
readonly viewportWidth?: number;
|
|
39
|
+
readonly viewportHeight?: number;
|
|
40
|
+
readonly browser?: string;
|
|
41
|
+
readonly sdkVersion?: string;
|
|
42
|
+
/**
|
|
43
|
+
* The host app's **Release** — the build identifier the builder configured at
|
|
44
|
+
* `init` (#117, ADR-0024). Uploaded sourcemaps are keyed by it, so the server
|
|
45
|
+
* can symbolicate this session's stack traces against the exact build that
|
|
46
|
+
* produced them. Omitted when the Project doesn't set one.
|
|
47
|
+
*/
|
|
48
|
+
readonly release?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One structured frame of a captured stack trace (#117, ADR-0024), top of stack
|
|
53
|
+
* first — the server's `errorFrames` wire shape. `file` is the scrubbed script
|
|
54
|
+
* URL; `line`/`column` are 1-based as browsers report them.
|
|
55
|
+
*/
|
|
56
|
+
export interface CapturedFrame {
|
|
57
|
+
readonly file: string;
|
|
58
|
+
readonly line: number;
|
|
59
|
+
readonly column: number | null;
|
|
60
|
+
readonly function: string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The JSON content of a feedback submission — the object serialised into the
|
|
65
|
+
* multipart `payload` part next to the `key` and identity evidence. Every field
|
|
66
|
+
* is optional: none of it feeds the server's trust decision, so a submission may
|
|
67
|
+
* carry any subset. `annotation` is the structured `{ element?, region?, marks? }`
|
|
68
|
+
* (spec §D); the screenshot is a separate binary part, never part of this JSON.
|
|
69
|
+
*/
|
|
70
|
+
export interface ReportContent {
|
|
71
|
+
readonly comment?: string;
|
|
72
|
+
/**
|
|
73
|
+
* The Reporter's self-provided display **name / email** (spec §F), captured in the
|
|
74
|
+
* invite onboarding step and riding along with every submission so a Member sees
|
|
75
|
+
* who reported an Issue. **Display only, never a trust signal** — the server derives
|
|
76
|
+
* the tier from identity evidence alone and ignores these. Omitted when the Reporter
|
|
77
|
+
* gave none (a Public/anonymous visitor).
|
|
78
|
+
*/
|
|
79
|
+
readonly reporterName?: string;
|
|
80
|
+
readonly reporterEmail?: string;
|
|
81
|
+
readonly url?: string;
|
|
82
|
+
readonly environment?: CaptureEnvironment;
|
|
83
|
+
readonly annotation?: Annotation;
|
|
84
|
+
/** The masked breadcrumb trace buffer that rode on this report (spec §C). */
|
|
85
|
+
readonly trace?: readonly Breadcrumb[];
|
|
86
|
+
/**
|
|
87
|
+
* Provenance (spec §D/§E). Omitted for a manual report — the transport stamps the
|
|
88
|
+
* `reporter` default on the wire; set to `error` by the SDK's error capture.
|
|
89
|
+
*/
|
|
90
|
+
readonly source?: FeedbackSource;
|
|
91
|
+
/**
|
|
92
|
+
* For `source: error` only — whether the error was **handled** (ADR-0025):
|
|
93
|
+
* `false` = an uncaught crash, `true` = a manual capture. Set by the backend SDK
|
|
94
|
+
* (`@fixback/node`), which distinguishes the two; the browser SDK captures only
|
|
95
|
+
* uncaught errors and leaves it unset.
|
|
96
|
+
*/
|
|
97
|
+
readonly handled?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* The deploy **Environment** the SDK was configured with (ADR-0025) —
|
|
100
|
+
* `production` / `staging` / … — distinct from {@link CaptureEnvironment.release}
|
|
101
|
+
* (a build version). Named apart from `environment` (the capture bag above) to
|
|
102
|
+
* avoid the collision. Set by the backend SDK; unset by the browser SDK for now.
|
|
103
|
+
*/
|
|
104
|
+
readonly deployEnvironment?: string;
|
|
105
|
+
/** For `source: error` only — the SDK's per-session error fingerprint (spec §E). */
|
|
106
|
+
readonly errorSignature?: string;
|
|
107
|
+
/** For `source: error` only — the running occurrence count within the session (spec §E). */
|
|
108
|
+
readonly occurrences?: number;
|
|
109
|
+
/**
|
|
110
|
+
* For `source: error` only — the captured error's parsed stack frames (#117,
|
|
111
|
+
* ADR-0024), top of stack first, capped client-side. The analysis worker
|
|
112
|
+
* matches them against the release's uploaded sourcemaps to write the Issue's
|
|
113
|
+
* Code-area pointer.
|
|
114
|
+
*/
|
|
115
|
+
readonly errorFrames?: readonly CapturedFrame[];
|
|
116
|
+
}
|