drengr-js 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.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Drengr Analytics for JavaScript runtimes — Web, React Native, Electron.
3
+ * One call captures every fetch/XHR exchange (secret/PII redaction applied
4
+ * in-process before anything leaves the runtime) and ships it to your org's
5
+ * ingest endpoint under a publishable key.
6
+ *
7
+ * import { Drengr } from 'drengr-js';
8
+ * Drengr.start({
9
+ * ingestUrl: 'https://<ref>.supabase.co/functions/v1/ingest',
10
+ * publishableKey: 'drengr_pk_…',
11
+ * appPackage: 'com.example.app',
12
+ * });
13
+ */
14
+ import { type CaptureOptions, type NetworkEvent } from './capture.js';
15
+ import { IngestSink, type StorageAdapter } from './sink.js';
16
+ export type { NetworkEvent, StorageAdapter };
17
+ export { IngestSink };
18
+ export * as redact from './redact.js';
19
+ export interface DrengrOptions {
20
+ ingestUrl: string;
21
+ publishableKey: string;
22
+ /** Stable app identifier shown in the dashboard (reverse-DNS or domain). */
23
+ appPackage: string;
24
+ /** Extra envelope context merged over the defaults. */
25
+ context?: Record<string, unknown>;
26
+ /** Storage for the offline queue + install id. Default: localStorage or memory. */
27
+ storage?: StorageAdapter;
28
+ maxBodyBytes?: number;
29
+ captureWhen?: CaptureOptions['captureWhen'];
30
+ ignoreHosts?: string[];
31
+ redactHeaders?: string[];
32
+ /** Start paused (e.g. behind a consent gate); call setEnabled(true) later. */
33
+ enabled?: boolean;
34
+ /** Called after each capture, before delivery — for debugging. */
35
+ onEvent?: (e: NetworkEvent) => void;
36
+ }
37
+ export declare const Drengr: {
38
+ /** Install capture + delivery. Subsequent calls are ignored (stop() first). */
39
+ start(options: DrengrOptions): void;
40
+ /** Pause/resume capture (consent gate). Delivery of already-captured events continues. */
41
+ setEnabled(v: boolean): void;
42
+ /** Persistently opt this install OUT of capture (GDPR). Unlike setEnabled(false),
43
+ * this survives restart: it writes the opt-out flag to storage AND pauses now, so
44
+ * start() reads it and stays paused on the next launch. */
45
+ optOut(): void;
46
+ /** Reverse optOut(): clear the persisted flag and resume capture. */
47
+ optIn(): void;
48
+ /** Flush the queue now (e.g. before navigation). Best-effort. */
49
+ flush(): Promise<void>;
50
+ /** Sets external_id (your own stable, non-PII user id — not an email) on the
51
+ * session and all events hereafter; emits one identify event. traits are
52
+ * redacted before delivery. Fail-open: no-op if start() hasn't run or
53
+ * externalId is empty. */
54
+ identify(externalId: string, traits?: Record<string, unknown>): void;
55
+ /** Tags the session with an experiment variant, attached to all events
56
+ * hereafter as `experiments`. Pass a null/empty variant to clear the key. */
57
+ setExperiment(key: string, variant: string | null): void;
58
+ /** Uninstall capture, restoring the runtime's own fetch/XHR. */
59
+ stop(): void;
60
+ };
61
+ export declare const SDK_VERSION = "0.1.0";
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Drengr Analytics for JavaScript runtimes — Web, React Native, Electron.
3
+ * One call captures every fetch/XHR exchange (secret/PII redaction applied
4
+ * in-process before anything leaves the runtime) and ships it to your org's
5
+ * ingest endpoint under a publishable key.
6
+ *
7
+ * import { Drengr } from 'drengr-js';
8
+ * Drengr.start({
9
+ * ingestUrl: 'https://<ref>.supabase.co/functions/v1/ingest',
10
+ * publishableKey: 'drengr_pk_…',
11
+ * appPackage: 'com.example.app',
12
+ * });
13
+ */
14
+ import { install, isInstalled, setEnabled, uninstall, } from './capture.js';
15
+ import { IngestSink, defaultStorage } from './sink.js';
16
+ export { IngestSink };
17
+ export * as redact from './redact.js';
18
+ const INSTALL_KEY = 'drengr_install_id';
19
+ const OPTOUT_KEY = 'drengr_opt_out';
20
+ let sink = null;
21
+ let storageRef = null;
22
+ function runtimeOs() {
23
+ try {
24
+ const nav = globalThis.navigator;
25
+ if (nav?.product === 'ReactNative')
26
+ return 'react-native';
27
+ const proc = globalThis.process;
28
+ if (proc?.versions?.electron)
29
+ return 'electron';
30
+ if (nav?.userAgent)
31
+ return 'web';
32
+ if (proc?.versions?.node)
33
+ return 'node';
34
+ }
35
+ catch { /* fall through */ }
36
+ return 'js';
37
+ }
38
+ function isThenable(v) {
39
+ return typeof v === 'object' && v !== null && typeof v.then === 'function';
40
+ }
41
+ function genId() {
42
+ try {
43
+ const c = globalThis.crypto;
44
+ if (c?.randomUUID)
45
+ return c.randomUUID();
46
+ }
47
+ catch { /* fall through */ }
48
+ return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
49
+ }
50
+ export const Drengr = {
51
+ /** Install capture + delivery. Subsequent calls are ignored (stop() first). */
52
+ start(options) {
53
+ if (isInstalled())
54
+ return;
55
+ const storage = options.storage ?? defaultStorage();
56
+ storageRef = storage; // so optOut()/optIn() can persist the choice after start()
57
+ // A mutable context: on async storage (React Native's AsyncStorage) the
58
+ // install_id resolves after start() returns, so the sink reads it lazily and
59
+ // we backfill here before the first flush.
60
+ const context = {
61
+ app_package: options.appPackage,
62
+ os: runtimeOs(),
63
+ install_id: '', // filled sync (localStorage) or async (AsyncStorage) below
64
+ session_id: `s-${Date.now()}`,
65
+ sdk_version: SDK_VERSION,
66
+ ...options.context,
67
+ };
68
+ const s = new IngestSink({
69
+ url: options.ingestUrl,
70
+ publishableKey: options.publishableKey,
71
+ storage,
72
+ context,
73
+ });
74
+ sink = s;
75
+ // CONSENT-SAFE START. Sync storage (localStorage): read the opt-out NOW and
76
+ // fold it into startPaused so an opted-out user is paused BEFORE capture goes
77
+ // live — reading it a microtask later let same-tick requests leak. Async
78
+ // storage (RN AsyncStorage): can't read synchronously, so start PAUSED and
79
+ // enable only after the async read resolves (never if opted out).
80
+ const asyncStore = isThenable(storage.getItem(INSTALL_KEY));
81
+ const syncOptOut = !asyncStore && storage.getItem(OPTOUT_KEY) === '1';
82
+ const startPaused = options.enabled === false || asyncStore || syncOptOut;
83
+ // Always exclude our OWN ingest host: on React Native fetch is XHR-backed, so
84
+ // the pre-patch fetch the sink uses still routes through the patched XHR and
85
+ // would self-capture every delivery. Ignoring the host closes that loop.
86
+ const ignoreHosts = new Set((options.ignoreHosts ?? []).map((h) => h.toLowerCase()));
87
+ try {
88
+ const ih = new URL(options.ingestUrl).host;
89
+ if (ih)
90
+ ignoreHosts.add(ih.toLowerCase());
91
+ }
92
+ catch { /* malformed ingest URL: nothing to add */ }
93
+ install({
94
+ maxBodyBytes: options.maxBodyBytes,
95
+ captureWhen: options.captureWhen,
96
+ ignoreHosts,
97
+ redactHeaderNames: options.redactHeaders
98
+ ? new Set(options.redactHeaders.map((h) => h.toLowerCase()))
99
+ : undefined,
100
+ onEvent: (e) => {
101
+ try {
102
+ options.onEvent?.(e);
103
+ }
104
+ catch { /* app callback must not break delivery */ }
105
+ s.addNetwork(e);
106
+ },
107
+ });
108
+ if (startPaused)
109
+ setEnabled(false);
110
+ // Resolve install_id + opt-out across BOTH sync and async storage.
111
+ void Promise.resolve(storage.getItem(INSTALL_KEY)).then((existing) => {
112
+ let id = typeof existing === 'string' && existing ? existing : '';
113
+ if (!id) {
114
+ id = genId();
115
+ try {
116
+ void storage.setItem(INSTALL_KEY, id);
117
+ }
118
+ catch { /* memory-only ok */ }
119
+ }
120
+ context.install_id = id;
121
+ });
122
+ void Promise.resolve(storage.getItem(OPTOUT_KEY)).then((v) => {
123
+ if (v === '1') {
124
+ setEnabled(false); // opted out — stay paused regardless
125
+ }
126
+ else if (options.enabled !== false && !syncOptOut) {
127
+ setEnabled(true); // not opted out and not explicitly disabled — resume
128
+ }
129
+ });
130
+ },
131
+ /** Pause/resume capture (consent gate). Delivery of already-captured events continues. */
132
+ setEnabled(v) {
133
+ setEnabled(v);
134
+ },
135
+ /** Persistently opt this install OUT of capture (GDPR). Unlike setEnabled(false),
136
+ * this survives restart: it writes the opt-out flag to storage AND pauses now, so
137
+ * start() reads it and stays paused on the next launch. */
138
+ optOut() {
139
+ setEnabled(false);
140
+ try {
141
+ void storageRef?.setItem(OPTOUT_KEY, '1');
142
+ }
143
+ catch { /* memory-only ok */ }
144
+ },
145
+ /** Reverse optOut(): clear the persisted flag and resume capture. */
146
+ optIn() {
147
+ try {
148
+ void storageRef?.removeItem(OPTOUT_KEY);
149
+ }
150
+ catch { /* memory-only ok */ }
151
+ setEnabled(true);
152
+ },
153
+ /** Flush the queue now (e.g. before navigation). Best-effort. */
154
+ flush() {
155
+ return sink?.flush() ?? Promise.resolve();
156
+ },
157
+ /** Sets external_id (your own stable, non-PII user id — not an email) on the
158
+ * session and all events hereafter; emits one identify event. traits are
159
+ * redacted before delivery. Fail-open: no-op if start() hasn't run or
160
+ * externalId is empty. */
161
+ identify(externalId, traits) {
162
+ try {
163
+ sink?.identify(externalId, traits);
164
+ }
165
+ catch { /* fail-open */ }
166
+ },
167
+ /** Tags the session with an experiment variant, attached to all events
168
+ * hereafter as `experiments`. Pass a null/empty variant to clear the key. */
169
+ setExperiment(key, variant) {
170
+ try {
171
+ sink?.setExperiment(key, variant);
172
+ }
173
+ catch { /* fail-open */ }
174
+ },
175
+ /** Uninstall capture, restoring the runtime's own fetch/XHR. */
176
+ stop() {
177
+ uninstall();
178
+ sink = null;
179
+ },
180
+ };
181
+ export const SDK_VERSION = '0.1.0';
@@ -0,0 +1,18 @@
1
+ export declare const redactMask = "[REDACTED]";
2
+ /** Header names (lowercase) whose values are always masked. */
3
+ export declare const sensitiveHeaders: Set<string>;
4
+ /** Whether a header/query/field name denotes a secret. */
5
+ export declare function isSensitiveName(name: string): boolean;
6
+ /** Mask the values of sensitive headers; preserve every key. */
7
+ export declare function redactHeaders(headers: Record<string, string>, extra?: Set<string>): Record<string, string>;
8
+ /** Redact card numbers, JWTs, bearer tokens, cookie lines, and PII anywhere in a string. */
9
+ export declare function scrubValues(s: string): string;
10
+ /** Mask sensitive query/fragment params and scrub secrets in the path. */
11
+ export declare function redactUrl(url: string): string;
12
+ /** Mask values whose adjacent name is sensitive (see note above). Best-effort. */
13
+ export declare function scrubNamedValues(s: string): string;
14
+ /** Redact a body string: structurally (JSON keys / form fields) then by value. */
15
+ export declare function redactBody(body: string): string;
16
+ /** Project an already-redacted body into `dotted.path → scalar`, keeping only
17
+ * analytics scalars (num/bool/short non-mask strings). Null when nothing safe remains. */
18
+ export declare function projectBody(body: string | null | undefined): string | null;
@@ -0,0 +1,341 @@
1
+ // Secret + PII redaction for captured events (port of redact.dart): structural
2
+ // key-masking + value-level scrubbing; best-effort, never throws (input unchanged).
3
+ export const redactMask = '[REDACTED]';
4
+ /** Header names (lowercase) whose values are always masked. */
5
+ export const sensitiveHeaders = new Set([
6
+ 'authorization',
7
+ 'proxy-authorization',
8
+ 'cookie',
9
+ 'set-cookie',
10
+ 'x-auth-token',
11
+ 'x-api-key',
12
+ 'x-access-token',
13
+ 'x-session-token',
14
+ 'x-secret',
15
+ 'www-authenticate',
16
+ 'proxy-authenticate',
17
+ 'x-csrf-token',
18
+ 'x-xsrf-token',
19
+ ]);
20
+ /** Whole-name matches only — short tokens here so `pin` doesn't hit `shipping`. */
21
+ const sensitiveExact = new Set([
22
+ 'password', 'passwd', 'pwd', 'pass', 'passphrase', 'secret', 'token',
23
+ 'authorization', 'pin', 'cvv', 'cvc', 'csc', 'cvv2', 'ssn', 'sin',
24
+ 'otp', 'totp', 'iban',
25
+ // De-masked (3-tier policy): 'auth', 'pan', 'sig' — see the Dart source note.
26
+ ]);
27
+ /** Longer fragments safe to match as substrings of a normalized name. */
28
+ const sensitiveFragments = [
29
+ 'token', 'secret', 'password', 'passphrase', 'apikey', 'apisecret',
30
+ 'accesstoken', 'refreshtoken', 'idtoken', 'oauthtoken', 'privatekey',
31
+ 'secretkey', 'sessiontoken', 'cardnumber', 'cardno', 'ccnumber',
32
+ 'creditcard', 'accountnumber', 'routingnumber', 'sortcode',
33
+ // Rare-substring tokens as fragments to catch compounds (card_cvv, user_ssn); pin/pass/sin stay whole-name-only.
34
+ 'cvv', 'cvc', 'cvv2', 'ssn', 'otp', 'totp', 'passphrase',
35
+ // Personal data (PII), redacted by default — 0-code means 0-code PII safety.
36
+ 'email', 'phone', 'firstname', 'lastname', 'fullname', 'username',
37
+ 'recipientname', 'customername', 'sendername', 'passport', 'nationality',
38
+ 'address', 'birthdate', 'dateofbirth', 'promocode', 'promotioncode',
39
+ 'messagetext', 'giftmessage',
40
+ // De-masked: 'sessionid' (correlation key, not a credential), 'signature'.
41
+ ];
42
+ /** Whether a header/query/field name denotes a secret. */
43
+ export function isSensitiveName(name) {
44
+ const n = name.toLowerCase().replace(/[_\-$@.\s]/g, '');
45
+ if (sensitiveExact.has(n))
46
+ return true;
47
+ for (const f of sensitiveFragments) {
48
+ if (n.includes(f))
49
+ return true;
50
+ }
51
+ return false;
52
+ }
53
+ /** Mask the values of sensitive headers; preserve every key. */
54
+ export function redactHeaders(headers, extra = new Set()) {
55
+ const out = {};
56
+ for (const [k, v] of Object.entries(headers)) {
57
+ const lk = k.toLowerCase();
58
+ out[k] = sensitiveHeaders.has(lk) || extra.has(lk) ? redactMask : v;
59
+ }
60
+ return out;
61
+ }
62
+ // --- value-level scrubbers (run over any text or URL) ---
63
+ // 13+ digits with optional single space/dash separators between digits.
64
+ const digitRun = /[0-9](?:[ -]?[0-9]){11,}/g;
65
+ const jwtRe = /eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]*/g;
66
+ const bearerRe = /[Bb]earer\s+[A-Za-z0-9\-._~+/]+=*/g;
67
+ const cookieLineRe = /^(set-cookie|cookie)\s*:\s*.*$/gim;
68
+ // Free-text PII by VALUE PATTERN (audit blocker #1). Phone needs separators so bare id/timestamp runs aren't hit.
69
+ const emailRe = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
70
+ const ssnRe = /\b\d{3}-\d{2}-\d{4}\b/g;
71
+ const phoneRe = /(?:\+\d{1,3}[ .-]?)?\(?\d{3}\)?[ .-]\d{3}[ .-]\d{4}\b/g;
72
+ // Well-known opaque SECRETS by unambiguous vendor prefix — catches a key under a
73
+ // benign field name (name-masking misses). Zero-FP by anchoring on the prefix.
74
+ const secretTokenRe = /\b(?:(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|gh[opusr]_[A-Za-z0-9]{36,}|xox[baprs]-[A-Za-z0-9-]{10,})\b/g;
75
+ // PEM private key block (catastrophic if shipped).
76
+ const pemRe = /-----BEGIN[A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z0-9 ]*PRIVATE KEY-----/g;
77
+ function luhn(digits) {
78
+ if (digits.length < 13)
79
+ return false;
80
+ let sum = 0;
81
+ let alt = false;
82
+ for (let i = digits.length - 1; i >= 0; i--) {
83
+ let n = digits.charCodeAt(i) - 48;
84
+ if (n < 0 || n > 9)
85
+ return false;
86
+ if (alt) {
87
+ n *= 2;
88
+ if (n > 9)
89
+ n -= 9;
90
+ }
91
+ sum += n;
92
+ alt = !alt;
93
+ }
94
+ return sum % 10 === 0;
95
+ }
96
+ /** Redact card numbers, JWTs, bearer tokens, cookie lines, and PII anywhere in a string. */
97
+ export function scrubValues(s) {
98
+ let out = s.replace(digitRun, (m) => {
99
+ const digits = m.replace(/[ -]/g, '');
100
+ if (digits.length > 40)
101
+ return '[REDACTED-PAN]'; // suspicious + bounds work
102
+ for (let len = 13; len <= 19 && len <= digits.length; len++) {
103
+ for (let i = 0; i + len <= digits.length; i++) {
104
+ if (luhn(digits.substring(i, i + len)))
105
+ return '[REDACTED-PAN]';
106
+ }
107
+ }
108
+ return m;
109
+ });
110
+ out = out.replace(jwtRe, '[REDACTED-JWT]');
111
+ out = out.replace(bearerRe, `Bearer ${redactMask}`);
112
+ out = out.replace(cookieLineRe, (_m, p1) => `${p1}: ${redactMask}`);
113
+ out = out.replace(emailRe, '[REDACTED-EMAIL]');
114
+ out = out.replace(ssnRe, '[REDACTED-SSN]');
115
+ out = out.replace(phoneRe, '[REDACTED-PHONE]');
116
+ out = out.replace(secretTokenRe, '[REDACTED-SECRET]');
117
+ out = out.replace(pemRe, '[REDACTED-KEY]');
118
+ return out;
119
+ }
120
+ /** Mask sensitive query/fragment params and scrub secrets in the path. */
121
+ export function redactUrl(url) {
122
+ try {
123
+ let result = url;
124
+ let u = null;
125
+ let relative = false;
126
+ try {
127
+ u = new URL(url);
128
+ }
129
+ catch {
130
+ // Relative URL: parse against a dummy base so query/fragment masking runs, then strip it — else params leak.
131
+ try {
132
+ u = new URL(url, 'http://drengr.invalid');
133
+ relative = true;
134
+ }
135
+ catch {
136
+ u = null;
137
+ }
138
+ }
139
+ if (u) {
140
+ if (u.search.length > 1) {
141
+ const params = new URLSearchParams(u.search);
142
+ const masked = new URLSearchParams();
143
+ for (const [k, v] of params) {
144
+ masked.append(k, isSensitiveName(k) ? redactMask : v);
145
+ }
146
+ u.search = masked.toString();
147
+ }
148
+ const frag = u.hash.startsWith('#') ? u.hash.slice(1) : u.hash;
149
+ if (frag.includes('=')) {
150
+ const newFrag = frag
151
+ .split('&')
152
+ .map((pair) => {
153
+ const i = pair.indexOf('=');
154
+ if (i > 0 && isSensitiveName(pair.substring(0, i))) {
155
+ return `${pair.substring(0, i)}=${redactMask}`;
156
+ }
157
+ return pair;
158
+ })
159
+ .join('&');
160
+ u.hash = `#${newFrag}`;
161
+ }
162
+ result = relative ? u.toString().slice('http://drengr.invalid'.length) : u.toString();
163
+ }
164
+ return scrubValues(result);
165
+ }
166
+ catch {
167
+ return url;
168
+ }
169
+ }
170
+ // Mask a value whenever its adjacent NAME is sensitive — for bodies key-masking
171
+ // can't reach (JSON truncated past the cap, XML/SOAP) and inline literals in a
172
+ // parsed JSON string (GraphQL `query`). Value stops at any backslash/quote so a
173
+ // JSON-wrapped literal matches the INNERMOST name:"value" pair. Bounded → no ReDoS.
174
+ // escNamed runs FIRST (anchored on the escaped quote) so a plain-quote wrapper
175
+ // can't shadow the first inner literal.
176
+ const escNamedRe = /([A-Za-z][A-Za-z0-9_.\-]{0,63})(\s*[:=]\s*)\\"[^"\\]{0,8192}\\"/g;
177
+ const dqNamedRe = /(["']?)([A-Za-z][A-Za-z0-9_.\-]{0,63})\1(\s*[:=]\s*\\?")[^"\\]{0,8192}(\\?")/g;
178
+ const sqNamedRe = /(["']?)([A-Za-z][A-Za-z0-9_.\-]{0,63})\1(\s*[:=]\s*\\?')[^'\\]{0,8192}(\\?')/g;
179
+ // XML element text: <name>value</name>
180
+ const xmlElemRe = /<([A-Za-z][A-Za-z0-9_.\-:]{0,63})>[^<]{0,8192}<\/\1\s*>/g;
181
+ // Bare numeric/bool under a quoted JSON key ("cvv":123) — value-scrubbing skips short digit runs, so it'd leak.
182
+ const jsonNumRe = /("[A-Za-z][A-Za-z0-9_.\-]{0,63}"\s*:\s*)(-?\d[\d.eE+\-]{0,40}|true|false)/g;
183
+ /** Mask values whose adjacent name is sensitive (see note above). Best-effort. */
184
+ export function scrubNamedValues(s) {
185
+ let out = s.replace(escNamedRe, (m, name, sep) => isSensitiveName(name) ? `${name}${sep}\\"${redactMask}\\"` : m);
186
+ out = out.replace(dqNamedRe, (m, q, name, sep, close) => isSensitiveName(name) ? `${q}${name}${q}${sep}${redactMask}${close}` : m);
187
+ out = out.replace(sqNamedRe, (m, q, name, sep, close) => isSensitiveName(name) ? `${q}${name}${q}${sep}${redactMask}${close}` : m);
188
+ out = out.replace(xmlElemRe, (m, name) => isSensitiveName(name) ? `<${name}>${redactMask}</${name}>` : m);
189
+ out = out.replace(jsonNumRe, (m, head, _val) => {
190
+ const name = head.slice(1, head.indexOf('"', 1));
191
+ return isSensitiveName(name) ? `${head}${redactMask}` : m;
192
+ });
193
+ return out;
194
+ }
195
+ /** Redact a body string: structurally (JSON keys / form fields) then by value. */
196
+ export function redactBody(body) {
197
+ try {
198
+ const decoded = tryJson(body);
199
+ let out;
200
+ if (decoded !== undefined)
201
+ out = scrubValues(JSON.stringify(redactJson(decoded)));
202
+ else if (looksFormEncoded(body))
203
+ out = scrubValues(redactFormEncoded(body));
204
+ else
205
+ out = scrubValues(body);
206
+ // Net values sensitive by NAME that survived structural + value passes. See scrubNamedValues.
207
+ return scrubNamedValues(out);
208
+ }
209
+ catch {
210
+ return body;
211
+ }
212
+ }
213
+ function tryJson(body) {
214
+ const t = body.trimStart();
215
+ if (t.length === 0 || (t[0] !== '{' && t[0] !== '['))
216
+ return undefined;
217
+ try {
218
+ return JSON.parse(body);
219
+ }
220
+ catch {
221
+ return undefined;
222
+ }
223
+ }
224
+ function redactJson(v) {
225
+ if (Array.isArray(v))
226
+ return v.map(redactJson);
227
+ if (v !== null && typeof v === 'object') {
228
+ const out = {};
229
+ for (const [k, val] of Object.entries(v)) {
230
+ out[k] = isSensitiveName(k) ? redactMask : redactJson(val);
231
+ }
232
+ return out;
233
+ }
234
+ return v;
235
+ }
236
+ function looksFormEncoded(body) {
237
+ if (!body.includes('=') || body.includes('\n') || body.includes(' '))
238
+ return false;
239
+ return /^[^=&]+=[^&]*(?:&[^=&]+=[^&]*)*$/.test(body);
240
+ }
241
+ function redactFormEncoded(body) {
242
+ return body
243
+ .split('&')
244
+ .map((pair) => {
245
+ const i = pair.indexOf('=');
246
+ if (i <= 0)
247
+ return pair;
248
+ const key = pair.substring(0, i);
249
+ let name = key;
250
+ try {
251
+ name = decodeURIComponent(key.replace(/\+/g, ' '));
252
+ }
253
+ catch {
254
+ /* keep raw */
255
+ }
256
+ if (isSensitiveName(name))
257
+ return `${key}=${redactMask}`;
258
+ // Scrub the DECODED value — else an encoded value slips the outer scrubValues and projectBody ships the secret.
259
+ let val = pair.substring(i + 1);
260
+ try {
261
+ val = decodeURIComponent(val.replace(/\+/g, ' '));
262
+ }
263
+ catch {
264
+ /* keep raw */
265
+ }
266
+ return `${key}=${scrubValues(val)}`;
267
+ })
268
+ .join('&');
269
+ }
270
+ // --- safe projection (the annotatable DTO shipped to the server) ---
271
+ const projMaxKeys = 512;
272
+ const projMaxDepth = 12;
273
+ const projMaxStrLen = 1024;
274
+ /** Project an already-redacted body into `dotted.path → scalar`, keeping only
275
+ * analytics scalars (num/bool/short non-mask strings). Null when nothing safe remains. */
276
+ export function projectBody(body) {
277
+ if (!body)
278
+ return null;
279
+ const decoded = tryJson(body) ?? tryForm(body);
280
+ if (decoded === undefined || decoded === null)
281
+ return null;
282
+ const out = {};
283
+ try {
284
+ flatten('', decoded, out, 0);
285
+ if (Object.keys(out).length === 0)
286
+ return null;
287
+ return JSON.stringify(out);
288
+ }
289
+ catch {
290
+ return null;
291
+ }
292
+ }
293
+ function tryForm(body) {
294
+ if (!looksFormEncoded(body))
295
+ return undefined;
296
+ const map = {};
297
+ for (const pair of body.split('&')) {
298
+ const i = pair.indexOf('=');
299
+ if (i <= 0)
300
+ continue;
301
+ let k = pair.substring(0, i);
302
+ let v = pair.substring(i + 1);
303
+ try {
304
+ k = decodeURIComponent(k.replace(/\+/g, ' '));
305
+ }
306
+ catch { /* keep raw */ }
307
+ try {
308
+ v = decodeURIComponent(v.replace(/\+/g, ' '));
309
+ }
310
+ catch { /* keep raw */ }
311
+ map[k] = v;
312
+ }
313
+ return Object.keys(map).length === 0 ? undefined : map;
314
+ }
315
+ function flatten(prefix, v, out, depth) {
316
+ if (Object.keys(out).length >= projMaxKeys || depth > projMaxDepth)
317
+ return;
318
+ if (Array.isArray(v)) {
319
+ for (let i = 0; i < v.length && Object.keys(out).length < projMaxKeys; i++) {
320
+ flatten(prefix === '' ? `${i}` : `${prefix}.${i}`, v[i], out, depth + 1);
321
+ }
322
+ }
323
+ else if (v !== null && typeof v === 'object') {
324
+ for (const [k, val] of Object.entries(v)) {
325
+ if (Object.keys(out).length < projMaxKeys) {
326
+ flatten(prefix === '' ? k : `${prefix}.${k}`, val, out, depth + 1);
327
+ }
328
+ }
329
+ }
330
+ else if (typeof v === 'string') {
331
+ if (v.length === 0 || v.length > projMaxStrLen)
332
+ return;
333
+ if (v.startsWith('[REDACTED'))
334
+ return; // redactor dropped it — no signal
335
+ out[prefix] = v;
336
+ }
337
+ else if (typeof v === 'number' || typeof v === 'boolean') {
338
+ out[prefix] = v;
339
+ }
340
+ // null / other types: skip
341
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Batches captured signals and ships them to the Drengr ingest endpoint,
3
+ * authenticated by a publishable key. Port of the proven Dart IngestSink,
4
+ * carrying both device-run lessons from birth:
5
+ * - delivery uses the PRE-PATCH fetch (structurally invisible to capture —
6
+ * the self-capture loop cannot exist);
7
+ * - the persistence scheduler uses the writer-loops-until-clean pattern
8
+ * (an overlap marks dirty and returns; never reschedules a microtask,
9
+ * which starved the event loop and froze the Flutter demo).
10
+ *
11
+ * Best-effort and non-blocking: never throws into the app, drops oldest on
12
+ * overflow, retries with exponential backoff + full jitter, persists the
13
+ * queue through a pluggable storage adapter (localStorage on web,
14
+ * AsyncStorage-compatible on React Native, in-memory fallback anywhere).
15
+ */
16
+ import { type NetworkEvent } from './capture.js';
17
+ /** Minimal async-tolerant KV storage. localStorage satisfies it directly;
18
+ * React Native's AsyncStorage satisfies it via its promise API. */
19
+ export interface StorageAdapter {
20
+ getItem(key: string): string | null | Promise<string | null>;
21
+ setItem(key: string, value: string): void | Promise<void>;
22
+ removeItem(key: string): void | Promise<void>;
23
+ }
24
+ export interface IngestSinkOptions {
25
+ /** Full ingest URL, e.g. https://<ref>.supabase.co/functions/v1/ingest */
26
+ url: string;
27
+ /** Publishable key (drengr_pk_…) sent as Authorization: Bearer. */
28
+ publishableKey: string;
29
+ /** Shared envelope context (app_package, os, install_id, session_id, …). */
30
+ context: Record<string, unknown>;
31
+ storage?: StorageAdapter;
32
+ maxBatch?: number;
33
+ maxQueue?: number;
34
+ flushIntervalMs?: number;
35
+ }
36
+ export declare function defaultStorage(): StorageAdapter;
37
+ export declare class IngestSink {
38
+ private readonly url;
39
+ private readonly key;
40
+ private readonly context;
41
+ private readonly storage;
42
+ private readonly maxBatch;
43
+ private readonly maxQueue;
44
+ private readonly flushIntervalMs;
45
+ private queue;
46
+ private timer;
47
+ private sending;
48
+ private retries;
49
+ private persistScheduled;
50
+ private persisting;
51
+ private persistDirty;
52
+ private externalId;
53
+ private experiments;
54
+ constructor(opts: IngestSinkOptions);
55
+ /** Map a captured exchange to an ingest event and enqueue it. */
56
+ addNetwork: (e: NetworkEvent) => void;
57
+ /** Sets the session's external_id (attached to every event hereafter) and emits
58
+ * one identify event. traits go through the same redact+project pipeline as
59
+ * bodies. Fail-open: invalid externalId is a no-op. */
60
+ identify: (externalId: string, traits?: Record<string, unknown>) => void;
61
+ /** Sets/clears a session-scoped experiment variant (attached to every event
62
+ * hereafter as `experiments`). variant null/empty clears the key. Fail-open. */
63
+ setExperiment: (key: string, variant: string | null | undefined) => void;
64
+ private toNet;
65
+ private enqueue;
66
+ flush(): Promise<void>;
67
+ private armBackoff;
68
+ private schedulePersist;
69
+ private persist;
70
+ private restore;
71
+ }