@reproapp/node-sdk 0.0.3 → 0.0.5
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/README.md +36 -2
- package/dist/index.d.ts +140 -15
- package/dist/index.js +4927 -927
- package/dist/ingest/client.d.ts +10 -0
- package/dist/ingest/client.js +158 -0
- package/dist/ingest/mapper.d.ts +2 -0
- package/dist/ingest/mapper.js +92 -0
- package/dist/ingest/types.d.ts +40 -0
- package/dist/ingest/types.js +2 -0
- package/dist/ingest/worker.js +19 -0
- package/dist/integrations/sendgrid.d.ts +2 -4
- package/dist/integrations/sendgrid.js +4 -14
- package/dist/privacy-fallback.d.ts +1 -0
- package/dist/privacy-fallback.js +27 -0
- package/dist/privacy-redaction.d.ts +3 -0
- package/dist/privacy-redaction.js +38 -0
- package/dist/privacy.d.ts +108 -0
- package/dist/privacy.js +2868 -0
- package/dist/trace-materializer-worker.d.ts +1 -0
- package/dist/trace-materializer-worker.js +33 -0
- package/docs/tracing.md +1 -0
- package/package.json +8 -2
- package/src/index.ts +5583 -954
- package/src/ingest/client.ts +194 -0
- package/src/ingest/mapper.ts +104 -0
- package/src/ingest/types.ts +42 -0
- package/src/integrations/sendgrid.ts +6 -19
- package/src/privacy-fallback.ts +25 -0
- package/src/privacy-redaction.ts +37 -0
- package/src/privacy.ts +3593 -0
- package/src/trace-materializer-worker.ts +39 -0
- package/test/circular-capture.test.js +111 -0
- package/test/disable-subtree.test.js +154 -0
- package/test/integration-unawaited.js +183 -0
- package/test/kafka-runtime-privacy-policy.test.js +285 -0
- package/test/privacy-runtime-policy.test.js +2043 -0
- package/test/promise-map.test.js +72 -0
- package/test/unawaited.test.js +163 -0
- package/test/wrap-plugin-arrow-args.test.js +80 -0
- package/tracer/cjs-hook.js +0 -1
- package/tracer/wrap-plugin.js +96 -10
- package/dist/redaction.d.ts +0 -44
- package/dist/redaction.js +0 -167
- package/dist/server.js +0 -26
- /package/dist/{server.d.ts → ingest/worker.d.ts} +0 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { mapLegacyEntriesToCanonicalEvents } from './mapper';
|
|
2
|
+
import type { IngestClientConfig, LegacyEntry } from './types';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_INGEST_BASE = 'http://localhost:8080';
|
|
5
|
+
const DEFAULT_INGEST_PATH = '/v1/ingest/events';
|
|
6
|
+
const DEFAULT_QUEUE_MAX_ITEMS = 1000;
|
|
7
|
+
const DEFAULT_QUEUE_MAX_ATTEMPTS = 5;
|
|
8
|
+
const DEFAULT_QUEUE_BACKOFF_MAX_MS = 4000;
|
|
9
|
+
const DEFAULT_DEFER_DRAIN_MS = 25;
|
|
10
|
+
|
|
11
|
+
type QueuedIngestBatch = {
|
|
12
|
+
config: IngestClientConfig;
|
|
13
|
+
sessionId: string;
|
|
14
|
+
entries: LegacyEntry[];
|
|
15
|
+
attempts: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const ingestQueue: QueuedIngestBatch[] = [];
|
|
19
|
+
let drainTimer: ReturnType<typeof setTimeout> | null = null;
|
|
20
|
+
let draining = false;
|
|
21
|
+
let backoffMs = 0;
|
|
22
|
+
let shouldDeferDrain: (() => boolean) | null = null;
|
|
23
|
+
let deferDrainMs = DEFAULT_DEFER_DRAIN_MS;
|
|
24
|
+
|
|
25
|
+
export const configureIngestQueueDrain = (options: {
|
|
26
|
+
shouldDefer?: (() => boolean) | null;
|
|
27
|
+
deferMs?: number;
|
|
28
|
+
}): void => {
|
|
29
|
+
shouldDeferDrain = options.shouldDefer ?? null;
|
|
30
|
+
if (Number.isFinite(options.deferMs) && Number(options.deferMs) >= 0) {
|
|
31
|
+
deferDrainMs = Number(options.deferMs);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const kickIngestQueueDrain = (): void => {
|
|
36
|
+
if (ingestQueue.length > 0 && !drainTimer) {
|
|
37
|
+
scheduleDrain(shouldDeferDrain?.() ? deferDrainMs : 0);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const resolveIngestUrl = (config: IngestClientConfig): string => {
|
|
42
|
+
const envBase = typeof process !== 'undefined' ? (process as any)?.env?.REPRO_INGEST_BASE : undefined;
|
|
43
|
+
const ingestBase = String(envBase || config.ingestBase || DEFAULT_INGEST_BASE).replace(/\/+$/, '');
|
|
44
|
+
const ingestPath = config.ingestPath || DEFAULT_INGEST_PATH;
|
|
45
|
+
const normalizedPath = ingestPath.startsWith('/') ? ingestPath : `/${ingestPath}`;
|
|
46
|
+
return `${ingestBase}${normalizedPath}`;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const toHeaders = (config: IngestClientConfig): Record<string, string> => {
|
|
50
|
+
const headers: Record<string, string> = {
|
|
51
|
+
'Content-Type': 'application/json',
|
|
52
|
+
'X-App-Id': config.appId,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (config.appSecret) {
|
|
56
|
+
headers['X-App-Secret'] = config.appSecret;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (config.appName) {
|
|
60
|
+
headers['X-App-Name'] = config.appName;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return headers;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const postIngestEntries = async (
|
|
67
|
+
config: IngestClientConfig,
|
|
68
|
+
sessionId: string,
|
|
69
|
+
entries: LegacyEntry[],
|
|
70
|
+
): Promise<void> => {
|
|
71
|
+
await sendIngestEntries(config, sessionId, entries);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const sendIngestEntries = async (
|
|
75
|
+
config: IngestClientConfig,
|
|
76
|
+
sessionId: string,
|
|
77
|
+
entries: LegacyEntry[],
|
|
78
|
+
): Promise<boolean> => {
|
|
79
|
+
if (!sessionId || !Array.isArray(entries) || entries.length === 0) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const events = mapLegacyEntriesToCanonicalEvents(config, sessionId, entries);
|
|
84
|
+
if (!events.length) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const response = await fetch(resolveIngestUrl(config), {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: toHeaders(config),
|
|
92
|
+
body: JSON.stringify({ events }),
|
|
93
|
+
});
|
|
94
|
+
return response.ok;
|
|
95
|
+
} catch {
|
|
96
|
+
// swallow in SDK
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const scheduleDrain = (delayMs = 0): void => {
|
|
102
|
+
if (drainTimer) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const nextDelay = shouldDeferDrain?.()
|
|
106
|
+
? Math.max(delayMs, deferDrainMs)
|
|
107
|
+
: delayMs;
|
|
108
|
+
drainTimer = setTimeout(() => {
|
|
109
|
+
drainTimer = null;
|
|
110
|
+
if (shouldDeferDrain?.()) {
|
|
111
|
+
scheduleDrain(deferDrainMs);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
void drainIngestQueue();
|
|
115
|
+
}, nextDelay);
|
|
116
|
+
try {
|
|
117
|
+
(drainTimer as any).unref?.();
|
|
118
|
+
} catch {}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const drainIngestQueue = async (): Promise<void> => {
|
|
122
|
+
if (draining) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (shouldDeferDrain?.()) {
|
|
126
|
+
scheduleDrain(deferDrainMs);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
draining = true;
|
|
130
|
+
try {
|
|
131
|
+
while (ingestQueue.length > 0) {
|
|
132
|
+
if (shouldDeferDrain?.()) {
|
|
133
|
+
scheduleDrain(deferDrainMs);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const batch = ingestQueue[0];
|
|
137
|
+
const ok = await sendIngestEntries(batch.config, batch.sessionId, batch.entries);
|
|
138
|
+
if (ok) {
|
|
139
|
+
ingestQueue.shift();
|
|
140
|
+
backoffMs = 0;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
batch.attempts += 1;
|
|
145
|
+
if (batch.attempts >= DEFAULT_QUEUE_MAX_ATTEMPTS) {
|
|
146
|
+
ingestQueue.shift();
|
|
147
|
+
backoffMs = 0;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
backoffMs = Math.min(
|
|
152
|
+
DEFAULT_QUEUE_BACKOFF_MAX_MS,
|
|
153
|
+
backoffMs ? backoffMs * 2 : 250,
|
|
154
|
+
);
|
|
155
|
+
scheduleDrain(backoffMs);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
draining = false;
|
|
160
|
+
if (ingestQueue.length > 0 && !drainTimer) {
|
|
161
|
+
scheduleDrain(backoffMs || deferDrainMs);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export const enqueueIngestEntries = (
|
|
167
|
+
config: IngestClientConfig,
|
|
168
|
+
sessionId: string,
|
|
169
|
+
entries: LegacyEntry[],
|
|
170
|
+
): void => {
|
|
171
|
+
if (!sessionId || !Array.isArray(entries) || entries.length === 0) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
ingestQueue.push({
|
|
175
|
+
config: { ...config },
|
|
176
|
+
sessionId,
|
|
177
|
+
entries: entries.slice(),
|
|
178
|
+
attempts: 0,
|
|
179
|
+
});
|
|
180
|
+
if (ingestQueue.length > DEFAULT_QUEUE_MAX_ITEMS) {
|
|
181
|
+
ingestQueue.splice(0, ingestQueue.length - DEFAULT_QUEUE_MAX_ITEMS);
|
|
182
|
+
}
|
|
183
|
+
scheduleDrain(shouldDeferDrain?.() ? deferDrainMs : 0);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export const flushIngestQueue = async (): Promise<void> => {
|
|
187
|
+
if (drainTimer) {
|
|
188
|
+
clearTimeout(drainTimer);
|
|
189
|
+
drainTimer = null;
|
|
190
|
+
}
|
|
191
|
+
await drainIngestQueue();
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export type { IngestClientConfig, LegacyEntry } from './types';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { CanonicalEvent, IngestClientConfig, LegacyEntry } from './types';
|
|
2
|
+
|
|
3
|
+
const toIso = (timestamp: unknown): string => {
|
|
4
|
+
const numeric =
|
|
5
|
+
typeof timestamp === 'number' && Number.isFinite(timestamp) ? timestamp : Date.now();
|
|
6
|
+
return new Date(numeric).toISOString();
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const inferEventType = (entry: LegacyEntry): string => {
|
|
10
|
+
if (entry.request !== undefined) return 'backend_request';
|
|
11
|
+
if (entry.db !== undefined) return 'db_change';
|
|
12
|
+
if (entry.trace !== undefined) return 'trace_batch';
|
|
13
|
+
if (entry.email !== undefined) return 'email_event';
|
|
14
|
+
return 'backend_event';
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const toActorLabels = (config: IngestClientConfig): Record<string, string> | undefined => {
|
|
18
|
+
if (!config.actorLabels) return undefined;
|
|
19
|
+
const labels = Object.entries(config.actorLabels).reduce<Record<string, string>>(
|
|
20
|
+
(acc, [key, value]) => {
|
|
21
|
+
if (!key) return acc;
|
|
22
|
+
if (typeof value !== 'string') return acc;
|
|
23
|
+
acc[key] = value;
|
|
24
|
+
return acc;
|
|
25
|
+
},
|
|
26
|
+
{},
|
|
27
|
+
);
|
|
28
|
+
return Object.keys(labels).length ? labels : undefined;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const resolveServiceName = (config: IngestClientConfig): string | undefined => {
|
|
32
|
+
const candidate = config.serviceName ?? config.appName;
|
|
33
|
+
if (typeof candidate !== 'string') {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
const normalized = candidate.trim();
|
|
37
|
+
return normalized.length ? normalized : undefined;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const toPayload = (entry: LegacyEntry): Record<string, unknown> => {
|
|
41
|
+
const payload: Record<string, unknown> = {
|
|
42
|
+
action_id: entry.actionId ?? null,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const normalizeFieldValue = (key: string, value: unknown): unknown => {
|
|
46
|
+
if (key !== 'trace' || typeof value !== 'string') {
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(value) as unknown;
|
|
52
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
59
|
+
if (key === 't' || key === 'actionId') continue;
|
|
60
|
+
payload[key] = normalizeFieldValue(key, value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return payload;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const mapLegacyEntriesToCanonicalEvents = (
|
|
67
|
+
config: IngestClientConfig,
|
|
68
|
+
sessionId: string,
|
|
69
|
+
entries: LegacyEntry[],
|
|
70
|
+
): CanonicalEvent[] => {
|
|
71
|
+
const actorLabels = toActorLabels(config);
|
|
72
|
+
const actorId = config.actorId ?? config.appId;
|
|
73
|
+
const actorType = config.actorType ?? 'service';
|
|
74
|
+
const schemaVersion = config.schemaVersion ?? 1;
|
|
75
|
+
const serviceName = resolveServiceName(config);
|
|
76
|
+
|
|
77
|
+
return entries.map((entry) => {
|
|
78
|
+
const payload = toPayload(entry);
|
|
79
|
+
if (
|
|
80
|
+
serviceName &&
|
|
81
|
+
(typeof payload.serviceName !== 'string' || !String(payload.serviceName).trim().length)
|
|
82
|
+
) {
|
|
83
|
+
payload.serviceName = serviceName;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const canonical: CanonicalEvent = {
|
|
87
|
+
schema_version: schemaVersion,
|
|
88
|
+
tenant_id: config.tenantId,
|
|
89
|
+
app_id: config.appId,
|
|
90
|
+
session_id: sessionId,
|
|
91
|
+
event_type: inferEventType(entry),
|
|
92
|
+
event_ts: toIso(entry.t),
|
|
93
|
+
actor_id: actorId,
|
|
94
|
+
actor_type: actorType,
|
|
95
|
+
payload,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (actorLabels) {
|
|
99
|
+
canonical.actor_labels = actorLabels;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return canonical;
|
|
103
|
+
});
|
|
104
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface IngestClientConfig {
|
|
2
|
+
appId: string;
|
|
3
|
+
tenantId: string;
|
|
4
|
+
appSecret?: string;
|
|
5
|
+
appName?: string;
|
|
6
|
+
serviceName?: string;
|
|
7
|
+
ingestBase?: string;
|
|
8
|
+
ingestPath?: string;
|
|
9
|
+
actorId?: string;
|
|
10
|
+
actorType?: string;
|
|
11
|
+
actorLabels?: Record<string, string>;
|
|
12
|
+
source?: string;
|
|
13
|
+
schemaVersion?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface LegacyEntry {
|
|
17
|
+
actionId?: string | null;
|
|
18
|
+
requestRid?: string | null;
|
|
19
|
+
request?: unknown;
|
|
20
|
+
requestValues?: unknown[];
|
|
21
|
+
db?: unknown;
|
|
22
|
+
dbValues?: unknown[];
|
|
23
|
+
trace?: unknown[];
|
|
24
|
+
traceBatch?: unknown;
|
|
25
|
+
traceValues?: unknown[];
|
|
26
|
+
email?: unknown;
|
|
27
|
+
t?: number;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CanonicalEvent {
|
|
32
|
+
schema_version: number;
|
|
33
|
+
tenant_id: string;
|
|
34
|
+
app_id: string;
|
|
35
|
+
session_id: string;
|
|
36
|
+
event_type: string;
|
|
37
|
+
event_ts: string;
|
|
38
|
+
actor_id: string;
|
|
39
|
+
actor_type: string;
|
|
40
|
+
actor_labels?: Record<string, string>;
|
|
41
|
+
payload: Record<string, unknown>;
|
|
42
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// repro-node/src/integrations/sendgrid.ts
|
|
2
2
|
import { AsyncLocalStorage } from 'async_hooks';
|
|
3
|
+
import { enqueueIngestEntries, type IngestClientConfig, type LegacyEntry } from '../ingest/client';
|
|
3
4
|
|
|
4
5
|
type Ctx = { sid?: string; aid?: string };
|
|
5
6
|
const als = new AsyncLocalStorage<Ctx>();
|
|
@@ -7,32 +8,18 @@ export const getCtx = () => als.getStore() || {};
|
|
|
7
8
|
|
|
8
9
|
// If you already export als/getCtx from repro-node, reuse that instead of re-declaring.
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
cfg:
|
|
11
|
+
function post(
|
|
12
|
+
cfg: IngestClientConfig,
|
|
12
13
|
sessionId: string,
|
|
13
14
|
body: any,
|
|
14
15
|
) {
|
|
15
16
|
try {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
const apiBase = String(envBase || legacyBase || 'https://repro-api-d7288.ondigitalocean.app/api').replace(/\/+$/, '');
|
|
19
|
-
await fetch(`${apiBase}/v1/sessions/${sessionId}/backend`, {
|
|
20
|
-
method: 'POST',
|
|
21
|
-
headers: {
|
|
22
|
-
'Content-Type': 'application/json',
|
|
23
|
-
'X-App-Id': cfg.appId,
|
|
24
|
-
'X-App-Secret': cfg.appSecret,
|
|
25
|
-
...(cfg.appName ? { 'X-App-Name': cfg.appName } : {}),
|
|
26
|
-
},
|
|
27
|
-
body: JSON.stringify(body),
|
|
28
|
-
});
|
|
17
|
+
const entries = Array.isArray(body?.entries) ? (body.entries as LegacyEntry[]) : [];
|
|
18
|
+
enqueueIngestEntries(cfg, sessionId, entries);
|
|
29
19
|
} catch { /* swallow */ }
|
|
30
20
|
}
|
|
31
21
|
|
|
32
|
-
export type SendgridPatchConfig = {
|
|
33
|
-
appId: string;
|
|
34
|
-
appSecret: string;
|
|
35
|
-
appName?: string;
|
|
22
|
+
export type SendgridPatchConfig = IngestClientConfig & {
|
|
36
23
|
// Optional: provide a function to resolve sid/aid if AsyncLocalStorage is not set
|
|
37
24
|
resolveContext?: () => { sid?: string; aid?: string } | undefined;
|
|
38
25
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const SANITIZED_TEXT_CACHE = new Map<string, string>();
|
|
2
|
+
const SANITIZED_TEXT_CACHE_MAX = 500;
|
|
3
|
+
|
|
4
|
+
export async function sanitizeUnknownBoundaryTextWithDetectors(value: string): Promise<string> {
|
|
5
|
+
if (!value) {
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
const cached = SANITIZED_TEXT_CACHE.get(value);
|
|
9
|
+
if (cached !== undefined) {
|
|
10
|
+
return cached;
|
|
11
|
+
}
|
|
12
|
+
rememberSanitizedText(value, value);
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function rememberSanitizedText(input: string, output: string): void {
|
|
17
|
+
SANITIZED_TEXT_CACHE.set(input, output);
|
|
18
|
+
if (SANITIZED_TEXT_CACHE.size <= SANITIZED_TEXT_CACHE_MAX) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const oldestKey = SANITIZED_TEXT_CACHE.keys().next().value;
|
|
22
|
+
if (typeof oldestKey === 'string') {
|
|
23
|
+
SANITIZED_TEXT_CACHE.delete(oldestKey);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
|
|
3
|
+
const EMAIL_PATTERN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
|
|
4
|
+
const EMAIL_TOKEN_PREFIX = 'email_tok_';
|
|
5
|
+
const EMAIL_TOKEN_LENGTH = 12;
|
|
6
|
+
|
|
7
|
+
export function redactEmailDeterministic(value: string): string {
|
|
8
|
+
const normalized = String(value ?? '').trim().toLowerCase();
|
|
9
|
+
if (!normalized) {
|
|
10
|
+
return `${EMAIL_TOKEN_PREFIX}unknown`;
|
|
11
|
+
}
|
|
12
|
+
const token = createHash('sha256').update(normalized).digest('hex').slice(0, EMAIL_TOKEN_LENGTH);
|
|
13
|
+
return `${EMAIL_TOKEN_PREFIX}${token}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function redactEmailsInText(value: string): string {
|
|
17
|
+
if (!value) {
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
return value.replace(EMAIL_PATTERN, (match) => isAlreadyMaskedEmail(match) ? match : redactEmailDeterministic(match));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function collectEmailsInText(value: string): string[] {
|
|
24
|
+
if (!value) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
const matches = value.match(EMAIL_PATTERN);
|
|
28
|
+
return Array.isArray(matches) ? matches : [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isAlreadyMaskedEmail(value: string): boolean {
|
|
32
|
+
if (/^email_tok_[a-z0-9]+$/i.test(value)) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
const [local = '', domain = ''] = String(value).split('@');
|
|
36
|
+
return local.includes('...') || domain.includes('...');
|
|
37
|
+
}
|