@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/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
+ }