scenescout 3.13.0 → 3.14.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,383 @@
1
+ /**
2
+ * The refresh broker: one role's stored refresh token, presented by one
3
+ * session at a time.
4
+ *
5
+ * Every session attached by the same role loads the same saved profile, so
6
+ * every one of them holds the same refresh token. An app that rotates refresh
7
+ * tokens with reuse detection treats a second presentation of a spent token as
8
+ * theft and revokes the whole token family — every session of that role is
9
+ * then signed out at once. The broker stops that: when a session's page is
10
+ * about to send the role's refresh token, it first takes a lock next to the
11
+ * profile. Holding it, the session re-reads the profile; if another session
12
+ * has already rotated the token, it loads the rotated profile and sends the
13
+ * current token in place of the spent one. When the response arrives and the
14
+ * page has stored what it got back, the session writes its state over the
15
+ * profile and releases the lock. No two sessions ever present one token.
16
+ *
17
+ * Sessions may be separate processes (one MCP server per lane) sharing the
18
+ * profile on disk, so the lock is a file, created exclusively, owner-only,
19
+ * and taken over only once it has gone stale.
20
+ *
21
+ * Everything here is Playwright-free so it can be table-tested; the browser
22
+ * half — spotting the request and swapping the token on the wire — is in
23
+ * browser.ts. No function here returns, logs or prints a token's value:
24
+ * slots are named by where the token lives, never by what it is.
25
+ */
26
+ import crypto from "node:crypto";
27
+ import fs from "node:fs";
28
+ import os from "node:os";
29
+ import { splitProfile, withSessionStorage } from "./profiles.js";
30
+ // ── Where a refresh token lives in a profile ─────────────────────────────────
31
+ /** Cookie names, storage keys and JSON field names that hold a refresh token. */
32
+ export const REFRESH_NAME_RE = /refresh/i;
33
+ /**
34
+ * The shortest value treated as a token. A real refresh token is long and
35
+ * random; a short value ("1", "true") would match unrelated requests.
36
+ */
37
+ export const MIN_TOKEN_LENGTH = 16;
38
+ /** How deep inside a JSON-valued storage entry a refresh-token field is looked for. */
39
+ const MAX_JSON_DEPTH = 4;
40
+ function looksLikeToken(value) {
41
+ return typeof value === "string" && value.length >= MIN_TOKEN_LENGTH && !/\s/.test(value);
42
+ }
43
+ /** Refresh-token fields inside a JSON value, by dotted path. */
44
+ function jsonFields(value, at, depth, out) {
45
+ if (depth > MAX_JSON_DEPTH || !value || typeof value !== "object")
46
+ return;
47
+ for (const [key, inner] of Object.entries(value)) {
48
+ const here = at ? `${at}.${key}` : key;
49
+ if (REFRESH_NAME_RE.test(key) && looksLikeToken(inner))
50
+ out.push({ path: here, value: inner });
51
+ else
52
+ jsonFields(inner, here, depth + 1, out);
53
+ }
54
+ }
55
+ /**
56
+ * The refresh tokens a storage state holds: cookies named for one, storage
57
+ * entries named for one, and fields named for one inside a JSON-valued
58
+ * storage entry (an app that keeps `{ accessToken, refreshToken }` under one
59
+ * key). Anything that is not a storage state yields none.
60
+ */
61
+ export function refreshTokenSlots(state) {
62
+ if (!state || typeof state !== "object")
63
+ return [];
64
+ const s = state;
65
+ const out = [];
66
+ if (Array.isArray(s.cookies)) {
67
+ for (const c of s.cookies) {
68
+ if (typeof c?.name === "string" && REFRESH_NAME_RE.test(c.name) && looksLikeToken(c.value)) {
69
+ out.push({ slot: `cookie ${c.name} (${String(c.domain ?? "")}${String(c.path ?? "")})`, value: c.value });
70
+ }
71
+ }
72
+ }
73
+ if (Array.isArray(s.origins)) {
74
+ for (const o of s.origins) {
75
+ if (!Array.isArray(o?.localStorage))
76
+ continue;
77
+ for (const item of o.localStorage) {
78
+ if (typeof item?.name !== "string" || typeof item.value !== "string")
79
+ continue;
80
+ const where = `storage ${String(o.origin ?? "")} ${item.name}`;
81
+ if (REFRESH_NAME_RE.test(item.name) && looksLikeToken(item.value)) {
82
+ out.push({ slot: where, value: item.value });
83
+ continue;
84
+ }
85
+ if (!/^\s*[{[]/.test(item.value))
86
+ continue;
87
+ let parsed;
88
+ try {
89
+ parsed = JSON.parse(item.value);
90
+ }
91
+ catch {
92
+ continue; // a storage value that only looks like JSON holds no field to find
93
+ }
94
+ const fields = [];
95
+ jsonFields(parsed, "", 0, fields);
96
+ for (const f of fields)
97
+ out.push({ slot: `${where} → ${f.path}`, value: f.value });
98
+ }
99
+ }
100
+ }
101
+ return out;
102
+ }
103
+ /** The forms a token can take on the wire: as is, and percent-encoded in a form body or query. */
104
+ function wireForms(value) {
105
+ const encoded = encodeURIComponent(value);
106
+ return encoded === value ? [value] : [value, encoded];
107
+ }
108
+ /**
109
+ * The known refresh token a request carries — in its body, a header (a Cookie
110
+ * header included) or its URL — or null. `known` is what the session last
111
+ * loaded from the profile; a token the page got some other way is not the
112
+ * role's and is left alone.
113
+ */
114
+ export function presentedToken(req, known) {
115
+ if (known.length === 0)
116
+ return null;
117
+ const haystacks = [req.body ?? "", req.url, ...Object.values(req.headers)];
118
+ for (const t of known) {
119
+ for (const form of wireForms(t.value)) {
120
+ if (haystacks.some((h) => h.includes(form)))
121
+ return t;
122
+ }
123
+ }
124
+ return null;
125
+ }
126
+ export function planRefresh(sent, current) {
127
+ if (current.some((c) => c.value === sent.value))
128
+ return { kind: "send" };
129
+ const same = current.find((c) => c.slot === sent.slot);
130
+ return same ? { kind: "swap", to: same } : { kind: "unknown" };
131
+ }
132
+ /** Replace a spent token with the current one in a body, URL or header value, in whichever wire form it appears. */
133
+ export function swapToken(text, from, to) {
134
+ let out = text.split(from).join(to);
135
+ const fromEnc = encodeURIComponent(from);
136
+ if (fromEnc !== from)
137
+ out = out.split(fromEnc).join(encodeURIComponent(to));
138
+ return out;
139
+ }
140
+ /** Apply a swap to a whole request: the parts that changed, and nothing else. */
141
+ export function swapRequest(req, from, to) {
142
+ const out = {};
143
+ const url = swapToken(req.url, from, to);
144
+ if (url !== req.url)
145
+ out.url = url;
146
+ if (req.body !== null) {
147
+ const body = swapToken(req.body, from, to);
148
+ if (body !== req.body)
149
+ out.body = body;
150
+ }
151
+ let headersChanged = false;
152
+ const headers = {};
153
+ for (const [k, v] of Object.entries(req.headers)) {
154
+ headers[k] = swapToken(v, from, to);
155
+ if (headers[k] !== v)
156
+ headersChanged = true;
157
+ }
158
+ if (headersChanged)
159
+ out.headers = headers;
160
+ return out;
161
+ }
162
+ /**
163
+ * Whether the page has stored the rotation it was sent: the slot the token
164
+ * was sent from now holds a different token. Until then, writing the state
165
+ * back would save the spent token for every other session to present.
166
+ */
167
+ export function rotationStored(state, sent) {
168
+ const now = refreshTokenSlots(state).find((t) => t.slot === sent.slot);
169
+ return now !== undefined && now.value !== sent.value;
170
+ }
171
+ /**
172
+ * The profile the broker writes back once the page has stored a rotation: the
173
+ * page's storage state, which has no sessionStorage, with the sessionStorage
174
+ * of the profile on disk kept beside it. Without it, an app whose sign-in also
175
+ * lives in sessionStorage would lose that half on every brokered refresh.
176
+ * A profile that could not be read (null) leaves the page's state as it is.
177
+ */
178
+ export function profileAfterRotation(pageState, profileOnDisk) {
179
+ if (profileOnDisk === null)
180
+ return pageState;
181
+ return withSessionStorage(pageState, splitProfile(profileOnDisk).sessionStorage);
182
+ }
183
+ /**
184
+ * The rotated refresh token a refresh response carries, for a page that did
185
+ * not store it in time: a refresh-named field in a JSON body, else a
186
+ * refresh-named Set-Cookie. Null when neither names exactly one; guessing
187
+ * between two would save the wrong one.
188
+ */
189
+ export function rotatedFromResponse(body, setCookies) {
190
+ const found = new Set();
191
+ try {
192
+ const fields = [];
193
+ jsonFields(JSON.parse(body), "", 0, fields);
194
+ for (const f of fields)
195
+ found.add(f.value);
196
+ }
197
+ catch {
198
+ // Not a JSON body: the Set-Cookie headers are all there is to read.
199
+ }
200
+ if (found.size === 0) {
201
+ for (const line of setCookies) {
202
+ const pair = line.split(";")[0];
203
+ const eq = pair.indexOf("=");
204
+ if (eq <= 0)
205
+ continue;
206
+ const name = pair.slice(0, eq).trim();
207
+ const value = pair.slice(eq + 1).trim();
208
+ if (REFRESH_NAME_RE.test(name) && looksLikeToken(value))
209
+ found.add(value);
210
+ }
211
+ }
212
+ return found.size === 1 ? [...found][0] : null;
213
+ }
214
+ /** A copy of a storage state with one token replaced wherever it is held: a cookie's value, or inside a storage entry. */
215
+ export function swapProfileToken(state, from, to) {
216
+ const s = JSON.parse(JSON.stringify(state));
217
+ for (const c of s.cookies ?? [])
218
+ if (c.value === from)
219
+ c.value = to;
220
+ for (const o of s.origins ?? []) {
221
+ for (const item of o.localStorage ?? [])
222
+ if (typeof item.value === "string")
223
+ item.value = swapToken(item.value, from, to);
224
+ }
225
+ return s;
226
+ }
227
+ // ── Whether the broker runs ──────────────────────────────────────────────────
228
+ export const REFRESH_BROKER_ENV = "SCENESCOUT_REFRESH_BROKER";
229
+ /**
230
+ * Whether a session brokers its role's refresh token. On by default for a
231
+ * session attached by role; SCENESCOUT_REFRESH_BROKER=off turns it off, and
232
+ * any other value than on/off is refused rather than guessed at. An explicit
233
+ * attach option wins over the environment.
234
+ */
235
+ export function brokerEnabled(opts) {
236
+ if (!opts.roleSession)
237
+ return false;
238
+ if (opts.option !== undefined)
239
+ return opts.option;
240
+ const raw = opts.env?.trim().toLowerCase();
241
+ if (raw === undefined || raw === "" || raw === "on")
242
+ return true;
243
+ if (raw === "off")
244
+ return false;
245
+ throw new Error(`${REFRESH_BROKER_ENV} must be "on" or "off" (got "${opts.env.slice(0, 20)}")`);
246
+ }
247
+ // ── The lock ─────────────────────────────────────────────────────────────────
248
+ /** Owner read/write only, like the profile it guards. */
249
+ export const LOCK_FILE_MODE = 0o600;
250
+ /** A lock untouched this long is taken to belong to a process that died holding it. */
251
+ export const DEFAULT_STALE_MS = 30_000;
252
+ /** How long a session waits for the lock before it gives up and says so. */
253
+ export const DEFAULT_WAIT_MS = 45_000;
254
+ /** The lock file for a profile: beside it, so it shares the profile's owner-only directory. */
255
+ export function lockPathFor(profileFile) {
256
+ return `${profileFile}.lock`;
257
+ }
258
+ /** Whether a lock last touched at `mtimeMs` is stale at `now`. */
259
+ export function isStale(mtimeMs, now, staleMs) {
260
+ return now - mtimeMs > staleMs;
261
+ }
262
+ function readNonce(file) {
263
+ try {
264
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
265
+ return typeof parsed.nonce === "string" ? parsed.nonce : null;
266
+ }
267
+ catch (err) {
268
+ const code = err.code;
269
+ if (code === "ENOENT")
270
+ return null;
271
+ // Half-written by its creator, or not ours: no nonce to compare.
272
+ if (err instanceof SyntaxError)
273
+ return "";
274
+ throw err;
275
+ }
276
+ }
277
+ /** One attempt: the lock, or null when another process holds a live one. */
278
+ export function tryAcquireLock(file, opts = {}) {
279
+ const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
280
+ const now = opts.now ?? Date.now;
281
+ const nonce = crypto.randomBytes(12).toString("hex");
282
+ try {
283
+ const fd = fs.openSync(file, "wx", LOCK_FILE_MODE);
284
+ try {
285
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, host: os.hostname(), at: now(), nonce }));
286
+ }
287
+ finally {
288
+ fs.closeSync(fd);
289
+ }
290
+ }
291
+ catch (err) {
292
+ if (err.code !== "EEXIST")
293
+ throw err;
294
+ let mtimeMs;
295
+ try {
296
+ mtimeMs = fs.statSync(file).mtimeMs;
297
+ }
298
+ catch (statErr) {
299
+ if (statErr.code === "ENOENT")
300
+ return null; // released between the two calls: try again next poll
301
+ throw statErr;
302
+ }
303
+ if (!isStale(mtimeMs, now(), staleMs))
304
+ return null;
305
+ // Take over a stale lock by moving it aside under a name only this attempt
306
+ // uses, then judging the file that was actually moved: if another waiter
307
+ // took the stale lock over first, what was moved is its fresh lock, whose
308
+ // time is recent, and it goes straight back.
309
+ const aside = `${file}.${nonce}.stale`;
310
+ try {
311
+ fs.renameSync(file, aside);
312
+ }
313
+ catch (renameErr) {
314
+ if (renameErr.code === "ENOENT")
315
+ return null;
316
+ throw renameErr;
317
+ }
318
+ if (!isStale(fs.statSync(aside).mtimeMs, now(), staleMs)) {
319
+ try {
320
+ fs.linkSync(aside, file);
321
+ }
322
+ catch (linkErr) {
323
+ // A third process took the free name meanwhile. Two locks now exist for a
324
+ // moment; the one moved aside can only be dropped, and its holder's
325
+ // release finds a nonce not its own and leaves the new lock alone.
326
+ if (linkErr.code !== "EEXIST")
327
+ throw linkErr;
328
+ }
329
+ fs.rmSync(aside, { force: true });
330
+ return null;
331
+ }
332
+ fs.rmSync(aside, { force: true });
333
+ return tryAcquireLock(file, opts);
334
+ }
335
+ return {
336
+ path: file,
337
+ nonce,
338
+ release() {
339
+ // Only our own lock: one taken over as stale belongs to someone else now.
340
+ if (readNonce(file) === nonce)
341
+ fs.rmSync(file, { force: true });
342
+ },
343
+ };
344
+ }
345
+ /**
346
+ * Wait for the lock. While it is held, its file is touched regularly so a
347
+ * long refresh is never mistaken for a dead holder; the returned release
348
+ * stops that. Throws after `waitMs` with a message that names the lock file.
349
+ */
350
+ export async function acquireLock(file, opts = {}) {
351
+ const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
352
+ const waitMs = opts.waitMs ?? DEFAULT_WAIT_MS;
353
+ const pollMs = opts.pollMs ?? 25;
354
+ const now = opts.now ?? Date.now;
355
+ const deadline = now() + waitMs;
356
+ for (;;) {
357
+ const held = tryAcquireLock(file, { staleMs, now });
358
+ if (held) {
359
+ const beat = setInterval(() => {
360
+ try {
361
+ if (readNonce(file) === held.nonce)
362
+ fs.utimesSync(file, new Date(), new Date());
363
+ }
364
+ catch {
365
+ /* the lock was released or taken over; the next beat is cleared by release */
366
+ }
367
+ }, Math.max(50, Math.floor(staleMs / 3)));
368
+ beat.unref();
369
+ const release = held.release;
370
+ return {
371
+ ...held,
372
+ release() {
373
+ clearInterval(beat);
374
+ release();
375
+ },
376
+ };
377
+ }
378
+ if (now() >= deadline) {
379
+ throw new Error(`timed out after ${waitMs} ms waiting for the refresh lock at ${file}; another session is refreshing, or a stale lock is not yet ${staleMs} ms old`);
380
+ }
381
+ await new Promise((r) => setTimeout(r, pollMs + Math.floor(Math.random() * pollMs)));
382
+ }
383
+ }