@fixback/expo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/dist/FeedbackModal.d.ts +23 -0
- package/dist/FeedbackModal.js +201 -0
- package/dist/FixbackProvider.d.ts +35 -0
- package/dist/FixbackProvider.js +102 -0
- package/dist/adapters.d.ts +26 -0
- package/dist/adapters.js +143 -0
- package/dist/auto-report-backoff.d.ts +42 -0
- package/dist/auto-report-backoff.js +67 -0
- package/dist/boot.d.ts +65 -0
- package/dist/boot.js +60 -0
- package/dist/breadcrumbs.d.ts +276 -0
- package/dist/breadcrumbs.js +731 -0
- package/dist/client.d.ts +174 -0
- package/dist/client.js +351 -0
- package/dist/error-capture.d.ts +168 -0
- package/dist/error-capture.js +395 -0
- package/dist/http.d.ts +40 -0
- package/dist/http.js +20 -0
- package/dist/identity.d.ts +25 -0
- package/dist/identity.js +46 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +10 -0
- package/dist/report.d.ts +93 -0
- package/dist/report.js +67 -0
- package/dist/scrub.d.ts +55 -0
- package/dist/scrub.js +184 -0
- package/dist/shake.d.ts +51 -0
- package/dist/shake.js +79 -0
- package/dist/submit.d.ts +98 -0
- package/dist/submit.js +154 -0
- package/dist/tokens.d.ts +41 -0
- package/dist/tokens.js +46 -0
- package/dist/version.d.ts +13 -0
- package/dist/version.js +13 -0
- package/package.json +63 -0
package/dist/identity.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Public Reporter's stable, first-party anonymous id — the mobile
|
|
3
|
+
* counterpart of `packages/sdk/src/identity.ts`.
|
|
4
|
+
*
|
|
5
|
+
* When the host app does not supply its own identity, the SDK establishes a
|
|
6
|
+
* per-device id and persists it (AsyncStorage on React Native), so repeated
|
|
7
|
+
* boots from the same device are one Reporter. Persistence is best-effort: if
|
|
8
|
+
* storage is unavailable or failing, we fall back to an ephemeral id rather
|
|
9
|
+
* than throw — the SDK must never disturb the host app. The storage key is the
|
|
10
|
+
* web SDK's, so the shape stays recognisable across clients.
|
|
11
|
+
*/
|
|
12
|
+
export const ANONYMOUS_ID_STORAGE_KEY = "fixback.anonymousId";
|
|
13
|
+
/** A random id: a UUID where the platform offers one, else a compact fallback. */
|
|
14
|
+
export function generateAnonymousId() {
|
|
15
|
+
const c = globalThis.crypto;
|
|
16
|
+
if (c && typeof c.randomUUID === "function") {
|
|
17
|
+
try {
|
|
18
|
+
return c.randomUUID();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Fall through to the compact fallback.
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return `fb-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Return this device's anonymous id, creating and persisting one on first use.
|
|
28
|
+
* Any storage failure — absent module, quota, corrupted value — yields an
|
|
29
|
+
* ephemeral id for this session instead of an error.
|
|
30
|
+
*/
|
|
31
|
+
export async function ensureAnonymousId(storage) {
|
|
32
|
+
if (!storage)
|
|
33
|
+
return generateAnonymousId();
|
|
34
|
+
try {
|
|
35
|
+
const existing = await storage.getItem(ANONYMOUS_ID_STORAGE_KEY);
|
|
36
|
+
if (typeof existing === "string" && existing.length > 0)
|
|
37
|
+
return existing;
|
|
38
|
+
const id = generateAnonymousId();
|
|
39
|
+
await storage.setItem(ANONYMOUS_ID_STORAGE_KEY, id);
|
|
40
|
+
return id;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Storage blocked or absent — an ephemeral id keeps this boot working.
|
|
44
|
+
return generateAnonymousId();
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fixback/expo — the Fixback capture SDK for Expo / React Native apps.
|
|
3
|
+
*
|
|
4
|
+
* Shake the device to report feedback with trace Evidence; uncaught errors
|
|
5
|
+
* report themselves. Spec 0004; decision record ADR-0021.
|
|
6
|
+
*/
|
|
7
|
+
export { Fixback, FixbackProvider, useFixback, type FixbackHandle, type FixbackProviderProps, } from "./FixbackProvider";
|
|
8
|
+
export { canonicalizeOrigin, createFixbackClient, DEFAULT_API_URL, DEFAULT_SHAKE_SAMPLE_INTERVAL_MS, type ComposerContext, type ComposerDraft, type FixbackAdapters, type FixbackClient, type FixbackOptions, type FixbackStatus, type ShakeOptions, } from "./client";
|
|
9
|
+
export type { BootAnswer, CaptureConfig, IdentityInputs, ProjectGate, ReporterTier, } from "./boot";
|
|
10
|
+
export type { CaptureEnvironment, FeedbackSource, ReportContent, } from "./report";
|
|
11
|
+
export type { Breadcrumb, BreadcrumbCategory, BreadcrumbLevel, BeforeBreadcrumb, } from "./breadcrumbs";
|
|
12
|
+
export type { BeforeSend } from "./scrub";
|
|
13
|
+
export { createShakeDetector, DEFAULT_SHAKE_TUNING, type ShakeDetector, type ShakeSample, type ShakeTuning, } from "./shake";
|
|
14
|
+
export type { RecordedFeedback, ScreenshotFile, SubmitResult, } from "./submit";
|
|
15
|
+
export { EXPO_SDK_VERSION } from "./version";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fixback/expo — the Fixback capture SDK for Expo / React Native apps.
|
|
3
|
+
*
|
|
4
|
+
* Shake the device to report feedback with trace Evidence; uncaught errors
|
|
5
|
+
* report themselves. Spec 0004; decision record ADR-0021.
|
|
6
|
+
*/
|
|
7
|
+
export { Fixback, FixbackProvider, useFixback, } from "./FixbackProvider";
|
|
8
|
+
export { canonicalizeOrigin, createFixbackClient, DEFAULT_API_URL, DEFAULT_SHAKE_SAMPLE_INTERVAL_MS, } from "./client";
|
|
9
|
+
export { createShakeDetector, DEFAULT_SHAKE_TUNING, } from "./shake";
|
|
10
|
+
export { EXPO_SDK_VERSION } from "./version";
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ingest **feedback** content wire-contract, vendored, plus the helpers
|
|
3
|
+
* that assemble it from what a Reporter composed — kept file-parallel with
|
|
4
|
+
* `packages/sdk/src/report.ts` (ADR-0021). The mobile slice carries no
|
|
5
|
+
* `annotation` (element picking and drawing are web-only today, spec 0004 §G);
|
|
6
|
+
* every content field is optional on the server, so the payload simply omits it.
|
|
7
|
+
*/
|
|
8
|
+
import type { Breadcrumb } from "./breadcrumbs";
|
|
9
|
+
/**
|
|
10
|
+
* Where a Feedback came from — a human in the composer (`reporter`, the
|
|
11
|
+
* default) or the SDK's automatic error capture (`auto`). Mirrors the server's
|
|
12
|
+
* `FEEDBACK_SOURCES`; the server derives trust independently.
|
|
13
|
+
*/
|
|
14
|
+
export type FeedbackSource = "reporter" | "auto";
|
|
15
|
+
/** The capture environment recorded alongside a report. */
|
|
16
|
+
export interface CaptureEnvironment {
|
|
17
|
+
readonly viewportWidth?: number;
|
|
18
|
+
readonly viewportHeight?: number;
|
|
19
|
+
readonly browser?: string;
|
|
20
|
+
readonly sdkVersion?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The JSON content of a feedback submission — the object serialised into the
|
|
24
|
+
* multipart `payload` part next to the `key` and identity evidence. Every
|
|
25
|
+
* field is optional: none of it feeds the server's trust decision. The
|
|
26
|
+
* screenshot is a separate binary part, never part of this JSON.
|
|
27
|
+
*/
|
|
28
|
+
export interface ReportContent {
|
|
29
|
+
readonly comment?: string;
|
|
30
|
+
/**
|
|
31
|
+
* The Reporter's self-provided display **name / email** — display only,
|
|
32
|
+
* never a trust signal; the server derives the tier from identity evidence
|
|
33
|
+
* alone and ignores these.
|
|
34
|
+
*/
|
|
35
|
+
readonly reporterName?: string;
|
|
36
|
+
readonly reporterEmail?: string;
|
|
37
|
+
/** On mobile: `<origin>/<screen>` when a screen was tracked, else the origin. */
|
|
38
|
+
readonly url?: string;
|
|
39
|
+
readonly environment?: CaptureEnvironment;
|
|
40
|
+
/** The masked breadcrumb trace buffer that rode on this report. */
|
|
41
|
+
readonly trace?: readonly Breadcrumb[];
|
|
42
|
+
/**
|
|
43
|
+
* Provenance. Omitted for a manual report — the transport stamps the
|
|
44
|
+
* `reporter` default on the wire; set to `auto` by the SDK's error capture.
|
|
45
|
+
*/
|
|
46
|
+
readonly source?: FeedbackSource;
|
|
47
|
+
/** For `source: auto` only — the SDK's per-session error fingerprint. */
|
|
48
|
+
readonly errorSignature?: string;
|
|
49
|
+
/** For `source: auto` only — the running occurrence count within the session. */
|
|
50
|
+
readonly occurrences?: number;
|
|
51
|
+
}
|
|
52
|
+
/** What the composer hands to {@link assembleContent} when the Reporter sends. */
|
|
53
|
+
export interface ReportDraft {
|
|
54
|
+
readonly comment?: string;
|
|
55
|
+
readonly url?: string;
|
|
56
|
+
readonly environment?: CaptureEnvironment;
|
|
57
|
+
readonly trace?: readonly Breadcrumb[];
|
|
58
|
+
/** The Reporter's self-provided display name / email, if the SDK holds them. */
|
|
59
|
+
readonly reporterName?: string;
|
|
60
|
+
readonly reporterEmail?: string;
|
|
61
|
+
}
|
|
62
|
+
/** What the platform adapter reads off the device for the environment. */
|
|
63
|
+
export interface EnvironmentInputs {
|
|
64
|
+
/** The app window's dimensions in points, from `Dimensions.get("window")`. */
|
|
65
|
+
readonly windowWidth?: number;
|
|
66
|
+
readonly windowHeight?: number;
|
|
67
|
+
/** The platform description, e.g. `ios 17.4` (`Platform.OS` + `Platform.Version`). */
|
|
68
|
+
readonly os?: string;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Build the capture environment from what the adapter read off the device. A
|
|
72
|
+
* dimension is recorded only when it is a positive number (the server rejects
|
|
73
|
+
* `0` via `int().positive()`); the `browser` field — the server's free-form
|
|
74
|
+
* client string — carries a `react-native <os>` description.
|
|
75
|
+
*/
|
|
76
|
+
export declare function collectEnvironment(inputs: EnvironmentInputs, sdkVersion: string): CaptureEnvironment;
|
|
77
|
+
/**
|
|
78
|
+
* The server's hard bounds on the display-only reporter fields
|
|
79
|
+
* (`feedbackContentBody` in `apps/api/src/ingest/ingest.controller.ts`). An
|
|
80
|
+
* over-long value fails the whole submission with a 400 there, and unlike the
|
|
81
|
+
* web SDK — whose onboarding input is the source — these arrive as free-form
|
|
82
|
+
* init options, so the client clamps rather than lets one oversized display
|
|
83
|
+
* string swallow every report.
|
|
84
|
+
*/
|
|
85
|
+
export declare const MAX_REPORTER_NAME_LENGTH = 200;
|
|
86
|
+
export declare const MAX_REPORTER_EMAIL_LENGTH = 320;
|
|
87
|
+
/**
|
|
88
|
+
* Assemble the ingest content from what the Reporter composed. Empty pieces
|
|
89
|
+
* are dropped rather than sent as blanks: a whitespace-only comment or an
|
|
90
|
+
* empty environment is simply omitted, so the payload carries only what was
|
|
91
|
+
* actually provided (mirroring the server's all-optional content).
|
|
92
|
+
*/
|
|
93
|
+
export declare function assembleContent(draft: ReportDraft): ReportContent;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ingest **feedback** content wire-contract, vendored, plus the helpers
|
|
3
|
+
* that assemble it from what a Reporter composed — kept file-parallel with
|
|
4
|
+
* `packages/sdk/src/report.ts` (ADR-0021). The mobile slice carries no
|
|
5
|
+
* `annotation` (element picking and drawing are web-only today, spec 0004 §G);
|
|
6
|
+
* every content field is optional on the server, so the payload simply omits it.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Build the capture environment from what the adapter read off the device. A
|
|
10
|
+
* dimension is recorded only when it is a positive number (the server rejects
|
|
11
|
+
* `0` via `int().positive()`); the `browser` field — the server's free-form
|
|
12
|
+
* client string — carries a `react-native <os>` description.
|
|
13
|
+
*/
|
|
14
|
+
export function collectEnvironment(inputs, sdkVersion) {
|
|
15
|
+
const env = {};
|
|
16
|
+
const width = inputs.windowWidth;
|
|
17
|
+
if (typeof width === "number" && width > 0)
|
|
18
|
+
env.viewportWidth = Math.round(width);
|
|
19
|
+
const height = inputs.windowHeight;
|
|
20
|
+
if (typeof height === "number" && height > 0) {
|
|
21
|
+
env.viewportHeight = Math.round(height);
|
|
22
|
+
}
|
|
23
|
+
const os = typeof inputs.os === "string" ? inputs.os.trim() : "";
|
|
24
|
+
env.browser = os ? `react-native ${os}` : "react-native";
|
|
25
|
+
if (sdkVersion.length > 0)
|
|
26
|
+
env.sdkVersion = sdkVersion;
|
|
27
|
+
return env;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The server's hard bounds on the display-only reporter fields
|
|
31
|
+
* (`feedbackContentBody` in `apps/api/src/ingest/ingest.controller.ts`). An
|
|
32
|
+
* over-long value fails the whole submission with a 400 there, and unlike the
|
|
33
|
+
* web SDK — whose onboarding input is the source — these arrive as free-form
|
|
34
|
+
* init options, so the client clamps rather than lets one oversized display
|
|
35
|
+
* string swallow every report.
|
|
36
|
+
*/
|
|
37
|
+
export const MAX_REPORTER_NAME_LENGTH = 200;
|
|
38
|
+
export const MAX_REPORTER_EMAIL_LENGTH = 320;
|
|
39
|
+
/**
|
|
40
|
+
* Assemble the ingest content from what the Reporter composed. Empty pieces
|
|
41
|
+
* are dropped rather than sent as blanks: a whitespace-only comment or an
|
|
42
|
+
* empty environment is simply omitted, so the payload carries only what was
|
|
43
|
+
* actually provided (mirroring the server's all-optional content).
|
|
44
|
+
*/
|
|
45
|
+
export function assembleContent(draft) {
|
|
46
|
+
const content = {};
|
|
47
|
+
const comment = draft.comment?.trim();
|
|
48
|
+
if (comment)
|
|
49
|
+
content.comment = comment;
|
|
50
|
+
const reporterName = draft.reporterName?.trim();
|
|
51
|
+
if (reporterName) {
|
|
52
|
+
content.reporterName = reporterName.slice(0, MAX_REPORTER_NAME_LENGTH);
|
|
53
|
+
}
|
|
54
|
+
const reporterEmail = draft.reporterEmail?.trim();
|
|
55
|
+
if (reporterEmail) {
|
|
56
|
+
content.reporterEmail = reporterEmail.slice(0, MAX_REPORTER_EMAIL_LENGTH);
|
|
57
|
+
}
|
|
58
|
+
if (draft.url)
|
|
59
|
+
content.url = draft.url;
|
|
60
|
+
if (draft.environment && Object.keys(draft.environment).length > 0) {
|
|
61
|
+
content.environment = draft.environment;
|
|
62
|
+
}
|
|
63
|
+
// Only a non-empty trace rides along — an empty buffer is simply omitted.
|
|
64
|
+
if (draft.trace && draft.trace.length > 0)
|
|
65
|
+
content.trace = draft.trace;
|
|
66
|
+
return content;
|
|
67
|
+
}
|
package/dist/scrub.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single client-side **scrub choke point** every report passes through
|
|
3
|
+
* before transport — a verbatim port of `packages/sdk/src/scrub.ts` (spec 0003
|
|
4
|
+
* §C; ADR-0021 keeps the two clients file-parallel).
|
|
5
|
+
*
|
|
6
|
+
* Masking is the SDK's job, done on the device before anything leaves it. The
|
|
7
|
+
* default scrubbers are **on**: they strip credentials, query strings, and
|
|
8
|
+
* bearer tokens from URLs, and redact obvious PII (emails, long digit runs,
|
|
9
|
+
* bearer tokens) from crumb and error text. The result is then handed to an
|
|
10
|
+
* optional per-project hook that can mutate it further or drop the whole report
|
|
11
|
+
* by returning `null`. The hook is **synchronous and network-free** by
|
|
12
|
+
* contract, and both manual (composer) and automatic (error-capture) reports
|
|
13
|
+
* run through the very same choke point.
|
|
14
|
+
*/
|
|
15
|
+
import type { ReportContent } from "./report";
|
|
16
|
+
/** The per-project client scrub hook. Return `null` to drop the whole report. */
|
|
17
|
+
export type BeforeSend = (draft: ReportContent) => ReportContent | null;
|
|
18
|
+
/** Options for {@link runBeforeSend}. */
|
|
19
|
+
export interface BeforeSendOptions {
|
|
20
|
+
/** The per-project hook, run **after** the default scrubbers. */
|
|
21
|
+
readonly hook?: BeforeSend | null;
|
|
22
|
+
/** Run the built-in default scrubbers first. Defaults to `true`. */
|
|
23
|
+
readonly scrub?: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Redact obvious PII from free text: email addresses, `Bearer <token>` /
|
|
27
|
+
* `token <value>` pairs, and long digit runs. Conservative by design — it keeps
|
|
28
|
+
* the shape of the message readable while removing the sensitive spans.
|
|
29
|
+
*/
|
|
30
|
+
export declare function redactPii(text: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Strip the sensitive parts of a URL: userinfo credentials, the entire query
|
|
33
|
+
* string, a token-bearing fragment (one that carries `key=value`), and PII in
|
|
34
|
+
* the **path segments**. Plain hash routes (`#/checkout`) are kept. Works on
|
|
35
|
+
* absolute and relative URLs alike, with no dependency and no throw. The host
|
|
36
|
+
* (authority) is never redacted.
|
|
37
|
+
*/
|
|
38
|
+
export declare function scrubUrl(url: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* Apply the built-in default scrubbers to a report draft: strip the report URL,
|
|
41
|
+
* and scrub every crumb's URLs and redact PII from its text. The Reporter's own
|
|
42
|
+
* `comment` is intentionally left untouched — it is authored on purpose, not
|
|
43
|
+
* scraped. The screenshot is handled elsewhere (composer preview + removal,
|
|
44
|
+
* spec 0004 §C); this is the final URL/PII sweep.
|
|
45
|
+
*/
|
|
46
|
+
export declare function applyDefaultScrub(draft: ReportContent): ReportContent;
|
|
47
|
+
/**
|
|
48
|
+
* Run the report draft through the client scrub choke point: the default
|
|
49
|
+
* scrubbers first (unless `scrub` is `false`), then the optional per-project
|
|
50
|
+
* hook. Returns the scrubbed (and possibly hook-mutated) draft, or `null` when
|
|
51
|
+
* the hook drops the report. A hook that throws is treated as a no-op — the
|
|
52
|
+
* already-scrubbed draft is kept, so a buggy hook never breaks the report path
|
|
53
|
+
* nor leaks unscrubbed data.
|
|
54
|
+
*/
|
|
55
|
+
export declare function runBeforeSend(draft: ReportContent, options?: BeforeSendOptions): ReportContent | null;
|
package/dist/scrub.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single client-side **scrub choke point** every report passes through
|
|
3
|
+
* before transport — a verbatim port of `packages/sdk/src/scrub.ts` (spec 0003
|
|
4
|
+
* §C; ADR-0021 keeps the two clients file-parallel).
|
|
5
|
+
*
|
|
6
|
+
* Masking is the SDK's job, done on the device before anything leaves it. The
|
|
7
|
+
* default scrubbers are **on**: they strip credentials, query strings, and
|
|
8
|
+
* bearer tokens from URLs, and redact obvious PII (emails, long digit runs,
|
|
9
|
+
* bearer tokens) from crumb and error text. The result is then handed to an
|
|
10
|
+
* optional per-project hook that can mutate it further or drop the whole report
|
|
11
|
+
* by returning `null`. The hook is **synchronous and network-free** by
|
|
12
|
+
* contract, and both manual (composer) and automatic (error-capture) reports
|
|
13
|
+
* run through the very same choke point.
|
|
14
|
+
*/
|
|
15
|
+
/** A digit run at least this long is treated as sensitive (phone, card, id). */
|
|
16
|
+
const MIN_DIGIT_RUN = 7;
|
|
17
|
+
const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
|
|
18
|
+
const DIGIT_RUN_RE = new RegExp(`\\d{${MIN_DIGIT_RUN},}`, "g");
|
|
19
|
+
const BEARER_RE = /\b(bearer|token)\s+[\w.\-~+/]+=*/gi;
|
|
20
|
+
/** An http(s) URL embedded in free text (e.g. a console log line). */
|
|
21
|
+
const URL_IN_TEXT_RE = /\bhttps?:\/\/[^\s"'<>]+/gi;
|
|
22
|
+
/**
|
|
23
|
+
* Redact obvious PII from free text: email addresses, `Bearer <token>` /
|
|
24
|
+
* `token <value>` pairs, and long digit runs. Conservative by design — it keeps
|
|
25
|
+
* the shape of the message readable while removing the sensitive spans.
|
|
26
|
+
*/
|
|
27
|
+
export function redactPii(text) {
|
|
28
|
+
if (typeof text !== "string" || text.length === 0)
|
|
29
|
+
return text;
|
|
30
|
+
return text
|
|
31
|
+
.replace(EMAIL_RE, "[redacted-email]")
|
|
32
|
+
.replace(BEARER_RE, (_match, keyword) => `${keyword} [redacted]`)
|
|
33
|
+
.replace(DIGIT_RUN_RE, "[redacted-number]");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Redact PII from the **path** portion of a URL: emails and long digit runs in
|
|
37
|
+
* path (or fragment-route) segments. Deliberately narrower than
|
|
38
|
+
* {@link redactPii}: no bearer/token rule and only ever applied to the
|
|
39
|
+
* post-authority remainder, so a host or port with digits is never touched.
|
|
40
|
+
*/
|
|
41
|
+
function redactPathPii(pathAndBeyond) {
|
|
42
|
+
return pathAndBeyond
|
|
43
|
+
.replace(EMAIL_RE, "[redacted-email]")
|
|
44
|
+
.replace(DIGIT_RUN_RE, "[redacted-number]");
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Strip the sensitive parts of a URL: userinfo credentials, the entire query
|
|
48
|
+
* string, a token-bearing fragment (one that carries `key=value`), and PII in
|
|
49
|
+
* the **path segments**. Plain hash routes (`#/checkout`) are kept. Works on
|
|
50
|
+
* absolute and relative URLs alike, with no dependency and no throw. The host
|
|
51
|
+
* (authority) is never redacted.
|
|
52
|
+
*/
|
|
53
|
+
export function scrubUrl(url) {
|
|
54
|
+
if (typeof url !== "string" || url.length === 0)
|
|
55
|
+
return url;
|
|
56
|
+
let out = url;
|
|
57
|
+
// Drop userinfo credentials: scheme://user:pass@host → scheme://host
|
|
58
|
+
out = out.replace(/(^[a-z][a-z0-9+.-]*:\/\/)[^/@?#]*@/i, "$1");
|
|
59
|
+
// Drop the query string entirely (everything from '?' up to a '#').
|
|
60
|
+
out = out.replace(/\?[^#]*/, "");
|
|
61
|
+
// Drop a token-bearing fragment (it carries '='); keep plain hash routes.
|
|
62
|
+
out = out.replace(/#.*$/, (fragment) => fragment.includes("=") ? "" : fragment);
|
|
63
|
+
// Redact PII in the path (and any kept fragment route), never in the authority.
|
|
64
|
+
const absolute = out.match(/^([a-z][a-z0-9+.-]*:\/\/[^/?#]*)([\s\S]*)$/i);
|
|
65
|
+
if (absolute)
|
|
66
|
+
return absolute[1] + redactPathPii(absolute[2] ?? "");
|
|
67
|
+
const protocolRelative = out.match(/^(\/\/[^/?#]*)([\s\S]*)$/);
|
|
68
|
+
if (protocolRelative) {
|
|
69
|
+
return protocolRelative[1] + redactPathPii(protocolRelative[2] ?? "");
|
|
70
|
+
}
|
|
71
|
+
// A relative URL is all path — redact the whole thing.
|
|
72
|
+
return redactPathPii(out);
|
|
73
|
+
}
|
|
74
|
+
function scrubCrumbData(data) {
|
|
75
|
+
const next = { ...data };
|
|
76
|
+
if (typeof next.url === "string")
|
|
77
|
+
next.url = scrubUrl(next.url);
|
|
78
|
+
if (typeof next.from === "string")
|
|
79
|
+
next.from = scrubUrl(next.from);
|
|
80
|
+
if (typeof next.to === "string")
|
|
81
|
+
next.to = scrubUrl(next.to);
|
|
82
|
+
return next;
|
|
83
|
+
}
|
|
84
|
+
/** Scrub a crumb's free-text message: strip URL query strings, then redact PII. */
|
|
85
|
+
function scrubMessage(message) {
|
|
86
|
+
return redactPii(message.replace(URL_IN_TEXT_RE, (url) => scrubUrl(url)));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Scrub the string leaves of a structured console-arg value — the value a
|
|
90
|
+
* `json` arg carries. Walks arrays and plain objects, applying the same
|
|
91
|
+
* URL-strip + PII redaction as a crumb message to every nested string.
|
|
92
|
+
*/
|
|
93
|
+
function scrubArgValue(value) {
|
|
94
|
+
if (typeof value === "string")
|
|
95
|
+
return scrubMessage(value);
|
|
96
|
+
if (Array.isArray(value))
|
|
97
|
+
return value.map(scrubArgValue);
|
|
98
|
+
if (value && typeof value === "object") {
|
|
99
|
+
const out = {};
|
|
100
|
+
for (const [key, v] of Object.entries(value)) {
|
|
101
|
+
out[key] = scrubArgValue(v);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Scrub one structured console argument: redact PII / strip URL query strings
|
|
109
|
+
* from a `string` value, from every string leaf of a `json` value, and from an
|
|
110
|
+
* `error` value's `message` and `stack`. Other tags carry no free text.
|
|
111
|
+
*/
|
|
112
|
+
function scrubConsoleArg(arg) {
|
|
113
|
+
switch (arg.t) {
|
|
114
|
+
case "string":
|
|
115
|
+
return typeof arg.v === "string" ? { t: "string", v: scrubMessage(arg.v) } : arg;
|
|
116
|
+
case "json":
|
|
117
|
+
return { t: "json", v: scrubArgValue(arg.v) };
|
|
118
|
+
case "error": {
|
|
119
|
+
if (!arg.v || typeof arg.v !== "object")
|
|
120
|
+
return arg;
|
|
121
|
+
const v = arg.v;
|
|
122
|
+
const next = { ...v };
|
|
123
|
+
if (typeof next.message === "string")
|
|
124
|
+
next.message = scrubMessage(next.message);
|
|
125
|
+
if (typeof next.stack === "string")
|
|
126
|
+
next.stack = scrubMessage(next.stack);
|
|
127
|
+
return { t: "error", v: next };
|
|
128
|
+
}
|
|
129
|
+
default:
|
|
130
|
+
return arg;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function scrubCrumb(crumb) {
|
|
134
|
+
const next = { ...crumb };
|
|
135
|
+
if (typeof next.message === "string")
|
|
136
|
+
next.message = scrubMessage(next.message);
|
|
137
|
+
if (next.data)
|
|
138
|
+
next.data = scrubCrumbData(next.data);
|
|
139
|
+
// A rich network crumb carries its scrubbed URL at the top level — sweep it
|
|
140
|
+
// again here (idempotent) so the choke point holds wherever the URL rode.
|
|
141
|
+
if (typeof next.url === "string")
|
|
142
|
+
next.url = scrubUrl(next.url);
|
|
143
|
+
// A console crumb's structured args carry the same free text as its preview.
|
|
144
|
+
if (Array.isArray(next.args))
|
|
145
|
+
next.args = next.args.map(scrubConsoleArg);
|
|
146
|
+
return next;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Apply the built-in default scrubbers to a report draft: strip the report URL,
|
|
150
|
+
* and scrub every crumb's URLs and redact PII from its text. The Reporter's own
|
|
151
|
+
* `comment` is intentionally left untouched — it is authored on purpose, not
|
|
152
|
+
* scraped. The screenshot is handled elsewhere (composer preview + removal,
|
|
153
|
+
* spec 0004 §C); this is the final URL/PII sweep.
|
|
154
|
+
*/
|
|
155
|
+
export function applyDefaultScrub(draft) {
|
|
156
|
+
const next = { ...draft };
|
|
157
|
+
if (typeof next.url === "string")
|
|
158
|
+
next.url = scrubUrl(next.url);
|
|
159
|
+
if (next.trace && next.trace.length > 0) {
|
|
160
|
+
next.trace = next.trace.map(scrubCrumb);
|
|
161
|
+
}
|
|
162
|
+
return next;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Run the report draft through the client scrub choke point: the default
|
|
166
|
+
* scrubbers first (unless `scrub` is `false`), then the optional per-project
|
|
167
|
+
* hook. Returns the scrubbed (and possibly hook-mutated) draft, or `null` when
|
|
168
|
+
* the hook drops the report. A hook that throws is treated as a no-op — the
|
|
169
|
+
* already-scrubbed draft is kept, so a buggy hook never breaks the report path
|
|
170
|
+
* nor leaks unscrubbed data.
|
|
171
|
+
*/
|
|
172
|
+
export function runBeforeSend(draft, options = {}) {
|
|
173
|
+
const current = options.scrub === false ? draft : applyDefaultScrub(draft);
|
|
174
|
+
const hook = options.hook;
|
|
175
|
+
if (!hook)
|
|
176
|
+
return current;
|
|
177
|
+
try {
|
|
178
|
+
const result = hook(current);
|
|
179
|
+
return result ?? null;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return current;
|
|
183
|
+
}
|
|
184
|
+
}
|
package/dist/shake.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure **shake detector** — the mobile launcher's brain (spec 0004 §B,
|
|
3
|
+
* ADR-0021). It consumes raw accelerometer samples (in g, gravity included, as
|
|
4
|
+
* `expo-sensors` reports them) and fires `onShake` when a deliberate shake is
|
|
5
|
+
* recognised: enough acceleration-magnitude **peaks** over a threshold, close
|
|
6
|
+
* enough together, debounced so one sustained swing counts once, with a
|
|
7
|
+
* cooldown so a single shake never double-opens the composer.
|
|
8
|
+
*
|
|
9
|
+
* Kept free of any React Native import so the recogniser is unit-tested
|
|
10
|
+
* deterministically; the sensor wiring lives in `adapters.ts`.
|
|
11
|
+
*/
|
|
12
|
+
/** One accelerometer reading, in g per axis, stamped with an epoch-ms time. */
|
|
13
|
+
export interface ShakeSample {
|
|
14
|
+
readonly x: number;
|
|
15
|
+
readonly y: number;
|
|
16
|
+
readonly z: number;
|
|
17
|
+
/** Epoch milliseconds when the sample was taken. */
|
|
18
|
+
readonly at: number;
|
|
19
|
+
}
|
|
20
|
+
/** The tunable numbers of the recogniser — spec 0004 §B's defaults. */
|
|
21
|
+
export interface ShakeTuning {
|
|
22
|
+
/** A sample's magnitude must reach this (in g) to count as a peak. */
|
|
23
|
+
readonly thresholdG: number;
|
|
24
|
+
/** How many peaks within {@link windowMs} fire a shake. */
|
|
25
|
+
readonly minPeaks: number;
|
|
26
|
+
/** The sliding window peaks must fall inside. */
|
|
27
|
+
readonly windowMs: number;
|
|
28
|
+
/** Two peaks closer than this are one swing — debounce. */
|
|
29
|
+
readonly minGapMs: number;
|
|
30
|
+
/** After firing, ignore everything for this long. */
|
|
31
|
+
readonly cooldownMs: number;
|
|
32
|
+
}
|
|
33
|
+
/** The default tuning: a deliberate shake, not a bump or a phone put down hard. */
|
|
34
|
+
export declare const DEFAULT_SHAKE_TUNING: ShakeTuning;
|
|
35
|
+
/** Configuration for {@link createShakeDetector}. */
|
|
36
|
+
export interface ShakeDetectorOptions extends Partial<ShakeTuning> {
|
|
37
|
+
/** Called once per recognised shake. A throwing callback is swallowed. */
|
|
38
|
+
readonly onShake: () => void;
|
|
39
|
+
}
|
|
40
|
+
/** A live shake recogniser. */
|
|
41
|
+
export interface ShakeDetector {
|
|
42
|
+
/** Feed one accelerometer sample. */
|
|
43
|
+
sample(sample: ShakeSample): void;
|
|
44
|
+
/** Forget accumulated peaks and any cooldown. */
|
|
45
|
+
reset(): void;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Create a shake recogniser. The peak list is pruned to the sliding window on
|
|
49
|
+
* every sample, so memory stays bounded however long the app runs.
|
|
50
|
+
*/
|
|
51
|
+
export declare function createShakeDetector(options: ShakeDetectorOptions): ShakeDetector;
|
package/dist/shake.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure **shake detector** — the mobile launcher's brain (spec 0004 §B,
|
|
3
|
+
* ADR-0021). It consumes raw accelerometer samples (in g, gravity included, as
|
|
4
|
+
* `expo-sensors` reports them) and fires `onShake` when a deliberate shake is
|
|
5
|
+
* recognised: enough acceleration-magnitude **peaks** over a threshold, close
|
|
6
|
+
* enough together, debounced so one sustained swing counts once, with a
|
|
7
|
+
* cooldown so a single shake never double-opens the composer.
|
|
8
|
+
*
|
|
9
|
+
* Kept free of any React Native import so the recogniser is unit-tested
|
|
10
|
+
* deterministically; the sensor wiring lives in `adapters.ts`.
|
|
11
|
+
*/
|
|
12
|
+
/** The default tuning: a deliberate shake, not a bump or a phone put down hard. */
|
|
13
|
+
export const DEFAULT_SHAKE_TUNING = {
|
|
14
|
+
thresholdG: 2.0,
|
|
15
|
+
minPeaks: 3,
|
|
16
|
+
windowMs: 900,
|
|
17
|
+
minGapMs: 70,
|
|
18
|
+
cooldownMs: 2000,
|
|
19
|
+
};
|
|
20
|
+
function tuned(value, fallback) {
|
|
21
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
22
|
+
? value
|
|
23
|
+
: fallback;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Create a shake recogniser. The peak list is pruned to the sliding window on
|
|
27
|
+
* every sample, so memory stays bounded however long the app runs.
|
|
28
|
+
*/
|
|
29
|
+
export function createShakeDetector(options) {
|
|
30
|
+
const thresholdG = tuned(options.thresholdG, DEFAULT_SHAKE_TUNING.thresholdG);
|
|
31
|
+
const minPeaks = Math.max(1, Math.floor(tuned(options.minPeaks, DEFAULT_SHAKE_TUNING.minPeaks)));
|
|
32
|
+
const windowMs = tuned(options.windowMs, DEFAULT_SHAKE_TUNING.windowMs);
|
|
33
|
+
const minGapMs = tuned(options.minGapMs, DEFAULT_SHAKE_TUNING.minGapMs);
|
|
34
|
+
const cooldownMs = tuned(options.cooldownMs, DEFAULT_SHAKE_TUNING.cooldownMs);
|
|
35
|
+
/** Epoch-ms times of the peaks inside the current window. */
|
|
36
|
+
let peaks = [];
|
|
37
|
+
let lastPeakAt = Number.NEGATIVE_INFINITY;
|
|
38
|
+
let lastFiredAt = Number.NEGATIVE_INFINITY;
|
|
39
|
+
return {
|
|
40
|
+
sample(sample) {
|
|
41
|
+
const { x, y, z, at } = sample;
|
|
42
|
+
if (!Number.isFinite(x) ||
|
|
43
|
+
!Number.isFinite(y) ||
|
|
44
|
+
!Number.isFinite(z) ||
|
|
45
|
+
!Number.isFinite(at)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// Everything inside the cooldown is ignored — including peak bookkeeping,
|
|
49
|
+
// so the tail of the shake that fired can't seed the next window.
|
|
50
|
+
if (at - lastFiredAt < cooldownMs)
|
|
51
|
+
return;
|
|
52
|
+
const magnitude = Math.sqrt(x * x + y * y + z * z);
|
|
53
|
+
if (magnitude < thresholdG)
|
|
54
|
+
return;
|
|
55
|
+
// Debounce: a sustained swing above the threshold is one peak, not many.
|
|
56
|
+
if (at - lastPeakAt < minGapMs)
|
|
57
|
+
return;
|
|
58
|
+
lastPeakAt = at;
|
|
59
|
+
peaks.push(at);
|
|
60
|
+
const cutoff = at - windowMs;
|
|
61
|
+
peaks = peaks.filter((t) => t >= cutoff);
|
|
62
|
+
if (peaks.length >= minPeaks) {
|
|
63
|
+
peaks = [];
|
|
64
|
+
lastFiredAt = at;
|
|
65
|
+
try {
|
|
66
|
+
options.onShake();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// The gesture must never throw into the host app.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
reset() {
|
|
74
|
+
peaks = [];
|
|
75
|
+
lastPeakAt = Number.NEGATIVE_INFINITY;
|
|
76
|
+
lastFiredAt = Number.NEGATIVE_INFINITY;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|