@podway/relay 0.2.3

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/FAQ.md ADDED
@@ -0,0 +1,87 @@
1
+ # Running Podbay Relay on a headless host
2
+
3
+ The relay can run on any NAS, Raspberry Pi, or other always-on machine that
4
+ supports Node. This guide covers the common headless setup issues: finding
5
+ `npx`, starting the relay at boot, and opening the dashboard remotely.
6
+
7
+ ## `npx: command not found`, even though I just installed Node
8
+
9
+ Some NAS packages install `node` and `npm` without putting `npx` on your `PATH`.
10
+ Synology's DSM Node.js package is one example. Check the directory containing
11
+ `node`:
12
+
13
+ ```bash
14
+ NODE_BIN="$(dirname "$(which node)")"
15
+ ls "$NODE_BIN" | grep npx
16
+ ```
17
+
18
+ If `npx` appears, add that directory to `PATH` in `~/.bashrc`, `~/.profile`, or
19
+ the equivalent file for your shell.
20
+
21
+ If it is genuinely absent, search for another copy:
22
+
23
+ ```bash
24
+ find / -iname npx 2>/dev/null
25
+ ```
26
+
27
+ If you find another copy, add its directory to `PATH`. You can also install it
28
+ separately with `npm install -g npx`.
29
+
30
+ Boot schedulers and one-command SSH sessions often use a shorter `PATH` than a
31
+ normal login shell. If `npx` works after you log in but fails in a script, set
32
+ `PATH` explicitly at the top of that script.
33
+
34
+ ## How do I make the relay survive a reboot?
35
+
36
+ The relay itself does not need root. Use whichever startup mechanism your host
37
+ provides:
38
+
39
+ - **Synology DSM**: Go to Control Panel → Task Scheduler → Create →
40
+ Triggered Task → User-defined script, then choose **Boot-up**. Run:
41
+
42
+ ```bash
43
+ export PATH=/path/to/node/bin:$PATH # wherever npx actually lives, see above
44
+ npx @podway/relay@latest start --gateway <url> --accept
45
+ ```
46
+
47
+ - **A systemd host**: If you have root, create a service that runs the same
48
+ command at boot.
49
+ - **A host with a user crontab**: Run `crontab -e` and add the same command as
50
+ an `@reboot` entry. Not every NAS includes a user crontab.
51
+
52
+ The scheduler only needs to run the command once. `start` moves the relay into
53
+ the background and exits.
54
+
55
+ ## Do I need to pass `--code` again every time it starts?
56
+
57
+ No. `--code` is only used for the first pairing. The relay saves a reconnect
58
+ token locally, so later `start` commands reconnect automatically:
59
+
60
+ ```bash
61
+ npx @podway/relay@latest start --gateway <url> --accept
62
+ ```
63
+
64
+ Do not save the original pairing code in your boot script; it expires and is
65
+ not needed after pairing. To pair again, run
66
+ `npx @podway/relay@latest reset`. This also clears the relay's other saved data.
67
+
68
+ ## How do I open the dashboard remotely?
69
+
70
+ The dashboard listens only on `127.0.0.1`, so it is not exposed to your network.
71
+ Create an SSH tunnel from your computer:
72
+
73
+ ```bash
74
+ ssh -L 7373:127.0.0.1:7373 user@your-nas
75
+ ```
76
+
77
+ Keep the tunnel open, run `npx @podway/relay@latest status` on the relay host,
78
+ and open the printed `http://127.0.0.1:7373/...` URL on your computer.
79
+
80
+ If the tunnel immediately resets, check whether the SSH server disables
81
+ forwarding. Synology commonly sets `AllowTcpForwarding no`. Enabling it affects
82
+ every authorized SSH user, not only Podbay, and requires administrator access:
83
+
84
+ ```bash
85
+ sudo sed -i 's/^AllowTcpForwarding no/AllowTcpForwarding yes/' /etc/ssh/sshd_config
86
+ sudo systemctl restart sshd # or: synosystemctl restart sshd, on some DSM versions
87
+ ```
package/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 Podbay
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # Podbay Relay
2
+
3
+ The Podbay relay lets your pods reach websites through your network instead of
4
+ a datacenter. This helps with sites that block cloud IPs. For sites you choose,
5
+ the relay can also use a separate browser session that you sign into.
6
+
7
+ One relay handles two kinds of traffic:
8
+
9
+ | mode | what the pod gets | used by |
10
+ |---|---|---|
11
+ | **fetch** | a page loaded by this machine, optionally with a session you provided | the agent's `podbay fetch get` command |
12
+ | **tunnel** | a proxy connection through your IP, without your cookies | crawlers, scripts, Playwright, and other tools that load pages themselves |
13
+
14
+ The tunnel lets a pod run a real browser while the connection comes from your
15
+ network. Pods receive it through `$PODBAY_RELAY_PROXY`. It **fails closed**: if
16
+ the relay is not running, those connections are refused instead of quietly
17
+ leaving from the datacenter.
18
+
19
+ ```bash
20
+ # Your pod prints the exact command, pinned to a version and pairing code:
21
+ npx @podway/relay@<version> start --code <code> --accept
22
+ ```
23
+
24
+ Once started, the relay runs in the background and handles both modes.
25
+
26
+ ## What it does — and what it does not do
27
+
28
+ - **Public pages are clean by default.** They load in a fresh browser without
29
+ your cookies or accounts.
30
+ - **Signed-in access is explicit.** `relay login <domain>` opens a browser so
31
+ you can sign in. Only that domain may use the saved session. The relay returns
32
+ page content, never cookies, credentials, or session tokens.
33
+ - **The tunnel shares your IP, not your accounts.** Tools using the proxy get a
34
+ clean connection without your signed-in sessions.
35
+ - **Your private network stays blocked.** Pods cannot reach localhost, your LAN,
36
+ private IPs, routers, or other internal services through the relay.
37
+ - **You stay in control.** `relay dashboard` shows activity and lets you pause a
38
+ pod, block a site, revoke signed-in access, manage history, or stop the relay.
39
+ - **Requests are rate-limited per site**, so one pod cannot hammer a source
40
+ through your connection.
41
+
42
+ ## Commands
43
+
44
+ | command | description |
45
+ |---|---|
46
+ | `relay start --code <code> --gateway <url> --accept` | start the relay in the background |
47
+ | `relay login <domain>` | allow signed-in fetches for one domain |
48
+ | `relay dashboard` | open the local command center |
49
+ | `relay status` | show the relay, gateway, and signed-in site status |
50
+ | `relay stop` | stop it |
51
+ | `relay reset` | remove saved sessions, logins, and pairing |
52
+
53
+ ## Trust
54
+
55
+ The relay stores browser sessions for sites you sign into, so treat it as
56
+ sensitive software. It is open source under Apache-2.0 and uses its own browser
57
+ profile; it never touches your everyday browser, history, or cookies. Automating
58
+ a signed-in session may violate a site's terms, and the account risk is yours.
59
+
60
+ ## Local command center and history
61
+
62
+ `relay dashboard` shows the connection state, recent activity, and access
63
+ controls. While the relay runs, the dashboard is live. When stopped, it shows
64
+ saved history in read-only mode. The CLI always prints its local URL, so it also
65
+ works on headless machines without `xdg-open`.
66
+
67
+ Activity is stored in owner-only, daily files under `~/.podway/relay/events`.
68
+ Events include the pod ID when available, mode, sanitized target, timing,
69
+ outcome, HTTP status, signed-in use, and tunnel byte counts. History is kept for
70
+ 30 days by default; the dashboard offers 7, 30, or 90 days and enforces a
71
+ storage limit.
72
+
73
+ Before an event is written, URLs are stripped of usernames, passwords, query
74
+ strings, and fragments. Logs never include cookies, authorization headers,
75
+ browser storage, request or response bodies, or tunnel content. Detailed
76
+ history and exports stay on your machine; Podbay receives only coarse,
77
+ domain-level telemetry. Clearing history does not clear pairing, sessions,
78
+ paused pods, or blocked sites.
79
+
80
+ ## Running headless
81
+
82
+ Running the relay on a NAS, Raspberry Pi, or another always-on machine? See
83
+ [FAQ.md](FAQ.md) for help with missing `npx`, starting at boot, and opening the
84
+ dashboard remotely.
@@ -0,0 +1,109 @@
1
+ export type RelayMode = "fetch" | "tunnel";
2
+ export type RelayOutcome = "ok" | "site-refused" | "owner-blocked" | "safety-blocked" | "rate-limited" | "network-error";
3
+ export type RelaySource = {
4
+ podId: string;
5
+ podName?: string;
6
+ } | {
7
+ system: "health-check";
8
+ };
9
+ /** The only detailed event shape allowed across the local persistence boundary. */
10
+ export interface RelayEventV2 {
11
+ v: 2;
12
+ id: string;
13
+ startedAt: string;
14
+ finishedAt: string;
15
+ durationMs: number;
16
+ source?: RelaySource;
17
+ mode: RelayMode;
18
+ outcome: RelayOutcome;
19
+ target: string;
20
+ finalTarget?: string;
21
+ host: string;
22
+ httpStatus?: number;
23
+ session: boolean;
24
+ reason?: string;
25
+ bytesUp?: number;
26
+ bytesDown?: number;
27
+ }
28
+ /** Rows written by pb <= 0.1.6. */
29
+ export interface AuditEntry {
30
+ host: string;
31
+ status: number;
32
+ ms: number;
33
+ session: boolean;
34
+ error?: string;
35
+ kind?: RelayMode;
36
+ bytes?: number;
37
+ at?: string;
38
+ }
39
+ export interface SafeFetchTarget {
40
+ target: string;
41
+ host: string;
42
+ }
43
+ /** Strip the credential-bearing and high-entropy portions before an event exists. */
44
+ export declare function safeFetchTarget(value: string): SafeFetchTarget | null;
45
+ export declare function safeTunnelTarget(hostValue: string, port: number): {
46
+ target: string;
47
+ host: string;
48
+ } | null;
49
+ export declare function fetchOutcome(status: number, error?: string): RelayOutcome;
50
+ /** Rebuild a record from allowed fields, sanitizing targets again at the write boundary. */
51
+ export declare function sanitizeEvent(input: RelayEventV2): RelayEventV2 | null;
52
+ export interface EventQuery {
53
+ since?: string;
54
+ podId?: string;
55
+ mode?: RelayMode;
56
+ outcome?: RelayOutcome;
57
+ site?: string;
58
+ offset?: number;
59
+ limit?: number;
60
+ }
61
+ /** Bounded, day-partitioned, owner-local JSONL with an in-memory retained index. */
62
+ export declare class RelayEventStore {
63
+ private readonly options;
64
+ private index;
65
+ private lastPrunedDay;
66
+ private selectedRetentionDays;
67
+ readonly root: string;
68
+ constructor(options?: {
69
+ root?: string;
70
+ legacyFile?: string;
71
+ retentionDays?: 7 | 30 | 90;
72
+ maxBytes?: number;
73
+ now?: () => number;
74
+ });
75
+ private now;
76
+ private retentionDays;
77
+ setRetentionDays(days: 7 | 30 | 90): void;
78
+ init(): this;
79
+ private eventFiles;
80
+ private parseLines;
81
+ reload(): void;
82
+ append(input: RelayEventV2): boolean;
83
+ query(query?: EventQuery): RelayEventV2[];
84
+ exportJson(): string;
85
+ clear(): void;
86
+ storageBytes(): number;
87
+ prune(force?: boolean): void;
88
+ }
89
+ /** Compatibility writer for callers not yet migrated to v2. */
90
+ export declare function record(entry: AuditEntry): void;
91
+ export interface AuditSummary {
92
+ total: number;
93
+ ok: number;
94
+ failed: number;
95
+ sessionFetches: number;
96
+ tunnelConnections: number;
97
+ tunnelBytes: number;
98
+ byHost: {
99
+ host: string;
100
+ count: number;
101
+ failed: number;
102
+ session: boolean;
103
+ tunnel: number;
104
+ bytes: number;
105
+ }[];
106
+ }
107
+ /** Legacy summary kept for compatibility with older integrations and tests. */
108
+ export declare function summarize(text: string): AuditSummary;
109
+ export declare function readSummary(): AuditSummary;
package/dist/audit.js ADDED
@@ -0,0 +1,339 @@
1
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, } from "node:fs";
2
+ import path from "node:path";
3
+ import { auditFile, eventsDir } from "./config.js";
4
+ /** Strip the credential-bearing and high-entropy portions before an event exists. */
5
+ export function safeFetchTarget(value) {
6
+ try {
7
+ const url = new URL(value);
8
+ if (url.protocol !== "http:" && url.protocol !== "https:")
9
+ return null;
10
+ url.username = "";
11
+ url.password = "";
12
+ url.search = "";
13
+ url.hash = "";
14
+ const host = url.hostname.toLowerCase().replace(/\.$/, "");
15
+ if (!host)
16
+ return null;
17
+ return { target: url.toString(), host };
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export function safeTunnelTarget(hostValue, port) {
24
+ const host = hostValue.trim().toLowerCase().replace(/\.$/, "");
25
+ if (!host || /[\s/@?#]/.test(host) || !Number.isInteger(port) || port < 1 || port > 65535)
26
+ return null;
27
+ return { host, target: host.includes(":") ? `[${host}]:${port}` : `${host}:${port}` };
28
+ }
29
+ export function fetchOutcome(status, error) {
30
+ if (error)
31
+ return "network-error";
32
+ if (status === 403)
33
+ return "site-refused";
34
+ if (status === 429)
35
+ return "rate-limited";
36
+ if (status >= 200 && status < 400)
37
+ return "ok";
38
+ return "network-error";
39
+ }
40
+ function sourceOf(value) {
41
+ if (!value || typeof value !== "object")
42
+ return undefined;
43
+ const source = value;
44
+ if (typeof source.podId === "string" && source.podId.trim()) {
45
+ const out = { podId: source.podId.slice(0, 160) };
46
+ // Gateway-authoritative display name, bounded like the id. Optional — absent means
47
+ // the dashboard shows the id. (Old rows / old gateways simply omit it.)
48
+ if (typeof source.podName === "string" && source.podName.trim())
49
+ out.podName = source.podName.slice(0, 160);
50
+ return out;
51
+ }
52
+ if (source.system === "health-check")
53
+ return { system: "health-check" };
54
+ return undefined;
55
+ }
56
+ function boundedText(value, max = 500) {
57
+ return typeof value === "string" && value ? value.slice(0, max) : undefined;
58
+ }
59
+ /** Rebuild a record from allowed fields, sanitizing targets again at the write boundary. */
60
+ export function sanitizeEvent(input) {
61
+ const safe = input.mode === "fetch"
62
+ ? safeFetchTarget(input.target)
63
+ : (() => {
64
+ const match = input.target.match(/^\[?([^\]]+?)\]?:([0-9]+)$/);
65
+ return match ? safeTunnelTarget(match[1], Number(match[2])) : null;
66
+ })();
67
+ if (!safe)
68
+ return null;
69
+ const final = input.mode === "fetch" && input.finalTarget ? safeFetchTarget(input.finalTarget)?.target : undefined;
70
+ return {
71
+ v: 2,
72
+ id: boundedText(input.id, 200) ?? crypto.randomUUID(),
73
+ startedAt: new Date(input.startedAt).toISOString(),
74
+ finishedAt: new Date(input.finishedAt).toISOString(),
75
+ durationMs: Math.max(0, Math.round(Number(input.durationMs) || 0)),
76
+ source: sourceOf(input.source),
77
+ mode: input.mode,
78
+ outcome: input.outcome,
79
+ target: safe.target,
80
+ ...(final ? { finalTarget: final } : {}),
81
+ host: safe.host,
82
+ ...(Number.isInteger(input.httpStatus) ? { httpStatus: input.httpStatus } : {}),
83
+ session: input.mode === "fetch" && input.session === true,
84
+ ...(boundedText(input.reason) ? { reason: boundedText(input.reason) } : {}),
85
+ ...(input.mode === "tunnel" ? {
86
+ bytesUp: Math.max(0, Math.round(Number(input.bytesUp) || 0)),
87
+ bytesDown: Math.max(0, Math.round(Number(input.bytesDown) || 0)),
88
+ } : {}),
89
+ };
90
+ }
91
+ function legacyToV2(row, index, fallbackAt) {
92
+ const mode = row.kind === "tunnel" ? "tunnel" : "fetch";
93
+ const at = typeof row.at === "string" && Number.isFinite(Date.parse(row.at)) ? row.at : fallbackAt;
94
+ const target = mode === "tunnel"
95
+ ? safeTunnelTarget(String(row.host ?? "unknown.invalid"), 443)?.target
96
+ : safeFetchTarget(`https://${String(row.host ?? "unknown.invalid")}/`)?.target;
97
+ if (!target)
98
+ return null;
99
+ return sanitizeEvent({
100
+ v: 2,
101
+ id: `legacy-${index}-${Date.parse(at) || 0}`,
102
+ startedAt: at,
103
+ finishedAt: new Date((Date.parse(at) || Date.now()) + Math.max(0, Number(row.ms) || 0)).toISOString(),
104
+ durationMs: Math.max(0, Number(row.ms) || 0),
105
+ mode,
106
+ outcome: fetchOutcome(Number(row.status) || 0, row.error),
107
+ target,
108
+ host: String(row.host ?? "unknown.invalid"),
109
+ ...(mode === "fetch" ? { httpStatus: Number(row.status) || 0 } : {}),
110
+ session: row.session === true,
111
+ ...(row.error ? { reason: row.error } : {}),
112
+ ...(mode === "tunnel" ? { bytesUp: 0, bytesDown: Math.max(0, Number(row.bytes) || 0) } : {}),
113
+ });
114
+ }
115
+ /** Bounded, day-partitioned, owner-local JSONL with an in-memory retained index. */
116
+ export class RelayEventStore {
117
+ options;
118
+ index = [];
119
+ lastPrunedDay = "";
120
+ selectedRetentionDays;
121
+ root;
122
+ constructor(options = {}) {
123
+ this.options = options;
124
+ this.root = options.root ?? eventsDir();
125
+ this.selectedRetentionDays = options.retentionDays ?? 30;
126
+ }
127
+ now() { return (this.options.now ?? Date.now)(); }
128
+ retentionDays() { return this.selectedRetentionDays; }
129
+ setRetentionDays(days) { this.selectedRetentionDays = days; }
130
+ init() {
131
+ mkdirSync(this.root, { recursive: true, mode: 0o700 });
132
+ chmodSync(this.root, 0o700);
133
+ this.prune(true);
134
+ this.reload();
135
+ return this;
136
+ }
137
+ eventFiles() {
138
+ if (!existsSync(this.root))
139
+ return [];
140
+ return readdirSync(this.root)
141
+ .filter((name) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(name))
142
+ .sort()
143
+ .map((name) => path.join(this.root, name));
144
+ }
145
+ parseLines(text) {
146
+ const out = [];
147
+ for (const line of text.split("\n")) {
148
+ if (!line)
149
+ continue;
150
+ try {
151
+ const parsed = JSON.parse(line);
152
+ if (parsed.v === 2) {
153
+ const safe = sanitizeEvent(parsed);
154
+ if (safe)
155
+ out.push(safe);
156
+ }
157
+ }
158
+ catch { /* one corrupt row does not hide its siblings */ }
159
+ }
160
+ return out;
161
+ }
162
+ reload() {
163
+ const rows = [];
164
+ for (const file of this.eventFiles()) {
165
+ try {
166
+ rows.push(...this.parseLines(readFileSync(file, "utf8")));
167
+ }
168
+ catch { /* unreadable partition */ }
169
+ }
170
+ const legacy = this.options.legacyFile ?? auditFile();
171
+ try {
172
+ const fallback = new Date(statSync(legacy).mtimeMs).toISOString();
173
+ readFileSync(legacy, "utf8").split("\n").forEach((line, index) => {
174
+ if (!line)
175
+ return;
176
+ try {
177
+ const event = legacyToV2(JSON.parse(line), index, fallback);
178
+ if (event)
179
+ rows.push(event);
180
+ }
181
+ catch { /* malformed legacy row */ }
182
+ });
183
+ }
184
+ catch { /* no legacy log */ }
185
+ const cutoff = this.now() - this.retentionDays() * 86_400_000;
186
+ this.index = rows
187
+ .filter((event) => Date.parse(event.finishedAt) >= cutoff)
188
+ .sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt));
189
+ }
190
+ append(input) {
191
+ try {
192
+ const event = sanitizeEvent(input);
193
+ if (!event)
194
+ return false;
195
+ mkdirSync(this.root, { recursive: true, mode: 0o700 });
196
+ const day = event.startedAt.slice(0, 10);
197
+ const file = path.join(this.root, `${day}.jsonl`);
198
+ appendFileSync(file, JSON.stringify(event) + "\n", { mode: 0o600 });
199
+ chmodSync(file, 0o600);
200
+ this.index.unshift(event);
201
+ this.prune();
202
+ return true;
203
+ }
204
+ catch {
205
+ return false; // audit must never fail relay transport
206
+ }
207
+ }
208
+ query(query = {}) {
209
+ const since = query.since ? Date.parse(query.since) : Number.NEGATIVE_INFINITY;
210
+ const site = query.site?.toLowerCase();
211
+ const rows = this.index.filter((event) => Date.parse(event.startedAt) >= since &&
212
+ (!query.podId || (!!event.source && "podId" in event.source && event.source.podId === query.podId)) &&
213
+ (!query.mode || event.mode === query.mode) &&
214
+ (!query.outcome || event.outcome === query.outcome) &&
215
+ (!site || event.host.includes(site)));
216
+ const offset = Math.max(0, query.offset ?? 0);
217
+ return rows.slice(offset, offset + Math.min(10_000, Math.max(1, query.limit ?? 500)));
218
+ }
219
+ exportJson() { return JSON.stringify(this.index, null, 2); }
220
+ clear() {
221
+ for (const file of this.eventFiles())
222
+ rmSync(file, { force: true });
223
+ rmSync(this.options.legacyFile ?? auditFile(), { force: true });
224
+ this.index = [];
225
+ }
226
+ storageBytes() {
227
+ let total = 0;
228
+ for (const file of this.eventFiles()) {
229
+ try {
230
+ total += statSync(file).size;
231
+ }
232
+ catch { }
233
+ }
234
+ try {
235
+ total += statSync(this.options.legacyFile ?? auditFile()).size;
236
+ }
237
+ catch { }
238
+ return total;
239
+ }
240
+ prune(force = false) {
241
+ const today = new Date(this.now()).toISOString().slice(0, 10);
242
+ if (!force && this.lastPrunedDay === today)
243
+ return;
244
+ this.lastPrunedDay = today;
245
+ const cutoffDay = new Date(this.now() - this.retentionDays() * 86_400_000).toISOString().slice(0, 10);
246
+ for (const file of this.eventFiles()) {
247
+ if (path.basename(file, ".jsonl") < cutoffDay)
248
+ rmSync(file, { force: true });
249
+ }
250
+ const legacy = this.options.legacyFile ?? auditFile();
251
+ try {
252
+ if (statSync(legacy).mtimeMs < this.now() - this.retentionDays() * 86_400_000)
253
+ rmSync(legacy, { force: true });
254
+ }
255
+ catch { }
256
+ const ceiling = this.options.maxBytes ?? 50 * 1024 * 1024;
257
+ const files = this.eventFiles();
258
+ let total = files.reduce((sum, file) => { try {
259
+ return sum + statSync(file).size;
260
+ }
261
+ catch {
262
+ return sum;
263
+ } }, 0);
264
+ try {
265
+ total += statSync(legacy).size;
266
+ }
267
+ catch { }
268
+ const candidates = [legacy, ...files];
269
+ for (const file of candidates) {
270
+ if (total <= ceiling)
271
+ break;
272
+ try {
273
+ const size = statSync(file).size;
274
+ rmSync(file, { force: true });
275
+ total -= size;
276
+ }
277
+ catch { }
278
+ }
279
+ }
280
+ }
281
+ /** Compatibility writer for callers not yet migrated to v2. */
282
+ export function record(entry) {
283
+ try {
284
+ const at = new Date().toISOString();
285
+ const converted = legacyToV2({ ...entry, at }, 0, at);
286
+ if (converted)
287
+ new RelayEventStore().init().append(converted);
288
+ }
289
+ catch { /* best effort */ }
290
+ }
291
+ function isOk(row) {
292
+ return !row.error && row.status >= 200 && row.status < 400;
293
+ }
294
+ /** Legacy summary kept for compatibility with older integrations and tests. */
295
+ export function summarize(text) {
296
+ const rows = text.split("\n").filter(Boolean).flatMap((line) => {
297
+ try {
298
+ return [JSON.parse(line)];
299
+ }
300
+ catch {
301
+ return [];
302
+ }
303
+ });
304
+ const byHost = new Map();
305
+ let ok = 0, failed = 0, sessionFetches = 0, tunnelConnections = 0, tunnelBytes = 0;
306
+ for (const row of rows) {
307
+ const host = byHost.get(row.host) ?? { count: 0, failed: 0, session: false, tunnel: 0, bytes: 0 };
308
+ const good = isOk(row);
309
+ if (row.kind === "tunnel") {
310
+ host.tunnel++;
311
+ host.bytes += row.bytes ?? 0;
312
+ tunnelConnections++;
313
+ tunnelBytes += row.bytes ?? 0;
314
+ }
315
+ else
316
+ host.count++;
317
+ if (!good)
318
+ host.failed++;
319
+ if (row.session) {
320
+ host.session = true;
321
+ sessionFetches++;
322
+ }
323
+ byHost.set(row.host, host);
324
+ if (good)
325
+ ok++;
326
+ else
327
+ failed++;
328
+ }
329
+ return { total: rows.length, ok, failed, sessionFetches, tunnelConnections, tunnelBytes,
330
+ byHost: [...byHost.entries()].map(([host, value]) => ({ host, ...value })).sort((a, b) => b.count + b.tunnel - a.count - a.tunnel) };
331
+ }
332
+ export function readSummary() {
333
+ try {
334
+ return summarize(readFileSync(auditFile(), "utf8"));
335
+ }
336
+ catch {
337
+ return summarize("");
338
+ }
339
+ }
@@ -0,0 +1,58 @@
1
+ import type { Fetcher } from "./relay-client.js";
2
+ /**
3
+ * Fetch a page on the OWNER's machine, choosing the session per the login policy.
4
+ *
5
+ * - A host the owner explicitly signed into (`isSessionDomain`) is fetched in the
6
+ * PERSISTENT profile — as them, with their cookies. That is the deliberate,
7
+ * opt-in "fetch as me".
8
+ * - Every other host is fetched in a fresh EPHEMERAL context with no cookies — the
9
+ * residential IP, but not the owner's accounts. So a prompt-injected pod fetching
10
+ * gmail.com gets a logged-out page, not the owner's inbox.
11
+ *
12
+ * Both contexts: one page per request, always closed; warm and recycled after N pages;
13
+ * scrape and return bytes, never an eval channel or a live handle.
14
+ */
15
+ export interface BrowserOptions {
16
+ profileDir: string;
17
+ channel?: string;
18
+ navTimeoutMs?: number;
19
+ /** Hard ceiling on a WHOLE fetch (launch + nav + read). A browser that stalls past
20
+ * this is aborted and torn down so it can't hang the relay — see `fetch`. */
21
+ fetchDeadlineMs?: number;
22
+ recycleAfter?: number;
23
+ headless?: boolean;
24
+ /** Does this host use the owner's signed-in session? Read from config per fetch. */
25
+ isSessionDomain: (host: string) => boolean;
26
+ }
27
+ export declare class BrowserFetcher {
28
+ private readonly opts;
29
+ private browser;
30
+ private session;
31
+ private served;
32
+ constructor(opts: BrowserOptions);
33
+ private pw;
34
+ /**
35
+ * Launch, preferring the owner's installed Chrome (no download) and falling back to
36
+ * Playwright's bundled Chromium. A missing browser entirely is a clear, actionable
37
+ * error, not a stack trace.
38
+ */
39
+ private launch;
40
+ private sessionCtx;
41
+ private cleanCtx;
42
+ /**
43
+ * WATCHDOG: a fetch that stalls (a site refusing a headless browser, a hung context,
44
+ * a wedged persistent profile) must NEVER hang the relay — that was the whole
45
+ * "relay running but nothing comes back" failure (live-caught on reddit, 2026-08-03).
46
+ * The nav timeout only bounds `goto`; launch, `content()`, and context creation could
47
+ * hang unbounded. So the whole fetch races a hard deadline, and on a blown deadline we
48
+ * TEAR DOWN the browser so the NEXT fetch relaunches clean rather than inheriting a
49
+ * wedged one.
50
+ */
51
+ readonly fetch: Fetcher;
52
+ private doFetch;
53
+ /** Drop the wedged context so the next fetch launches a fresh one. */
54
+ private resetBrowser;
55
+ /** Remove only one site's relay-profile cookies; clean contexts and siblings stay intact. */
56
+ revokeDomain(domain: string): Promise<void>;
57
+ close(): Promise<void>;
58
+ }