@mhosaic/feedback 0.16.2 → 0.17.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/dist/chunk-4QOIFIT5.mjs +50 -0
- package/dist/chunk-4QOIFIT5.mjs.map +1 -0
- package/dist/chunk-QVCHZCDS.mjs +143 -0
- package/dist/chunk-QVCHZCDS.mjs.map +1 -0
- package/dist/{chunk-6JDT2KWQ.mjs → chunk-RP46APZ6.mjs} +7 -2
- package/dist/chunk-RP46APZ6.mjs.map +1 -0
- package/dist/{chunk-I4GWTJ6Q.mjs → chunk-TG4YNEXE.mjs} +60 -52
- package/dist/chunk-TG4YNEXE.mjs.map +1 -0
- package/dist/embed.min.js +4 -4
- package/dist/embed.min.js.map +1 -1
- package/dist/error-tracking.d.ts +1 -1
- package/dist/error-tracking.mjs +3 -138
- package/dist/error-tracking.mjs.map +1 -1
- package/dist/{index-snrtK3_E.d.ts → index-Bq4QI0xa.d.ts} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +2 -1
- package/dist/loader/react.d.ts +12 -2
- package/dist/loader/react.mjs +12 -2
- package/dist/loader/react.mjs.map +1 -1
- package/dist/loader.d.ts +3 -1
- package/dist/loader.min.js +1 -1
- package/dist/loader.min.js.map +1 -1
- package/dist/loader.mjs +1 -1
- package/dist/react.d.ts +11 -3
- package/dist/react.mjs +13 -2
- package/dist/react.mjs.map +1 -1
- package/dist/replay.d.ts +1 -1
- package/dist/telemetry.d.ts +53 -0
- package/dist/telemetry.mjs +166 -0
- package/dist/telemetry.mjs.map +1 -0
- package/dist/{types-BrsktaE1.d.ts → types-2dZ9bHAn.d.ts} +4 -0
- package/dist/webvitals.d.ts +1 -1
- package/dist/widget.min.js +4 -4
- package/dist/widget.min.js.map +1 -1
- package/package.json +5 -1
- package/dist/chunk-6JDT2KWQ.mjs.map +0 -1
- package/dist/chunk-I4GWTJ6Q.mjs.map +0 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/capture/urlSanitizer.ts
|
|
2
|
+
var SENSITIVE_NAME = /token|key|password|secret|auth|session|sig|credential|bearer|cookie/i;
|
|
3
|
+
var SENSITIVE_VALUE_PATTERNS = [
|
|
4
|
+
// JWT: three base64url segments separated by dots, header begins with eyJ.
|
|
5
|
+
/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/,
|
|
6
|
+
// Mhosaic feedback keys (the project's own format).
|
|
7
|
+
/^(?:sk|pk)_proj_[A-Za-z0-9_-]{16,}$/,
|
|
8
|
+
// GitHub PATs (ghp/gho/ghu/ghs/ghr prefixes, 36+ chars).
|
|
9
|
+
/^gh[pousr]_[A-Za-z0-9]{30,}$/,
|
|
10
|
+
// Stripe live/test secret keys.
|
|
11
|
+
/^(?:sk|pk|rk|whsec)_(?:live|test)_[A-Za-z0-9]{16,}$/,
|
|
12
|
+
// AWS access key id.
|
|
13
|
+
/^AKIA[0-9A-Z]{12,}$/,
|
|
14
|
+
// Slack bot tokens.
|
|
15
|
+
/^xox[abprso]-[A-Za-z0-9-]{12,}$/,
|
|
16
|
+
// Generic high-entropy: hex 40+ chars (SHA-1, SHA-256) or long alnum 32+.
|
|
17
|
+
/^[a-f0-9]{40,}$/i,
|
|
18
|
+
// Long opaque base64-ish string (common for OAuth codes & session ids).
|
|
19
|
+
/^[A-Za-z0-9_-]{40,}$/
|
|
20
|
+
];
|
|
21
|
+
function looksLikeCredential(value) {
|
|
22
|
+
return SENSITIVE_VALUE_PATTERNS.some((re) => re.test(value));
|
|
23
|
+
}
|
|
24
|
+
function sanitizeUrl(url) {
|
|
25
|
+
try {
|
|
26
|
+
const isAbsolute = /^https?:\/\//i.test(url);
|
|
27
|
+
const isRootRelative = url.startsWith("/");
|
|
28
|
+
if (!isAbsolute && !isRootRelative) return url;
|
|
29
|
+
const base = typeof window !== "undefined" ? window.location.origin : "http://localhost";
|
|
30
|
+
const u = new URL(url, base);
|
|
31
|
+
const clean = new URLSearchParams();
|
|
32
|
+
u.searchParams.forEach((value, name) => {
|
|
33
|
+
if (SENSITIVE_NAME.test(name) || looksLikeCredential(value)) {
|
|
34
|
+
clean.set(name, "[redacted]");
|
|
35
|
+
} else {
|
|
36
|
+
clean.set(name, value);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
u.search = clean.toString();
|
|
40
|
+
u.hash = u.hash ? "#[redacted]" : "";
|
|
41
|
+
return u.toString();
|
|
42
|
+
} catch {
|
|
43
|
+
return url;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
sanitizeUrl
|
|
49
|
+
};
|
|
50
|
+
//# sourceMappingURL=chunk-4QOIFIT5.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/capture/urlSanitizer.ts"],"sourcesContent":["/**\n * URL sanitizer for captured network request URLs.\n *\n * Two layers of defense:\n *\n * 1) Param NAME match — anything whose name suggests a credential is\n * redacted unconditionally (token, key, password, secret, auth, etc.).\n *\n * 2) Param VALUE shape match — even when the name is innocent (\"id\",\n * \"redirect_uri\", \"context\"), if the VALUE looks like a JWT, a\n * Bearer token, an mhosaic-feedback API key, or a long opaque hex/b64\n * string we redact it. This catches the realistic case where a backend\n * routes credentials through generic-named params or where a careless\n * developer puts a token in a URL fragment for \"convenience.\"\n *\n * The path portion is preserved as-is — its purpose is operator-side\n * \"which page was this report filed from\", and stripping it would\n * destroy that signal. Hosts that route secrets through path segments\n * (uncommon) should mark those routes for the widget to skip via the\n * sanitizeUrl hook in createFeedback().\n */\n\nconst SENSITIVE_NAME = /token|key|password|secret|auth|session|sig|credential|bearer|cookie/i\n\nconst SENSITIVE_VALUE_PATTERNS: RegExp[] = [\n // JWT: three base64url segments separated by dots, header begins with eyJ.\n /^eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+$/,\n // Mhosaic feedback keys (the project's own format).\n /^(?:sk|pk)_proj_[A-Za-z0-9_-]{16,}$/,\n // GitHub PATs (ghp/gho/ghu/ghs/ghr prefixes, 36+ chars).\n /^gh[pousr]_[A-Za-z0-9]{30,}$/,\n // Stripe live/test secret keys.\n /^(?:sk|pk|rk|whsec)_(?:live|test)_[A-Za-z0-9]{16,}$/,\n // AWS access key id.\n /^AKIA[0-9A-Z]{12,}$/,\n // Slack bot tokens.\n /^xox[abprso]-[A-Za-z0-9-]{12,}$/,\n // Generic high-entropy: hex 40+ chars (SHA-1, SHA-256) or long alnum 32+.\n /^[a-f0-9]{40,}$/i,\n // Long opaque base64-ish string (common for OAuth codes & session ids).\n /^[A-Za-z0-9_-]{40,}$/,\n]\n\nfunction looksLikeCredential(value: string): boolean {\n return SENSITIVE_VALUE_PATTERNS.some((re) => re.test(value))\n}\n\nexport function sanitizeUrl(url: string): string {\n try {\n // Accept absolute URLs or root-relative paths; reject everything else\n const isAbsolute = /^https?:\\/\\//i.test(url)\n const isRootRelative = url.startsWith('/')\n if (!isAbsolute && !isRootRelative) return url\n\n const base = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'\n const u = new URL(url, base)\n const clean = new URLSearchParams()\n u.searchParams.forEach((value, name) => {\n if (SENSITIVE_NAME.test(name) || looksLikeCredential(value)) {\n clean.set(name, '[redacted]')\n } else {\n clean.set(name, value)\n }\n })\n u.search = clean.toString()\n // Hash fragments occasionally carry credentials (OAuth implicit flow):\n // never log them.\n u.hash = u.hash ? '#[redacted]' : ''\n return u.toString()\n } catch {\n return url\n }\n}\n"],"mappings":";AAsBA,IAAM,iBAAiB;AAEvB,IAAM,2BAAqC;AAAA;AAAA,EAEzC;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AAAA;AAAA,EAEA;AACF;AAEA,SAAS,oBAAoB,OAAwB;AACnD,SAAO,yBAAyB,KAAK,CAAC,OAAO,GAAG,KAAK,KAAK,CAAC;AAC7D;AAEO,SAAS,YAAY,KAAqB;AAC/C,MAAI;AAEF,UAAM,aAAa,gBAAgB,KAAK,GAAG;AAC3C,UAAM,iBAAiB,IAAI,WAAW,GAAG;AACzC,QAAI,CAAC,cAAc,CAAC,eAAgB,QAAO;AAE3C,UAAM,OAAO,OAAO,WAAW,cAAc,OAAO,SAAS,SAAS;AACtE,UAAM,IAAI,IAAI,IAAI,KAAK,IAAI;AAC3B,UAAM,QAAQ,IAAI,gBAAgB;AAClC,MAAE,aAAa,QAAQ,CAAC,OAAO,SAAS;AACtC,UAAI,eAAe,KAAK,IAAI,KAAK,oBAAoB,KAAK,GAAG;AAC3D,cAAM,IAAI,MAAM,YAAY;AAAA,MAC9B,OAAO;AACL,cAAM,IAAI,MAAM,KAAK;AAAA,MACvB;AAAA,IACF,CAAC;AACD,MAAE,SAAS,MAAM,SAAS;AAG1B,MAAE,OAAO,EAAE,OAAO,gBAAgB;AAClC,WAAO,EAAE,SAAS;AAAA,EACpB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;","names":[]}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// src/modules/error-tracking.ts
|
|
2
|
+
var DEFAULTS = {
|
|
3
|
+
enabled: true,
|
|
4
|
+
perFingerprintCooldownMs: 5 * 60 * 1e3,
|
|
5
|
+
sampleRate: 1,
|
|
6
|
+
maxDescriptionLen: 200,
|
|
7
|
+
globalRatePerMinute: 30,
|
|
8
|
+
maxDistinctFingerprints: 200
|
|
9
|
+
};
|
|
10
|
+
function normalize(thrown) {
|
|
11
|
+
if (thrown instanceof Error) {
|
|
12
|
+
return {
|
|
13
|
+
name: thrown.name || "Error",
|
|
14
|
+
message: String(thrown.message ?? ""),
|
|
15
|
+
...thrown.stack && { stack: String(thrown.stack) }
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
if (typeof thrown === "string") return { name: "Error", message: thrown };
|
|
19
|
+
if (thrown && typeof thrown === "object") {
|
|
20
|
+
const o = thrown;
|
|
21
|
+
return {
|
|
22
|
+
name: typeof o.name === "string" && o.name || "Error",
|
|
23
|
+
message: typeof o.message === "string" && o.message || (() => {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.stringify(thrown);
|
|
26
|
+
} catch {
|
|
27
|
+
return String(thrown);
|
|
28
|
+
}
|
|
29
|
+
})(),
|
|
30
|
+
...typeof o.stack === "string" && { stack: o.stack }
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return { name: "Error", message: String(thrown) };
|
|
34
|
+
}
|
|
35
|
+
function clientFingerprint(err, pathname) {
|
|
36
|
+
const firstFrames = (err.stack ?? "").split("\n").slice(0, 4).join("\n");
|
|
37
|
+
const messagePrefix = err.message.slice(0, 30);
|
|
38
|
+
return `${err.name}:${messagePrefix}|${pathname}|${firstFrames}`;
|
|
39
|
+
}
|
|
40
|
+
function truncate(s, max) {
|
|
41
|
+
if (s.length <= max) return s;
|
|
42
|
+
return s.slice(0, Math.max(0, max - 1)) + "\u2026";
|
|
43
|
+
}
|
|
44
|
+
function withErrorTracking(fb, options = {}) {
|
|
45
|
+
const opts = { ...DEFAULTS, ...options };
|
|
46
|
+
if (!opts.enabled) return fb;
|
|
47
|
+
if (typeof window === "undefined") return fb;
|
|
48
|
+
const internal = fb;
|
|
49
|
+
const inFlight = /* @__PURE__ */ new Set();
|
|
50
|
+
const lastSent = /* @__PURE__ */ new Map();
|
|
51
|
+
const recentSendTimes = [];
|
|
52
|
+
function shouldSend(fp, now) {
|
|
53
|
+
if (opts.globalRatePerMinute > 0) {
|
|
54
|
+
const windowStart = now - 6e4;
|
|
55
|
+
while (recentSendTimes.length > 0 && recentSendTimes[0] < windowStart) {
|
|
56
|
+
recentSendTimes.shift();
|
|
57
|
+
}
|
|
58
|
+
if (recentSendTimes.length >= opts.globalRatePerMinute) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (opts.maxDistinctFingerprints > 0 && !lastSent.has(fp) && lastSent.size >= opts.maxDistinctFingerprints) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (opts.perFingerprintCooldownMs <= 0) {
|
|
66
|
+
recentSendTimes.push(now);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
const prev = lastSent.get(fp);
|
|
70
|
+
if (prev !== void 0 && now - prev < opts.perFingerprintCooldownMs) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
const HARD_CAP = 256;
|
|
74
|
+
const TARGET_AFTER_TRIM = 192;
|
|
75
|
+
if (lastSent.size > HARD_CAP) {
|
|
76
|
+
const cutoff = now - opts.perFingerprintCooldownMs * 2;
|
|
77
|
+
for (const [k, v] of lastSent) {
|
|
78
|
+
if (v < cutoff) lastSent.delete(k);
|
|
79
|
+
}
|
|
80
|
+
if (lastSent.size > HARD_CAP) {
|
|
81
|
+
const toDrop = lastSent.size - TARGET_AFTER_TRIM;
|
|
82
|
+
let dropped = 0;
|
|
83
|
+
for (const k of lastSent.keys()) {
|
|
84
|
+
if (dropped >= toDrop) break;
|
|
85
|
+
lastSent.delete(k);
|
|
86
|
+
dropped++;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
recentSendTimes.push(now);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
async function report(err) {
|
|
94
|
+
if (opts.sampleRate < 1 && Math.random() >= opts.sampleRate) return;
|
|
95
|
+
const fp = clientFingerprint(err, window.location.pathname);
|
|
96
|
+
if (inFlight.has(fp)) return;
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
if (!shouldSend(fp, now)) return;
|
|
99
|
+
lastSent.set(fp, now);
|
|
100
|
+
const description = truncate(
|
|
101
|
+
err.message ? `${err.name}: ${err.message}` : err.name,
|
|
102
|
+
opts.maxDescriptionLen
|
|
103
|
+
);
|
|
104
|
+
inFlight.add(fp);
|
|
105
|
+
try {
|
|
106
|
+
await internal.submit({
|
|
107
|
+
description,
|
|
108
|
+
feedback_type: "bug",
|
|
109
|
+
severity: "high",
|
|
110
|
+
synthetic: true
|
|
111
|
+
});
|
|
112
|
+
} catch {
|
|
113
|
+
} finally {
|
|
114
|
+
inFlight.delete(fp);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function onError(event) {
|
|
118
|
+
const candidate = event.error ?? {
|
|
119
|
+
name: "Error",
|
|
120
|
+
message: event.message,
|
|
121
|
+
stack: `at ${event.filename}:${event.lineno}:${event.colno}`
|
|
122
|
+
};
|
|
123
|
+
void report(normalize(candidate));
|
|
124
|
+
}
|
|
125
|
+
function onUnhandledRejection(event) {
|
|
126
|
+
void report(normalize(event.reason));
|
|
127
|
+
}
|
|
128
|
+
window.addEventListener("error", onError);
|
|
129
|
+
window.addEventListener("unhandledrejection", onUnhandledRejection);
|
|
130
|
+
const originalShutdown = internal.shutdown.bind(internal);
|
|
131
|
+
internal.shutdown = () => {
|
|
132
|
+
window.removeEventListener("error", onError);
|
|
133
|
+
window.removeEventListener("unhandledrejection", onUnhandledRejection);
|
|
134
|
+
lastSent.clear();
|
|
135
|
+
originalShutdown();
|
|
136
|
+
};
|
|
137
|
+
return fb;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export {
|
|
141
|
+
withErrorTracking
|
|
142
|
+
};
|
|
143
|
+
//# sourceMappingURL=chunk-QVCHZCDS.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/modules/error-tracking.ts"],"sourcesContent":["/**\n * Always-on debugger module. Hooks `window.error` and `unhandledrejection`,\n * then ships each fresh runtime error as a synthetic FeedbackReport via\n * the existing widget submit pipeline.\n *\n * Same opt-in pattern as `withReplay` / `withWebVitals` — host apps that\n * want curated-feedback-only simply don't import it.\n *\n * Design choices (see PR description):\n * - Per-fingerprint cooldown (default 5 min) absorbs render loops without\n * flooding the operator dashboard or burning the per-key write throttle.\n * - Server-side fingerprinting groups across sessions; the client-side\n * fingerprint exists only to dedup within a single page lifetime, so\n * it doesn't need to match the server's hash.\n * - Recursion guard: an error thrown *inside* the submit pipeline must\n * not re-enter the handler.\n * - Description is the error message truncated at 200 chars — the full\n * stack lives in technical_context.errors[].\n * - No screenshot is taken (buildAndSubmit honors `synthetic: true`):\n * html2canvas is slow + the DOM is unreliable mid-error.\n */\n\nimport type { FeedbackApi } from '../types'\n\nexport interface ErrorTrackingOptions {\n /** Opt-out kill switch without removing the import. Default `true`. */\n enabled?: boolean\n /**\n * Drop repeats of the same fingerprint within this window.\n * Default 300_000 ms (5 minutes). Set to 0 to disable dedup\n * (every fired error becomes a report — useful only for tests).\n */\n perFingerprintCooldownMs?: number\n /**\n * Probabilistic head sampling: 1.0 captures everything, 0.5 drops half.\n * Default 1.0. Sampled-out errors don't count toward the cooldown — if\n * you sample at 0.1, you'll still see ~10% of the volume per fingerprint.\n */\n sampleRate?: number\n /** Max characters in the auto-generated description. Default 200. */\n maxDescriptionLen?: number\n /**\n * Global ceiling on synthetic submissions per minute, regardless of\n * fingerprint. Defends against a hostile / buggy page that throws\n * errors with attacker-varying `message` strings (counter, nonce) —\n * each unique message creates a fresh fingerprint, bypassing the\n * per-fingerprint cooldown, and would otherwise burn the project's\n * backend write quota.\n * Default 30. Set to 0 to disable the global cap (not recommended).\n */\n globalRatePerMinute?: number\n /**\n * Max number of distinct fingerprints emitted per page lifetime. Once\n * exceeded, NEW fingerprints are dropped (the cooldown still applies\n * to already-seen ones, so legitimate repeating errors keep cycling).\n * Defense against a page that synthesizes an unbounded number of\n * unique error shapes.\n * Default 200. Set to 0 to disable.\n */\n maxDistinctFingerprints?: number\n}\n\ninterface InternalApi extends FeedbackApi {\n _registerTransformer?: (fn: unknown) => void\n}\n\ninterface NormalizedError {\n name: string\n message: string\n stack?: string\n}\n\nconst DEFAULTS = {\n enabled: true,\n perFingerprintCooldownMs: 5 * 60 * 1000,\n sampleRate: 1,\n maxDescriptionLen: 200,\n globalRatePerMinute: 30,\n maxDistinctFingerprints: 200,\n} as const satisfies Required<ErrorTrackingOptions>\n\n/**\n * Pull a normalized {name, message, stack} from whatever the runtime\n * handed us. Browsers throw genuine Error subclasses for uncaught\n * exceptions but `unhandledrejection.reason` is sometimes a string,\n * a plain object, or undefined.\n */\nfunction normalize(thrown: unknown): NormalizedError {\n if (thrown instanceof Error) {\n return {\n name: thrown.name || 'Error',\n message: String(thrown.message ?? ''),\n ...(thrown.stack && { stack: String(thrown.stack) }),\n }\n }\n if (typeof thrown === 'string') return { name: 'Error', message: thrown }\n if (thrown && typeof thrown === 'object') {\n const o = thrown as Record<string, unknown>\n return {\n name: (typeof o.name === 'string' && o.name) || 'Error',\n message:\n (typeof o.message === 'string' && o.message) ||\n (() => {\n try { return JSON.stringify(thrown) } catch { return String(thrown) }\n })(),\n ...(typeof o.stack === 'string' && { stack: o.stack }),\n }\n }\n return { name: 'Error', message: String(thrown) }\n}\n\n/**\n * Stable per-session signature. Server still computes its own fingerprint\n * across sessions — this only needs to dedup within one page lifetime.\n * Stack frames are the strongest discriminator; pathname keeps two distinct\n * routes from collapsing onto the same key.\n *\n * SECURITY: we include only a 30-char message prefix, not the full message.\n * A hostile or buggy page that throws errors with counter-suffixed messages\n * (e.g. `Error 1`, `Error 2`, …) would otherwise produce a fresh fingerprint\n * per throw and bypass the per-fingerprint cooldown. The stack frames are\n * the same across those counter variants, so collapsing the message into a\n * prefix preserves real-error distinction while killing the counter bypass.\n */\nfunction clientFingerprint(err: NormalizedError, pathname: string): string {\n const firstFrames = (err.stack ?? '').split('\\n').slice(0, 4).join('\\n')\n const messagePrefix = err.message.slice(0, 30)\n return `${err.name}:${messagePrefix}|${pathname}|${firstFrames}`\n}\n\nfunction truncate(s: string, max: number): string {\n if (s.length <= max) return s\n return s.slice(0, Math.max(0, max - 1)) + '…'\n}\n\nexport function withErrorTracking(\n fb: FeedbackApi,\n options: ErrorTrackingOptions = {},\n): FeedbackApi {\n const opts = { ...DEFAULTS, ...options }\n if (!opts.enabled) return fb\n if (typeof window === 'undefined') return fb\n\n const internal = fb as InternalApi\n\n // Recursion guard, scoped per-fingerprint: while we're posting a synthetic\n // report for fingerprint X, a re-entry for the SAME X (e.g. an error\n // raised by the submit pipeline itself) is dropped. Distinct errors that\n // fire concurrently still both go through.\n const inFlight = new Set<string>()\n\n // fingerprint -> last-sent epoch ms. Periodically pruned in shouldSend().\n const lastSent = new Map<string, number>()\n // Sliding-window of recent send timestamps for the global rate cap.\n // Trimmed in shouldSend(); never grows beyond globalRatePerMinute entries.\n const recentSendTimes: number[] = []\n\n function shouldSend(fp: string, now: number): boolean {\n // (1) Global rate ceiling — runs first because it doesn't depend on\n // fingerprint state. Defends against the \"counter-suffixed error\n // messages\" attack that would otherwise produce one unique\n // fingerprint per throw.\n if (opts.globalRatePerMinute > 0) {\n const windowStart = now - 60_000\n // Drop expired entries from the front of the sliding window.\n while (recentSendTimes.length > 0 && recentSendTimes[0]! < windowStart) {\n recentSendTimes.shift()\n }\n if (recentSendTimes.length >= opts.globalRatePerMinute) {\n return false\n }\n }\n // (2) Per-page cap on the number of distinct fingerprints. A hostile\n // page that synthesizes an unbounded number of unique error\n // shapes runs out of new slots after this many — already-seen\n // fingerprints keep cycling under the per-fp cooldown.\n if (\n opts.maxDistinctFingerprints > 0 &&\n !lastSent.has(fp) &&\n lastSent.size >= opts.maxDistinctFingerprints\n ) {\n return false\n }\n if (opts.perFingerprintCooldownMs <= 0) {\n recentSendTimes.push(now)\n return true\n }\n const prev = lastSent.get(fp)\n if (prev !== undefined && now - prev < opts.perFingerprintCooldownMs) {\n return false\n }\n // Cheap stale-entry sweep so the Map doesn't grow unboundedly on a\n // long-lived session with many distinct errors. First pass: drop\n // entries past `cooldown * 2`. If we're still above a hard cap of\n // 256 — i.e. an SPA that's been live for hours and keeps emitting\n // *fresh* fingerprints faster than they go stale — drop the oldest\n // entries until we're back to a comfortable ceiling. Map iteration\n // order is insertion order so the first N keys are the oldest.\n const HARD_CAP = 256\n const TARGET_AFTER_TRIM = 192\n if (lastSent.size > HARD_CAP) {\n const cutoff = now - opts.perFingerprintCooldownMs * 2\n for (const [k, v] of lastSent) {\n if (v < cutoff) lastSent.delete(k)\n }\n if (lastSent.size > HARD_CAP) {\n const toDrop = lastSent.size - TARGET_AFTER_TRIM\n let dropped = 0\n for (const k of lastSent.keys()) {\n if (dropped >= toDrop) break\n lastSent.delete(k)\n dropped++\n }\n }\n }\n recentSendTimes.push(now)\n return true\n }\n\n async function report(err: NormalizedError) {\n if (opts.sampleRate < 1 && Math.random() >= opts.sampleRate) return\n\n const fp = clientFingerprint(err, window.location.pathname)\n if (inFlight.has(fp)) return\n const now = Date.now()\n if (!shouldSend(fp, now)) return\n lastSent.set(fp, now)\n\n const description = truncate(\n err.message ? `${err.name}: ${err.message}` : err.name,\n opts.maxDescriptionLen,\n )\n\n inFlight.add(fp)\n try {\n await internal.submit({\n description,\n feedback_type: 'bug',\n severity: 'high',\n synthetic: true,\n } as Parameters<FeedbackApi['submit']>[0] & { synthetic: boolean })\n } catch {\n // Swallow submit errors — propagating them would just trigger another\n // unhandledrejection and feed the loop. The host's `onError` callback\n // (configured on createFeedback) still fires for visibility.\n } finally {\n inFlight.delete(fp)\n }\n }\n\n function onError(event: ErrorEvent) {\n const candidate: unknown = event.error ?? {\n name: 'Error',\n message: event.message,\n stack: `at ${event.filename}:${event.lineno}:${event.colno}`,\n }\n void report(normalize(candidate))\n }\n\n function onUnhandledRejection(event: PromiseRejectionEvent) {\n void report(normalize(event.reason))\n }\n\n window.addEventListener('error', onError)\n window.addEventListener('unhandledrejection', onUnhandledRejection)\n\n // Wrap shutdown so listeners come down with the widget. Without this, a\n // host that re-mounts the widget under React StrictMode would double the\n // listeners and emit each error twice.\n const originalShutdown = internal.shutdown.bind(internal)\n internal.shutdown = () => {\n window.removeEventListener('error', onError)\n window.removeEventListener('unhandledrejection', onUnhandledRejection)\n lastSent.clear()\n originalShutdown()\n }\n\n return fb\n}\n"],"mappings":"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","names":[]}
|
|
@@ -170,11 +170,16 @@ async function loadAndAttach(config, deferred) {
|
|
|
170
170
|
bundleUrl: manifest.bundle_url,
|
|
171
171
|
sriHash: manifest.sri_hash
|
|
172
172
|
});
|
|
173
|
-
const real = bundle.createFeedback(
|
|
173
|
+
const real = bundle.createFeedback({
|
|
174
|
+
...config,
|
|
175
|
+
// Forward the project's per-end-user gating flag from the manifest unless
|
|
176
|
+
// the host set it explicitly. The widget then runs the visibility check.
|
|
177
|
+
requiresVisibilityCheck: config.requiresVisibilityCheck ?? manifest.config?.requires_visibility_check ?? false
|
|
178
|
+
});
|
|
174
179
|
deferred._attach(real);
|
|
175
180
|
}
|
|
176
181
|
|
|
177
182
|
export {
|
|
178
183
|
createFeedback
|
|
179
184
|
};
|
|
180
|
-
//# sourceMappingURL=chunk-
|
|
185
|
+
//# sourceMappingURL=chunk-RP46APZ6.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/loader/bundleLoader.ts","../src/loader/manifest.ts","../src/loader/deferredApi.ts","../src/loader/index.ts"],"sourcesContent":["/**\n * Inject the widget bundle as an SRI-protected <script> and wait for its\n * window global to appear.\n *\n * Why a <script> tag and not `import()`:\n * `import()` can fetch cross-origin ESM but doesn't honor Subresource\n * Integrity on the dynamic-import call itself. A <script> tag with\n * `integrity=…` is the only path where the browser actually refuses\n * to execute a bundle whose bytes don't match the manifest's hash.\n *\n * The bundle is built with `tsup --format iife --globalName MhosaicFeedback`,\n * so once it parses it sets `window.MhosaicFeedback = { createFeedback }`\n * (per `src/widget.ts`'s exports). We wait for that to appear.\n *\n * Idempotency: if multiple FeedbackProviders mount on the same page (e.g.\n * during a React fast-refresh cycle), we reuse an already-injected bundle\n * instead of fetching a second copy.\n */\n\nimport type { FeedbackApi, FeedbackConfig } from '../types'\n\ndeclare global {\n interface Window {\n MhosaicFeedback?: {\n createFeedback(config: FeedbackConfig): FeedbackApi\n }\n }\n}\n\nconst SCRIPT_DATA_ATTR = 'data-mhosaic-feedback-bundle'\n\nexport interface BundleLoadOptions {\n bundleUrl: string\n sriHash: string\n /** Default 30 s. Long enough for slow networks; short enough to surface\n * CDN outages quickly. */\n timeoutMs?: number\n}\n\nexport interface BundleHandle {\n createFeedback(config: FeedbackConfig): FeedbackApi\n}\n\nexport async function injectBundle(\n opts: BundleLoadOptions,\n): Promise<BundleHandle> {\n // Reuse a bundle already loaded this page.\n if (window.MhosaicFeedback?.createFeedback) {\n return window.MhosaicFeedback\n }\n\n const existing = document.querySelector<HTMLScriptElement>(\n `script[${SCRIPT_DATA_ATTR}]`,\n )\n if (existing) {\n // Another loader instance started the fetch; wait for the global.\n return waitForGlobal(opts.timeoutMs ?? 30_000)\n }\n\n return new Promise<BundleHandle>((resolve, reject) => {\n const script = document.createElement('script')\n script.src = opts.bundleUrl\n script.integrity = opts.sriHash\n script.crossOrigin = 'anonymous'\n script.async = true\n script.setAttribute(SCRIPT_DATA_ATTR, '1')\n script.onload = () => {\n if (window.MhosaicFeedback?.createFeedback) {\n resolve(window.MhosaicFeedback)\n } else {\n reject(\n new Error(\n 'mhosaic-feedback: bundle loaded but window.MhosaicFeedback.createFeedback is missing — bundle/loader version mismatch?',\n ),\n )\n }\n }\n script.onerror = () => {\n reject(\n new Error(\n `mhosaic-feedback: failed to load bundle from ${opts.bundleUrl} ` +\n '(network, CSP, or SRI mismatch)',\n ),\n )\n }\n document.head.appendChild(script)\n\n const timeoutMs = opts.timeoutMs ?? 30_000\n setTimeout(() => {\n reject(\n new Error(`mhosaic-feedback: bundle load timeout after ${timeoutMs}ms`),\n )\n }, timeoutMs)\n })\n}\n\nfunction waitForGlobal(timeoutMs: number): Promise<BundleHandle> {\n return new Promise((resolve, reject) => {\n const start = Date.now()\n const tick = () => {\n if (window.MhosaicFeedback?.createFeedback) {\n resolve(window.MhosaicFeedback)\n return\n }\n if (Date.now() - start > timeoutMs) {\n reject(\n new Error(\n 'mhosaic-feedback: timed out waiting for bundle global (another loader instance started the fetch but never finished)',\n ),\n )\n return\n }\n setTimeout(tick, 50)\n }\n tick()\n })\n}\n","/**\n * Fetch the widget-manifest from the Mhosaic backend.\n *\n * The loader calls this on every page load. The backend reads `Project.\n * pinned_version` (or current stable) + `Project.widget_enabled` and\n * returns the bundle URL + SRI hash for the version this tenant should\n * receive — or `{enabled: false}` if the widget is killed for the tenant.\n *\n * Network failures are surfaced to the caller. The loader degrades to\n * \"widget not present\" rather than crashing the host page.\n */\n\nexport interface Manifest {\n /** False when the widget is disabled for this project (kill switch) or\n * no stable release exists. Loader bails silently. */\n enabled: boolean\n /** Semver of the bundle being served. Present when enabled=true. */\n version?: string\n /** Fully-qualified URL to the version-pinned bundle (jsDelivr). */\n bundle_url?: string\n /** Subresource integrity hash for the bundle. The loader injects the\n * script tag with `integrity=…` so the browser refuses to run a\n * bundle whose bytes don't match. */\n sri_hash?: string\n /** Static config the manifest endpoint passes through to the widget. */\n config?: {\n endpoint: string\n project_slug: string\n share_reports_with_widget: boolean\n /** Phase 4: project gates the widget per end-user (default false). */\n requires_visibility_check?: boolean\n }\n /** Human-readable detail when `enabled` is false. */\n detail?: string\n}\n\nexport async function fetchManifest(\n endpoint: string,\n apiKey: string,\n signal?: AbortSignal,\n): Promise<Manifest> {\n const base = endpoint.replace(/\\/$/, '')\n const url = `${base}/api/feedback/v1/widget-manifest/?pk=${encodeURIComponent(apiKey)}`\n // credentials: 'omit' — we authenticate via the public pk_proj_ query\n // param; sending cookies would just trigger CORS preflights for no\n // reason and is unnecessary since the manifest endpoint doesn't read\n // any session.\n const init: RequestInit = { credentials: 'omit' }\n if (signal) init.signal = signal\n const res = await fetch(url, init)\n if (!res.ok) {\n throw new Error(\n `mhosaic-feedback: manifest fetch failed (HTTP ${res.status})`,\n )\n }\n const body = (await res.json()) as Manifest\n return body\n}\n","/**\n * A FeedbackApi-shaped object that queues method calls until the real\n * widget bundle finishes loading, then drains the queue against the real\n * implementation.\n *\n * Why we need this: hosts that already have code like\n * const fb = createFeedback({...})\n * fb.identify({id, email, name})\n * useEffect(() => fb.open(...), [])\n * shouldn't have to refactor for async. The loader's createFeedback\n * returns sync — a deferred handle that records intent, then replays it\n * once the real bundle arrives. Idiomatic queue-then-flush pattern.\n *\n * If the bundle never loads (network failure, SRI mismatch, kill switch),\n * `_fail()` is called instead. Void methods (identify/setMetadata/open/...)\n * silently drop in that case — the widget just doesn't appear. The\n * `submit` Promise rejects so callers can surface real errors.\n */\n\nimport type {\n FeedbackApi,\n ReportPayload,\n SubmittedReport,\n UserIdentity,\n} from '../types'\n\ntype VoidMethod = 'show' | 'hide' | 'shutdown'\ntype ArgMethod = 'open' | 'identify' | 'setMetadata'\n\ntype Queued =\n | { kind: 'void'; method: VoidMethod }\n | { kind: 'arg'; method: 'open'; arg: Parameters<FeedbackApi['open']>[0] }\n | { kind: 'arg'; method: 'identify'; arg: UserIdentity }\n | { kind: 'arg'; method: 'setMetadata'; arg: Record<string, unknown> }\n | {\n kind: 'submit'\n payload: Partial<ReportPayload> & { description: string }\n resolve: (r: SubmittedReport) => void\n reject: (e: unknown) => void\n }\n\nexport interface DeferredHandle extends FeedbackApi {\n /** Called by the loader once the bundle is loaded and a real API exists. */\n _attach(real: FeedbackApi): void\n /** Called by the loader if the bundle fails to load. */\n _fail(err: Error): void\n}\n\nexport function createDeferredApi(): DeferredHandle {\n let real: FeedbackApi | null = null\n let failure: Error | null = null\n const queue: Queued[] = []\n\n function flush(item: Queued): void {\n if (real) {\n if (item.kind === 'void') {\n ;(real[item.method] as () => void)()\n } else if (item.kind === 'arg') {\n ;(real[item.method] as (a: unknown) => void)(item.arg)\n } else {\n // submit\n Promise.resolve(real.submit(item.payload)).then(\n item.resolve,\n item.reject,\n )\n }\n return\n }\n if (failure) {\n if (item.kind === 'submit') item.reject(failure)\n // Void / arg methods silently dropped — the widget never appeared.\n return\n }\n queue.push(item)\n }\n\n return {\n show() {\n flush({ kind: 'void', method: 'show' })\n },\n hide() {\n flush({ kind: 'void', method: 'hide' })\n },\n shutdown() {\n flush({ kind: 'void', method: 'shutdown' })\n },\n open(arg) {\n flush({ kind: 'arg', method: 'open', arg })\n },\n identify(arg) {\n flush({ kind: 'arg', method: 'identify', arg })\n },\n setMetadata(arg) {\n flush({ kind: 'arg', method: 'setMetadata', arg })\n },\n submit(payload) {\n return new Promise<SubmittedReport>((resolve, reject) => {\n flush({ kind: 'submit', payload, resolve, reject })\n })\n },\n _attach(impl) {\n real = impl\n while (queue.length > 0) flush(queue.shift()!)\n },\n _fail(err) {\n failure = err\n // Drain any pending submits with the failure; drop void/arg.\n for (const item of queue) {\n if (item.kind === 'submit') item.reject(err)\n }\n queue.length = 0\n },\n }\n}\n","/**\n * `@mhosaic/feedback/loader` — public entry for the loader architecture.\n *\n * Same shape as the direct-import path (`@mhosaic/feedback`), but the\n * widget bundle is fetched at runtime from a CDN URL the Mhosaic backend\n * dictates via the manifest endpoint. Letting hosts switch from the\n * direct-import path to the loader path gives them auto-updates without\n * any further code changes — Mhosaic ships a new Release row, every host\n * picks up the new bundle on next page load.\n *\n * Public surface MUST mirror the direct-import path exactly:\n * - `createFeedback(config)` returns a `FeedbackApi`-shaped handle.\n * - Method calls before the bundle finishes loading queue up and replay.\n * - `submit()` returns a Promise that resolves after the bundle is up\n * and the real submit completes.\n *\n * If the manifest endpoint reports the widget is disabled for this\n * project (`enabled: false` — kill switch or no stable release configured),\n * the deferred handle silently no-ops void calls and rejects `submit()`\n * calls with a clear error. The host page never crashes.\n */\n\nimport type { FeedbackApi, FeedbackConfig } from '../types'\nimport { injectBundle } from './bundleLoader'\nimport { fetchManifest } from './manifest'\nimport { createDeferredApi, type DeferredHandle } from './deferredApi'\n\nexport type { FeedbackApi, FeedbackConfig } from '../types'\nexport type { Manifest } from './manifest'\n\nexport function createFeedback(config: FeedbackConfig): FeedbackApi {\n const deferred = createDeferredApi()\n loadAndAttach(config, deferred).catch((err: Error) => {\n // Surface to console — host devtools is the canonical channel for\n // widget bootstrap errors. Production telemetry is the bundle's job\n // (it can't run if it never loaded), so console.warn is the floor.\n // eslint-disable-next-line no-console\n console.warn('[mhosaic-feedback] widget did not load:', err.message)\n deferred._fail(err)\n })\n return deferred\n}\n\nasync function loadAndAttach(\n config: FeedbackConfig,\n deferred: DeferredHandle,\n): Promise<void> {\n const manifest = await fetchManifest(config.endpoint, config.apiKey)\n if (!manifest.enabled || !manifest.bundle_url || !manifest.sri_hash) {\n // Disabled by Mhosaic (kill switch) or no stable release. Surface a\n // clear failure so submit() callers see an error, but no host crash.\n deferred._fail(\n new Error(\n `mhosaic-feedback: widget disabled for this project${\n manifest.detail ? ` — ${manifest.detail}` : ''\n }`,\n ),\n )\n return\n }\n const bundle = await injectBundle({\n bundleUrl: manifest.bundle_url,\n sriHash: manifest.sri_hash,\n })\n const real: FeedbackApi = bundle.createFeedback({\n ...config,\n // Forward the project's per-end-user gating flag from the manifest unless\n // the host set it explicitly. The widget then runs the visibility check.\n requiresVisibilityCheck:\n config.requiresVisibilityCheck ??\n manifest.config?.requires_visibility_check ??\n false,\n })\n deferred._attach(real)\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sanitizeUrl
|
|
3
|
+
} from "./chunk-4QOIFIT5.mjs";
|
|
1
4
|
import {
|
|
2
5
|
scrubCredentials
|
|
3
6
|
} from "./chunk-FGA63IEZ.mjs";
|
|
@@ -206,8 +209,27 @@ function createApiClient(options) {
|
|
|
206
209
|
}
|
|
207
210
|
return response.json();
|
|
208
211
|
}
|
|
212
|
+
async function checkVisibility(externalId) {
|
|
213
|
+
const response = await fetcher(
|
|
214
|
+
`${endpoint}/api/feedback/v1/widget/visibility/`,
|
|
215
|
+
{
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: {
|
|
218
|
+
...widgetHeaders(externalId),
|
|
219
|
+
"Content-Type": "application/json"
|
|
220
|
+
},
|
|
221
|
+
body: JSON.stringify({ external_id: externalId })
|
|
222
|
+
}
|
|
223
|
+
);
|
|
224
|
+
if (!response.ok) {
|
|
225
|
+
throw new Error(`checkVisibility failed: ${response.status}`);
|
|
226
|
+
}
|
|
227
|
+
const data = await response.json();
|
|
228
|
+
return Boolean(data.show);
|
|
229
|
+
}
|
|
209
230
|
return {
|
|
210
231
|
submitReport,
|
|
232
|
+
checkVisibility,
|
|
211
233
|
listMine,
|
|
212
234
|
listChangelog,
|
|
213
235
|
getReport,
|
|
@@ -218,52 +240,6 @@ function createApiClient(options) {
|
|
|
218
240
|
};
|
|
219
241
|
}
|
|
220
242
|
|
|
221
|
-
// src/capture/urlSanitizer.ts
|
|
222
|
-
var SENSITIVE_NAME = /token|key|password|secret|auth|session|sig|credential|bearer|cookie/i;
|
|
223
|
-
var SENSITIVE_VALUE_PATTERNS = [
|
|
224
|
-
// JWT: three base64url segments separated by dots, header begins with eyJ.
|
|
225
|
-
/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/,
|
|
226
|
-
// Mhosaic feedback keys (the project's own format).
|
|
227
|
-
/^(?:sk|pk)_proj_[A-Za-z0-9_-]{16,}$/,
|
|
228
|
-
// GitHub PATs (ghp/gho/ghu/ghs/ghr prefixes, 36+ chars).
|
|
229
|
-
/^gh[pousr]_[A-Za-z0-9]{30,}$/,
|
|
230
|
-
// Stripe live/test secret keys.
|
|
231
|
-
/^(?:sk|pk|rk|whsec)_(?:live|test)_[A-Za-z0-9]{16,}$/,
|
|
232
|
-
// AWS access key id.
|
|
233
|
-
/^AKIA[0-9A-Z]{12,}$/,
|
|
234
|
-
// Slack bot tokens.
|
|
235
|
-
/^xox[abprso]-[A-Za-z0-9-]{12,}$/,
|
|
236
|
-
// Generic high-entropy: hex 40+ chars (SHA-1, SHA-256) or long alnum 32+.
|
|
237
|
-
/^[a-f0-9]{40,}$/i,
|
|
238
|
-
// Long opaque base64-ish string (common for OAuth codes & session ids).
|
|
239
|
-
/^[A-Za-z0-9_-]{40,}$/
|
|
240
|
-
];
|
|
241
|
-
function looksLikeCredential(value) {
|
|
242
|
-
return SENSITIVE_VALUE_PATTERNS.some((re) => re.test(value));
|
|
243
|
-
}
|
|
244
|
-
function sanitizeUrl(url) {
|
|
245
|
-
try {
|
|
246
|
-
const isAbsolute = /^https?:\/\//i.test(url);
|
|
247
|
-
const isRootRelative = url.startsWith("/");
|
|
248
|
-
if (!isAbsolute && !isRootRelative) return url;
|
|
249
|
-
const base = typeof window !== "undefined" ? window.location.origin : "http://localhost";
|
|
250
|
-
const u = new URL(url, base);
|
|
251
|
-
const clean = new URLSearchParams();
|
|
252
|
-
u.searchParams.forEach((value, name) => {
|
|
253
|
-
if (SENSITIVE_NAME.test(name) || looksLikeCredential(value)) {
|
|
254
|
-
clean.set(name, "[redacted]");
|
|
255
|
-
} else {
|
|
256
|
-
clean.set(name, value);
|
|
257
|
-
}
|
|
258
|
-
});
|
|
259
|
-
u.search = clean.toString();
|
|
260
|
-
u.hash = u.hash ? "#[redacted]" : "";
|
|
261
|
-
return u.toString();
|
|
262
|
-
} catch {
|
|
263
|
-
return url;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
243
|
// src/capture/device.ts
|
|
268
244
|
function collectDevice() {
|
|
269
245
|
const nav = navigator;
|
|
@@ -844,7 +820,7 @@ function resolveStrings(overrides, options = {}) {
|
|
|
844
820
|
|
|
845
821
|
// src/widget/mount.tsx
|
|
846
822
|
import { h, render } from "preact";
|
|
847
|
-
import { useCallback } from "preact/hooks";
|
|
823
|
+
import { useCallback, useEffect as useEffect8, useState as useState7 } from "preact/hooks";
|
|
848
824
|
|
|
849
825
|
// src/widget/BoardView.tsx
|
|
850
826
|
import { useEffect as useEffect2, useMemo, useState as useState2 } from "preact/hooks";
|
|
@@ -4505,6 +4481,12 @@ var WIDGET_STYLES = `
|
|
|
4505
4481
|
|
|
4506
4482
|
// src/widget/mount.tsx
|
|
4507
4483
|
import { Fragment as Fragment3, jsx as jsx12, jsxs as jsxs12 } from "preact/jsx-runtime";
|
|
4484
|
+
function computeFabVisible(opts, externalId, visibilityAllowed) {
|
|
4485
|
+
if (!opts.showFAB) return false;
|
|
4486
|
+
if (opts.getExternalId !== void 0 && !externalId) return false;
|
|
4487
|
+
if (opts.requiresVisibilityCheck && !visibilityAllowed) return false;
|
|
4488
|
+
return true;
|
|
4489
|
+
}
|
|
4508
4490
|
function mountWidget(options) {
|
|
4509
4491
|
const shadow = options.host.attachShadow({ mode: "open" });
|
|
4510
4492
|
const style = document.createElement("style");
|
|
@@ -4553,7 +4535,29 @@ function mountWidget(options) {
|
|
|
4553
4535
|
}
|
|
4554
4536
|
}, []);
|
|
4555
4537
|
const externalId = options.getExternalId?.();
|
|
4556
|
-
const
|
|
4538
|
+
const [visibilityAllowed, setVisibilityAllowed] = useState7(
|
|
4539
|
+
!options.requiresVisibilityCheck
|
|
4540
|
+
);
|
|
4541
|
+
useEffect8(() => {
|
|
4542
|
+
if (!options.requiresVisibilityCheck) {
|
|
4543
|
+
setVisibilityAllowed(true);
|
|
4544
|
+
return;
|
|
4545
|
+
}
|
|
4546
|
+
if (!externalId || !options.checkVisibility) {
|
|
4547
|
+
setVisibilityAllowed(false);
|
|
4548
|
+
return;
|
|
4549
|
+
}
|
|
4550
|
+
let cancelled = false;
|
|
4551
|
+
options.checkVisibility(externalId).then((show) => {
|
|
4552
|
+
if (!cancelled) setVisibilityAllowed(show);
|
|
4553
|
+
}).catch(() => {
|
|
4554
|
+
if (!cancelled) setVisibilityAllowed(false);
|
|
4555
|
+
});
|
|
4556
|
+
return () => {
|
|
4557
|
+
cancelled = true;
|
|
4558
|
+
};
|
|
4559
|
+
}, [externalId]);
|
|
4560
|
+
const fabVisible = computeFabVisible(options, externalId, visibilityAllowed);
|
|
4557
4561
|
const showMineTab = Boolean(options.api && externalId);
|
|
4558
4562
|
return /* @__PURE__ */ jsxs12(Fragment3, { children: [
|
|
4559
4563
|
fabVisible && /* @__PURE__ */ jsx12(
|
|
@@ -4752,8 +4756,8 @@ function createFeedback(config) {
|
|
|
4752
4756
|
capture_method: captureMethod,
|
|
4753
4757
|
technical_context
|
|
4754
4758
|
};
|
|
4755
|
-
if ("0.
|
|
4756
|
-
payload.widget_version = "0.
|
|
4759
|
+
if ("0.17.0") {
|
|
4760
|
+
payload.widget_version = "0.17.0";
|
|
4757
4761
|
}
|
|
4758
4762
|
if (manualScreenshot) payload.screenshot = manualScreenshot;
|
|
4759
4763
|
if (values.synthetic) payload.synthetic = true;
|
|
@@ -4790,7 +4794,11 @@ function createFeedback(config) {
|
|
|
4790
4794
|
// Keep this a callback (not a snapshot) so the mount picks up identity
|
|
4791
4795
|
// changes that happen after createFeedback() — `notifyIdentityChanged()`
|
|
4792
4796
|
// is the trigger for a re-render.
|
|
4793
|
-
getExternalId: () => user?.id !== void 0 && user.id !== null && user.id !== "" ? String(user.id) : void 0
|
|
4797
|
+
getExternalId: () => user?.id !== void 0 && user.id !== null && user.id !== "" ? String(user.id) : void 0,
|
|
4798
|
+
// Phase 4: the manifest tells the loader whether this project gates the
|
|
4799
|
+
// widget per end-user; the loader forwards it as `requiresVisibilityCheck`.
|
|
4800
|
+
requiresVisibilityCheck: config.requiresVisibilityCheck ?? false,
|
|
4801
|
+
checkVisibility: (externalId) => api.checkVisibility(externalId)
|
|
4794
4802
|
});
|
|
4795
4803
|
const instance = {
|
|
4796
4804
|
show() {
|
|
@@ -4839,4 +4847,4 @@ function createFeedback(config) {
|
|
|
4839
4847
|
export {
|
|
4840
4848
|
createFeedback
|
|
4841
4849
|
};
|
|
4842
|
-
//# sourceMappingURL=chunk-
|
|
4850
|
+
//# sourceMappingURL=chunk-TG4YNEXE.mjs.map
|