@deeeed/metamask-harness 0.24.0 → 0.25.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,505 @@
1
+ // Segment collector shared by every platform's analytics actions.
2
+ //
3
+ // Both clients already ship the mechanism to divert MetaMetrics to an arbitrary
4
+ // host — the extension via SEGMENT_HOST, mobile via SEGMENT_PROXY_URL — and
5
+ // neither validates the write key. Pointing either at this collector yields the
6
+ // exact payloads Segment would have received.
7
+ //
8
+ // This lives in `shared/` because the payload shape is Segment's, not a
9
+ // client's: `<root>/shared/<family>/<stem>.mjs` is the fallback the adapter
10
+ // contract tries for every platform, so one implementation serves all.
11
+ //
12
+ // The server has to outlive the action process that starts it (each action is a
13
+ // separate short-lived process), so `startCollector` spawns this same file
14
+ // detached in `--serve` mode and records the pid. Events land in a JSONL file
15
+ // that later actions read by cursor.
16
+
17
+ import { randomUUID } from 'node:crypto';
18
+ import { createServer } from 'node:http';
19
+ import { execFile, spawn } from 'node:child_process';
20
+ import { constants } from 'node:fs';
21
+ import {
22
+ chmod,
23
+ lstat,
24
+ mkdir,
25
+ open,
26
+ rename,
27
+ unlink,
28
+ } from 'node:fs/promises';
29
+ import path from 'node:path';
30
+ import { promisify } from 'node:util';
31
+
32
+ const execFileAsync = promisify(execFile);
33
+
34
+ export function collectorPaths(projectRoot) {
35
+ const configuredRuntime = process.env.RECIPE_RUNTIME_DIR;
36
+ const runtimeRoot = configuredRuntime
37
+ ? path.resolve(projectRoot, configuredRuntime)
38
+ : path.join(projectRoot, 'temp/recipe/runtime');
39
+ const dir = path.join(runtimeRoot, 'analytics');
40
+ return {
41
+ dir,
42
+ eventsFile: path.join(dir, 'events.jsonl'),
43
+ stateFile: path.join(dir, 'collector.json'),
44
+ };
45
+ }
46
+
47
+ async function prepareCollectorStorage(projectRoot) {
48
+ const paths = collectorPaths(projectRoot);
49
+ const configuredRuntime = process.env.RECIPE_RUNTIME_DIR;
50
+ const boundary = configuredRuntime && path.isAbsolute(configuredRuntime)
51
+ ? path.resolve(configuredRuntime)
52
+ : path.resolve(projectRoot);
53
+ await ensureRealDirectoryChain(
54
+ boundary,
55
+ paths.dir,
56
+ Boolean(configuredRuntime && path.isAbsolute(configuredRuntime)),
57
+ );
58
+ await chmod(paths.dir, 0o700);
59
+ await ensurePrivateAppendFile(paths.eventsFile);
60
+ await secureExistingFile(paths.stateFile);
61
+ return paths;
62
+ }
63
+
64
+ async function ensureRealDirectoryChain(boundary, target, includeBoundary) {
65
+ const relative = path.relative(boundary, target);
66
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
67
+ throw new Error(`Analytics runtime path escapes its selected root: ${target}`);
68
+ }
69
+ if (includeBoundary) {
70
+ await mkdir(boundary, { recursive: true, mode: 0o700 });
71
+ }
72
+ let current = boundary;
73
+ const paths = includeBoundary ? [current] : [];
74
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
75
+ current = path.join(current, segment);
76
+ paths.push(current);
77
+ }
78
+ for (const directory of paths) {
79
+ let info;
80
+ try {
81
+ info = await lstat(directory);
82
+ } catch (error) {
83
+ if (error?.code !== 'ENOENT') throw error;
84
+ await mkdir(directory, { mode: 0o700 });
85
+ info = await lstat(directory);
86
+ }
87
+ if (info.isSymbolicLink() || !info.isDirectory()) {
88
+ throw new Error(`Analytics runtime directory must be a real directory, not a symlink: ${directory}`);
89
+ }
90
+ }
91
+ }
92
+
93
+ async function secureExistingFile(filePath) {
94
+ try {
95
+ const info = await lstat(filePath);
96
+ if (info.isSymbolicLink() || !info.isFile()) {
97
+ throw new Error(`Analytics runtime file must be a real file, not a symlink: ${filePath}`);
98
+ }
99
+ await chmod(filePath, 0o600);
100
+ return true;
101
+ } catch (error) {
102
+ if (error?.code === 'ENOENT') return false;
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ async function ensurePrivateAppendFile(filePath) {
108
+ await secureExistingFile(filePath);
109
+ const handle = await open(
110
+ filePath,
111
+ constants.O_WRONLY
112
+ | constants.O_APPEND
113
+ | constants.O_CREAT
114
+ | (constants.O_NOFOLLOW ?? 0),
115
+ 0o600,
116
+ );
117
+ try {
118
+ const info = await handle.stat();
119
+ if (!info.isFile()) throw new Error(`Analytics events path is not a regular file: ${filePath}`);
120
+ await handle.chmod(0o600);
121
+ } finally {
122
+ await handle.close();
123
+ }
124
+ }
125
+
126
+ async function appendPrivateFile(filePath, value) {
127
+ const handle = await open(
128
+ filePath,
129
+ constants.O_WRONLY | constants.O_APPEND | (constants.O_NOFOLLOW ?? 0),
130
+ );
131
+ try {
132
+ const info = await handle.stat();
133
+ if (!info.isFile()) throw new Error(`Analytics events path is not a regular file: ${filePath}`);
134
+ await handle.writeFile(value);
135
+ } finally {
136
+ await handle.close();
137
+ }
138
+ }
139
+
140
+ async function readPrivateFile(filePath) {
141
+ const handle = await open(
142
+ filePath,
143
+ constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
144
+ );
145
+ try {
146
+ const info = await handle.stat();
147
+ if (!info.isFile()) throw new Error(`Analytics runtime path is not a regular file: ${filePath}`);
148
+ return await handle.readFile('utf8');
149
+ } finally {
150
+ await handle.close();
151
+ }
152
+ }
153
+
154
+ async function writePrivateState(stateFile, state) {
155
+ await secureExistingFile(stateFile);
156
+ const temporary = path.join(path.dirname(stateFile), `.collector-${randomUUID()}.json`);
157
+ let handle;
158
+ try {
159
+ handle = await open(
160
+ temporary,
161
+ constants.O_WRONLY
162
+ | constants.O_CREAT
163
+ | constants.O_EXCL
164
+ | (constants.O_NOFOLLOW ?? 0),
165
+ 0o600,
166
+ );
167
+ await handle.writeFile(`${JSON.stringify(state)}\n`);
168
+ await handle.chmod(0o600);
169
+ await handle.close();
170
+ handle = undefined;
171
+ await rename(temporary, stateFile);
172
+ await chmod(stateFile, 0o600);
173
+ } catch (error) {
174
+ await handle?.close().catch(() => {});
175
+ await unlink(temporary).catch(() => {});
176
+ throw error;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Default port derives from the slot's CDP port so co-located slots do not
182
+ * collide. Mirrors farmslot's `SEGMENT_PORT="$(( CDP_PORT + 1000 ))"`.
183
+ */
184
+ export function collectorPort(input) {
185
+ const explicit = input?.node?.port ?? process.env.SEGMENT_MOCK_PORT;
186
+ if (explicit !== undefined) return validPort(explicit, 'collector port');
187
+ const cdpRaw = input?.node?.cdp_port ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
188
+ if (cdpRaw !== undefined) {
189
+ const cdp = validPort(cdpRaw, 'CDP port');
190
+ return validPort(cdp + 1000, 'collector port derived from CDP port');
191
+ }
192
+ return 9090;
193
+ }
194
+
195
+ function validPort(value, name) {
196
+ if (value === '') throw new Error(`Analytics ${name} must not be blank.`);
197
+ const port = Number(value);
198
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
199
+ throw new Error(`Analytics ${name} must be an integer from 1 to 65535, got ${JSON.stringify(value)}.`);
200
+ }
201
+ return port;
202
+ }
203
+
204
+ function projectRootOf(input) {
205
+ const root = input?.context?.projectRoot;
206
+ if (!root) throw new Error('Analytics actions require context.projectRoot.');
207
+ return root;
208
+ }
209
+
210
+ async function readState(stateFile) {
211
+ try {
212
+ return JSON.parse(await readPrivateFile(stateFile));
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+
218
+ function pidAlive(pid) {
219
+ if (!Number.isInteger(pid) || pid <= 0) return false;
220
+ try {
221
+ // Signal 0 tests for existence without delivering anything.
222
+ process.kill(pid, 0);
223
+ return true;
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+
229
+ async function collectorHealth(port) {
230
+ try {
231
+ const response = await fetch(`http://127.0.0.1:${port}/__collector`, {
232
+ signal: AbortSignal.timeout(1000),
233
+ });
234
+ if (!response.ok) return null;
235
+ const body = await response.json();
236
+ return body?.collector === 'metamask-harness' && body?.port === port
237
+ ? body
238
+ : null;
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ async function stopCollector(state) {
245
+ if (
246
+ !state?.instanceId ||
247
+ !pidAlive(state.pid) ||
248
+ (await collectorHealth(state.port))?.instanceId !== state.instanceId
249
+ ) {
250
+ return false;
251
+ }
252
+ try {
253
+ process.kill(state.pid, 'SIGTERM');
254
+ } catch {
255
+ return true;
256
+ }
257
+ const deadline = Date.now() + 2000;
258
+ while (Date.now() < deadline && pidAlive(state.pid)) {
259
+ await new Promise((resolve) => setTimeout(resolve, 50));
260
+ }
261
+ if (pidAlive(state.pid)) {
262
+ try {
263
+ process.kill(state.pid, 'SIGKILL');
264
+ } catch {
265
+ // The process exited after the final liveness check.
266
+ }
267
+ }
268
+ return true;
269
+ }
270
+
271
+ async function stopSpawnedCollector(pid) {
272
+ if (!pidAlive(pid)) return;
273
+ try {
274
+ process.kill(pid, 'SIGTERM');
275
+ } catch {
276
+ return;
277
+ }
278
+ const deadline = Date.now() + 2000;
279
+ while (Date.now() < deadline && pidAlive(pid)) {
280
+ await new Promise((resolve) => setTimeout(resolve, 50));
281
+ }
282
+ if (pidAlive(pid)) {
283
+ try {
284
+ process.kill(pid, 'SIGKILL');
285
+ } catch {
286
+ // The process exited after the final liveness check.
287
+ }
288
+ }
289
+ }
290
+
291
+ async function prepareAndroidAccess(input, port) {
292
+ if (input?.platform !== 'mobile') return null;
293
+ const androidDevice =
294
+ input?.node?.android_device ??
295
+ process.env.ANDROID_DEVICE;
296
+ const adbSerial =
297
+ input?.node?.adb_serial ??
298
+ process.env.ADB_SERIAL ??
299
+ process.env.ANDROID_SERIAL ??
300
+ androidDevice;
301
+ const iosSimulator =
302
+ input?.node?.simulator ??
303
+ input?.node?.ios_simulator ??
304
+ process.env.IOS_SIMULATOR;
305
+ const explicitlyAndroid =
306
+ input?.node?.platform === 'android' ||
307
+ (adbSerial && String(androidDevice ?? '') === String(adbSerial));
308
+ if (!adbSerial || (iosSimulator && !explicitlyAndroid)) return null;
309
+ try {
310
+ await execFileAsync(
311
+ 'adb',
312
+ ['-s', String(adbSerial), 'reverse', `tcp:${port}`, `tcp:${port}`],
313
+ { timeout: 5000 },
314
+ );
315
+ } catch (error) {
316
+ throw new Error(
317
+ `Analytics collector could not reverse port ${port} to Android device ${adbSerial}: ${error?.message ?? String(error)}`,
318
+ );
319
+ }
320
+ return String(adbSerial);
321
+ }
322
+
323
+ /**
324
+ * Idempotent: an already-running collector on the same port is reused, so a
325
+ * recipe may call start_capture in several nodes without stacking servers.
326
+ */
327
+ export async function startCollector(input) {
328
+ const projectRoot = projectRootOf(input);
329
+ const port = collectorPort(input);
330
+ const { eventsFile, stateFile } = await prepareCollectorStorage(projectRoot);
331
+
332
+ const existing = await readState(stateFile);
333
+ const existingHealth = existing?.port ? await collectorHealth(existing.port) : null;
334
+ if (
335
+ existing?.port === port &&
336
+ existing?.instanceId &&
337
+ pidAlive(existing.pid) &&
338
+ existingHealth?.instanceId === existing.instanceId
339
+ ) {
340
+ const androidDevice = await prepareAndroidAccess(input, port);
341
+ return { port, pid: existing.pid, eventsFile, reused: true, androidDevice };
342
+ }
343
+ if (existing?.port && existing.port !== port) {
344
+ const stopped = await stopCollector(existing);
345
+ if (!stopped && pidAlive(existing.pid)) {
346
+ throw new Error(
347
+ `Refusing to replace unverified analytics collector process ${existing.pid} on port ${existing.port}.`,
348
+ );
349
+ }
350
+ }
351
+
352
+ const occupied = await collectorHealth(port);
353
+ if (occupied) {
354
+ throw new Error(
355
+ `Analytics collector port ${port} is owned by another instance. Choose another port or stop that collector.`,
356
+ );
357
+ }
358
+
359
+ const instanceId = randomUUID();
360
+ const child = spawn(
361
+ process.execPath,
362
+ [
363
+ '--input-type=module',
364
+ '--eval',
365
+ collectorServerSource(),
366
+ String(port),
367
+ eventsFile,
368
+ instanceId,
369
+ ],
370
+ {
371
+ detached: true,
372
+ stdio: 'ignore',
373
+ },
374
+ );
375
+ child.unref();
376
+
377
+ // The server binds asynchronously; fail loudly rather than let a later
378
+ // read_events return an empty list that looks like "no events emitted".
379
+ const deadline = Date.now() + 5000;
380
+ while (Date.now() < deadline) {
381
+ if ((await collectorHealth(port))?.instanceId === instanceId) {
382
+ try {
383
+ await writePrivateState(stateFile, { pid: child.pid, port, eventsFile, instanceId });
384
+ const androidDevice = await prepareAndroidAccess(input, port);
385
+ return {
386
+ port,
387
+ pid: child.pid,
388
+ eventsFile,
389
+ reused: false,
390
+ androidDevice,
391
+ };
392
+ } catch (error) {
393
+ await stopSpawnedCollector(child.pid);
394
+ throw error;
395
+ }
396
+ }
397
+ await new Promise((resolve) => setTimeout(resolve, 100));
398
+ }
399
+ await stopSpawnedCollector(child.pid);
400
+ throw new Error(`Segment collector failed to bind port ${port} within 5000ms.`);
401
+ }
402
+
403
+ export async function readCollected(input, { since = 0, event = null } = {}) {
404
+ const { eventsFile } = await prepareCollectorStorage(projectRootOf(input));
405
+ let raw = '';
406
+ try {
407
+ raw = await readPrivateFile(eventsFile);
408
+ } catch (error) {
409
+ if (error?.code === 'ENOENT') return [];
410
+ throw error;
411
+ }
412
+ const events = raw
413
+ .split('\n')
414
+ .filter(Boolean)
415
+ .map((line, index) => {
416
+ try {
417
+ return JSON.parse(line);
418
+ } catch (error) {
419
+ throw new Error(
420
+ `Analytics capture is corrupt at JSONL record ${index + 1}: ${error?.message ?? String(error)}`,
421
+ );
422
+ }
423
+ })
424
+ .filter((entry) => entry.ts >= since);
425
+ return event ? events.filter((entry) => entry.event === event) : events;
426
+ }
427
+
428
+ // --- server mode (spawned detached by startCollector) ---
429
+
430
+ function eventsFromBody(body) {
431
+ // The Segment node SDK posts `{ batch: [...] }` to /v1/batch; the HTTP API
432
+ // also accepts a single event object on /v1/track.
433
+ const items = Array.isArray(body?.batch) ? body.batch : [body];
434
+ return items
435
+ .filter((item) => item && typeof item === 'object')
436
+ .map((item) => ({
437
+ type: item.type ?? null,
438
+ event: item.event ?? null,
439
+ properties: item.properties ?? {},
440
+ userId: item.userId ?? null,
441
+ anonymousId: item.anonymousId ?? null,
442
+ sentAt: item.timestamp ?? item.sentAt ?? null,
443
+ ts: Date.now(),
444
+ }));
445
+ }
446
+
447
+ function serve(port, eventsFile, instanceId) {
448
+ const server = createServer((request, response) => {
449
+ // The extension's Segment client runs in a page context, so preflight has
450
+ // to pass or nothing is ever delivered.
451
+ const cors = {
452
+ 'Access-Control-Allow-Origin': '*',
453
+ 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
454
+ 'Access-Control-Allow-Headers': '*',
455
+ };
456
+ if (request.method === 'OPTIONS') {
457
+ response.writeHead(204, cors);
458
+ response.end();
459
+ return;
460
+ }
461
+ if (request.method === 'GET') {
462
+ response.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
463
+ response.end(JSON.stringify({
464
+ collector: 'metamask-harness',
465
+ port,
466
+ instanceId,
467
+ }));
468
+ return;
469
+ }
470
+
471
+ const chunks = [];
472
+ request.on('data', (chunk) => chunks.push(chunk));
473
+ request.on('end', async () => {
474
+ try {
475
+ const events = eventsFromBody(JSON.parse(Buffer.concat(chunks).toString('utf8')));
476
+ if (events.length) {
477
+ await appendPrivateFile(
478
+ eventsFile,
479
+ `${events.map((event) => JSON.stringify(event)).join('\n')}\n`,
480
+ );
481
+ }
482
+ // Segment clients retry on non-2xx, so acknowledge only after the
483
+ // payload is appended.
484
+ response.writeHead(200, { ...cors, 'Content-Type': 'application/json' });
485
+ response.end('{}');
486
+ } catch {
487
+ response.writeHead(500, { ...cors, 'Content-Type': 'application/json' });
488
+ response.end('{"error":"collector_write_failed"}');
489
+ }
490
+ });
491
+ });
492
+ server.listen(port, '127.0.0.1');
493
+ }
494
+
495
+ function collectorServerSource() {
496
+ return [
497
+ `import { createServer } from 'node:http';`,
498
+ `import { constants } from 'node:fs';`,
499
+ `import { open } from 'node:fs/promises';`,
500
+ `const appendPrivateFile = ${appendPrivateFile.toString()};`,
501
+ `const eventsFromBody = ${eventsFromBody.toString()};`,
502
+ `const serve = ${serve.toString()};`,
503
+ `serve(Number(process.argv[1]), process.argv[2], process.argv[3]);`,
504
+ ].join('\n');
505
+ }
@@ -0,0 +1,14 @@
1
+ export function consentParams(node) {
2
+ const optional = (value, fallback) =>
3
+ value === undefined || value === null || value === '' ? fallback : value;
4
+ const participate = Boolean(optional(node?.participate, true));
5
+ const marketing = Boolean(optional(node?.marketing, participate));
6
+ const timeoutMs = Number(optional(node?.timeout_ms, 15000));
7
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
8
+ throw new Error('metamask.analytics.set_consent timeout_ms must be a positive number.');
9
+ }
10
+ if (marketing && !participate) {
11
+ throw new Error('metamask.analytics.set_consent cannot enable marketing collection while MetaMetrics participation is disabled.');
12
+ }
13
+ return { participate, marketing, timeoutMs };
14
+ }
@@ -0,0 +1,22 @@
1
+ import { runAdapter } from './_adapter.mjs';
2
+ import { readCollected } from './collector.mjs';
3
+
4
+ runAdapter(async (input) => {
5
+ const since = input.node?.since === undefined ? 0 : Number(input.node.since);
6
+ if (input.node?.since === '' || !Number.isFinite(since) || since < 0) {
7
+ throw new Error(`metamask.analytics.read_events since must be a non-negative number, got ${JSON.stringify(input.node?.since)}.`);
8
+ }
9
+ const event = input.node?.event ?? null;
10
+ if (event !== null && (typeof event !== 'string' || event.length === 0)) {
11
+ throw new Error('metamask.analytics.read_events event must be a non-empty string when provided.');
12
+ }
13
+ const events = await readCollected(input, { since, event });
14
+ return {
15
+ action: input.action,
16
+ since,
17
+ count: events.length,
18
+ events,
19
+ // Names alone are usually what a human wants to eyeball in the artifact.
20
+ names: events.map((entry) => entry.event).filter(Boolean),
21
+ };
22
+ });
@@ -0,0 +1,24 @@
1
+ import { runAdapter } from './_adapter.mjs';
2
+ import { startCollector } from './collector.mjs';
3
+
4
+ // Returns a cursor so later reads can be bracketed to this flow. Without it,
5
+ // repeated event names (`Perp Screen Viewed`) cross-match between flows.
6
+ runAdapter(async (input) => {
7
+ const {
8
+ port,
9
+ pid,
10
+ eventsFile,
11
+ reused,
12
+ androidDevice,
13
+ } = await startCollector(input);
14
+ return {
15
+ action: input.action,
16
+ cursor: Date.now(),
17
+ port,
18
+ pid,
19
+ eventsFile,
20
+ reused,
21
+ androidDevice,
22
+ note: `Client must be built with its Segment host pointed at http://localhost:${port} (extension SEGMENT_HOST, mobile SEGMENT_PROXY_URL) and a non-empty write key.`,
23
+ };
24
+ });