1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
|
// puppeteer-core for the browser tests, with one difference: a page's
// sockets to public relays never leave the machine. Throwaway test wallets
// boot, subscribe and publish like real ones, and a suite run from one home
// address gets that address banned (nos.lol, relay.damus.io, 2026-10-07).
// So every socket to a host that isn't local is answered in the page by a
// stand-in relay that has nothing stored: EVENT gets OK, REQ gets EOSE,
// COUNT gets 0. Local relays (ws://127.0.0.1:…, a test's own) are untouched,
// and a test's own WebSocket stubs still see every send, as before.
//
// A test that reads real content off the public relays launches with
// { relays: 'read' }: its sockets are real, but nothing it publishes leaves
// the page (each EVENT is answered OK in place). LIVE_RELAYS=read does the
// same for any test. { relays: 'live' } (a test of a public service, like
// the Cordn coordinator) or LIVE_RELAYS=1 turns the guard off, and
// LIVE_RELAYS=relay.example.com,other.example lets just those hosts through.
// puppeteer.connect (driving a real Chrome) is never guarded.
//
// Use: import puppeteer from './puppeteer.js'; (in place of 'puppeteer-core')
import real from 'puppeteer-core';
const live = (process.env.LIVE_RELAYS || '').trim();
const allow = live && live !== '1' && live !== 'read' ? live.split(',').map((h) => h.trim()).filter(Boolean) : [];
// Runs in the page before anything else.
function relayGuard(allow, readOnly) {
if (window.__relayGuard) return;
window.__relayGuard = { blocked: [], sent: 0, events: [] }; // events: what didn't go out
const Real = window.WebSocket;
const nativeSend = Real.prototype.send;
const nativeClose = Real.prototype.close;
const fakes = new WeakSet();
const reading = new WeakSet(); // real public sockets in read mode
const local = (h) => h === 'localhost' || h === '[::1]' || h === '0.0.0.0' || /^127\./.test(h) || h.endsWith('.localhost');
const answer = (ws, msg) => setTimeout(() => {
if (ws.readyState !== 1) return;
ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(msg) }));
}, 5);
const relay = (ws, d) => {
let m;
try { m = JSON.parse(d); } catch { return; }
if (!Array.isArray(m)) return;
window.__relayGuard.sent++;
if (m[0] === 'EVENT' && m[1]?.id) { window.__relayGuard.events.push(m[1]); answer(ws, ['OK', m[1].id, true, '']); }
else if (m[0] === 'REQ') answer(ws, ['EOSE', m[1]]);
else if (m[0] === 'COUNT') answer(ws, ['COUNT', m[1], { count: 0 }]);
else if (m[0] === 'AUTH' && m[1]?.id) answer(ws, ['OK', m[1].id, true, '']);
};
// Every send goes through here, so a test that wraps
// WebSocket.prototype.send (before or after) still sees a stand-in's.
Real.prototype.send = function (d) {
if (fakes.has(this)) { if (this.readyState !== 1) throw new DOMException('not open', 'InvalidStateError'); return relay(this, d); }
if (reading.has(this) && typeof d === 'string' && /^\s*\[\s*"EVENT"/.test(d)) return relay(this, d);
return nativeSend.call(this, d);
};
Real.prototype.close = function (code, reason) {
if (!fakes.has(this)) return nativeClose.call(this, code, reason);
if (this.readyState >= 2) return;
this.readyState = 2;
setTimeout(() => { this.readyState = 3; this.dispatchEvent(new CloseEvent('close', { code: code || 1000, reason: reason || '', wasClean: true })); }, 0);
};
const handlerProp = (ws, type) => {
let fn = null;
const call = (e) => fn && fn.call(ws, e);
ws.addEventListener(type, call);
Object.defineProperty(ws, 'on' + type, { get: () => fn, set: (v) => { fn = typeof v === 'function' ? v : null; }, configurable: true });
};
function WebSocket(url, protocols) {
if (!new.target) throw new TypeError("Failed to construct 'WebSocket': Please use the 'new' operator");
let u;
try { u = new URL(url, location.href); } catch { return new Real(url, protocols); }
if (u.protocol !== 'wss:' && u.protocol !== 'ws:') return new Real(url, protocols);
if (local(u.hostname) || allow.includes(u.hostname)) return new Real(url, protocols);
if (readOnly) { const ws = new Real(url, protocols); reading.add(ws); return ws; }
window.__relayGuard.blocked.push(u.href);
// a genuine EventTarget (so dispatchEvent works) that passes instanceof
const ws = Object.setPrototypeOf(new EventTarget(), Real.prototype);
fakes.add(ws);
Object.defineProperties(ws, {
url: { value: u.href }, protocol: { value: '' }, extensions: { value: '' },
bufferedAmount: { value: 0 }, binaryType: { value: 'blob', writable: true },
readyState: { value: 0, writable: true },
});
for (const t of ['open', 'message', 'close', 'error']) handlerProp(ws, t);
setTimeout(() => { if (ws.readyState !== 0) return; ws.readyState = 1; ws.dispatchEvent(new Event('open')); }, 0);
return ws;
}
WebSocket.prototype = Real.prototype;
for (const k of ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']) WebSocket[k] = Real[k];
window.WebSocket = WebSocket;
}
const guarded = new WeakSet();
const guardPage = async (page, ro) => {
if (!page || guarded.has(page)) return;
guarded.add(page);
await page.evaluateOnNewDocument(relayGuard, allow, ro).catch(() => {});
};
const guardContext = (ctx, ro) => {
const newPage = ctx.newPage.bind(ctx);
ctx.newPage = async (...a) => { const p = await newPage(...a); await guardPage(p, ro); return p; };
};
const guardBrowser = async (browser, ro) => {
guardContext(browser, ro);
for (const name of ['createBrowserContext', 'createIncognitoBrowserContext']) {
if (typeof browser[name] !== 'function') continue;
const make = browser[name].bind(browser);
browser[name] = async (...a) => { const c = await make(...a); guardContext(c, ro); return c; };
}
// pages it opened itself (popups, the first tab)
browser.on('targetcreated', async (t) => { if (t.type() === 'page') await guardPage(await t.page().catch(() => null), ro); });
for (const p of await browser.pages()) await guardPage(p, ro);
};
const launch = async ({ relays, ...opts } = {}) => {
// a machine without /usr/bin/google-chrome names its browser: CHROME=/path/to/chrome
if (process.env.CHROME) opts.executablePath = process.env.CHROME;
const browser = await real.launch(opts);
if (live !== '1' && relays !== 'live') await guardBrowser(browser, live === 'read' || relays === 'read');
return browser;
};
export default new Proxy(real, {
get(t, k) {
if (k === 'launch') return launch;
const v = t[k];
return typeof v === 'function' ? v.bind(t) : v;
},
});
// The create flow (2026-11-07): a heads-up, then the words, then the
// passphrase page, then a one-word check. seedWords() gets a test from
// "Create a new wallet" to the words on screen; skipSeedCheck() from there
// (or from the heads-up) into the wallet, no passphrase, check skipped.
export async function seedWords(page) {
await page.waitForSelector('#seed-intro-next, .words .w .t', { timeout: 30000 });
if (await page.$('#seed-intro-next')) await page.evaluate(() => document.getElementById('seed-intro-next').click());
await page.waitForSelector('.words .w .t');
}
export async function skipSeedCheck(page) {
if (await page.$('#seed-intro-next')) await seedWords(page);
for (const id of ['seed-next', 'pass-next', 'seed-skip']) {
await page.waitForSelector('#' + id, { timeout: 10000 });
await page.evaluate((i) => document.getElementById(i).click(), id);
}
}
|