@foldspace_npm/harness 0.1.15 → 0.1.17

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,627 @@
1
+ // Pure logic behind `foldspace observe`. Nothing here touches CDP, the network
2
+ // or the filesystem, so every rule that protects the customer (GET only, names
3
+ // never values, navigation-only clicks, same-origin paths) is unit-testable.
4
+ //
5
+ // Functions marked "self-contained" are also injected into the page with
6
+ // Function#toString, so they must not reference anything outside their body.
7
+
8
+ // ------------------------------------------------------------------ hosts
9
+
10
+ export function hostMatchesAny(hostname, hosts = []) {
11
+ const host = String(hostname || "").toLowerCase();
12
+ return hosts.some((pattern) => {
13
+ const bare = String(pattern).toLowerCase().replace(/^\*\./, "");
14
+ return Boolean(bare) && (host === bare || host.endsWith(`.${bare}`));
15
+ });
16
+ }
17
+
18
+ // Correct registrable domains need public-suffix data, which we do not ship.
19
+ // This approximation is only used to decide which requests are worth REPORTING
20
+ // on, never to decide where anything is sent.
21
+ const SECOND_LEVEL_SUFFIXES = new Set(["co", "com", "org", "net", "gov", "ac", "edu"]);
22
+
23
+ function siteOf(hostname) {
24
+ const host = String(hostname || "").toLowerCase();
25
+ if (!host.includes(".") || /^[\d.]+$/.test(host) || host.includes(":")) return host;
26
+ const labels = host.split(".");
27
+ const take = SECOND_LEVEL_SUFFIXES.has(labels[labels.length - 2]) ? 3 : 2;
28
+ return labels.slice(-take).join(".");
29
+ }
30
+
31
+ export function isSameSite(url, pageUrl, hosts = []) {
32
+ try {
33
+ const target = new URL(url);
34
+ const page = new URL(pageUrl);
35
+ if (!/^https?:$/.test(target.protocol)) return false;
36
+ return (
37
+ target.hostname === page.hostname ||
38
+ hostMatchesAny(target.hostname, hosts) ||
39
+ siteOf(target.hostname) === siteOf(page.hostname)
40
+ );
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ // ------------------------------------------------------------ navigation
47
+
48
+ export const NAV_SCOPE_SELECTOR =
49
+ "nav, aside, header, [role=navigation], [role=menubar], [role=tablist]";
50
+ export const NAV_ITEM_SELECTOR =
51
+ "a[href], button, [role=menuitem], [role=tab], [role=link]";
52
+
53
+ /** `--goto` accepts a path on the page's own origin and nothing else. */
54
+ export function sameOriginPath(input, pageUrl) {
55
+ const raw = String(input ?? "").trim();
56
+ if (!raw.startsWith("/") || raw.startsWith("//") || raw.includes("\\")) {
57
+ throw new Error(
58
+ `--goto takes a path on the app's own origin, starting with "/" (got "${raw}").`,
59
+ );
60
+ }
61
+ const origin = new URL(pageUrl).origin;
62
+ const resolved = new URL(raw, origin);
63
+ if (resolved.origin !== origin) {
64
+ throw new Error(`--goto must stay on ${origin}.`);
65
+ }
66
+ const refused = pathRefusal(resolved.pathname);
67
+ if (refused) throw new Error(`--goto: ${refused}`);
68
+ return { url: resolved.href, path: resolved.pathname + resolved.search + resolved.hash };
69
+ }
70
+
71
+ /**
72
+ * Exact label first, then case-insensitive, then contains. Self-contained.
73
+ * Returns the index into `items`, or -1.
74
+ */
75
+ export function pickByLabel(items, want) {
76
+ const wanted = String(want || "").replace(/\s+/g, " ").trim();
77
+ if (!wanted) return -1;
78
+ const lower = wanted.toLowerCase();
79
+ let index = items.findIndex((item) => item.label === wanted);
80
+ if (index < 0) index = items.findIndex((item) => item.label.toLowerCase() === lower);
81
+ if (index < 0) index = items.findIndex((item) => item.label.toLowerCase().includes(lower));
82
+ return index;
83
+ }
84
+
85
+ /**
86
+ * Why a same-origin PATH must not be opened, or null when it may. A GET is not
87
+ * always harmless: `/logout` ends the session and some apps delete, approve or
88
+ * send from a link. Paths that only open a form (`/new`, `/edit`) are fine.
89
+ * Self-contained (injected into the page).
90
+ */
91
+ export function pathRefusal(pathAndQuery) {
92
+ const pathOnly = String(pathAndQuery || "").split(/[?#]/)[0].toLowerCase();
93
+ const risky =
94
+ /(^|[\/._-])(log[-_]?out|sign[-_]?out|log[-_]?off|sign[-_]?off|delete|destroy|remove|purge|erase|revoke|regenerate|rotate|deactivate|disable|enable|activate|suspend|terminate|cancel|confirm|approve|reject|accept|decline|unsubscribe|subscribe|archive|restore|pay|charge|refund|run|execute|start|stop|pause|resume|send|resend|publish|unpublish|merge|transfer|sync|disconnect|connect|install|uninstall|impersonate|export|download)([\/._-]|$)/;
95
+ const hit = pathOnly.match(risky);
96
+ return hit
97
+ ? `"${pathOnly}" looks like it does something ("${hit[2]}"), not like a screen; observe only opens screens.`
98
+ : null;
99
+ }
100
+
101
+ /**
102
+ * Why observe will not click this item, or null when it may. An ALLOW-list:
103
+ * observe clicks only what is provably navigation -
104
+ * - a link with a real same-origin path (a GET to a screen), its path screened;
105
+ * - a tab;
106
+ * - a control that only expands a menu (aria-expanded / aria-haspopup / aria-controls).
107
+ * A plain button can do anything, so it is refused whatever its label says -
108
+ * a deny-list of verbs let "+ New deal", "Bulk delete", "Run payroll" and
109
+ * "Regenerate API key" through. Self-contained (injected into the page).
110
+ */
111
+ export function clickRefusal(item) {
112
+ const label = String(item?.label || "");
113
+ const text = label.replace(/^[^\p{L}\p{N}]+/u, "");
114
+ if (/\b(log|sign)[\s-]?(out|off)\b/i.test(text)) {
115
+ return `"${label}" ends the session; observe never clicks it.`;
116
+ }
117
+ if (item?.submitsForm) return `"${label}" submits a form; observe only opens screens.`;
118
+ if (item?.download) return `"${label}" downloads a file; observe only opens screens.`;
119
+
120
+ const changes =
121
+ /\b(delete|remove|destroy|erase|purge|empty|clear|reset|revoke|regenerate|rotate|deactivate|disable|enable|activate|suspend|terminate|cancel|confirm|approve|reject|decline|accept|submit|send|resend|save|create|add(?![- ]?ons)|new|invite|pay|buy|purchase|charge|refund|upgrade|downgrade|subscribe|unsubscribe|publish|unpublish|archive|restore|import|export|upload|run|execute|start|stop|pause|resume|merge|transfer|assign|mark|bulk|sync|connect|disconnect|install|uninstall)\b/i;
122
+
123
+ if (item?.kind === "link" && item?.path) {
124
+ // Same-origin link: following it is a GET. Screen where it goes.
125
+ const pathOnly = String(item.path).split(/[?#]/)[0].toLowerCase();
126
+ const risky =
127
+ /(^|[\/._-])(log[-_]?out|sign[-_]?out|log[-_]?off|sign[-_]?off|delete|destroy|remove|purge|erase|revoke|regenerate|rotate|deactivate|disable|enable|activate|suspend|terminate|cancel|confirm|approve|reject|accept|decline|unsubscribe|subscribe|archive|restore|pay|charge|refund|run|execute|start|stop|pause|resume|send|resend|publish|unpublish|merge|transfer|sync|disconnect|connect|install|uninstall|impersonate|export|download)([\/._-]|$)/;
128
+ const hit = pathOnly.match(risky);
129
+ return hit
130
+ ? `"${label}" goes to ${pathOnly}, which looks like it does something ("${hit[2]}"); observe only opens screens.`
131
+ : null;
132
+ }
133
+ if (item?.kind === "tab" || item?.expands) {
134
+ return changes.test(text)
135
+ ? `"${label}" reads like an action, not a screen; observe only opens screens.`
136
+ : null;
137
+ }
138
+ return `"${label}" is a ${item?.kind || "button"} with no link behind it, and a button can do anything. observe clicks links, tabs and menu openers only - find the screen's path with \`menu\` and use --goto.`;
139
+ }
140
+
141
+ // ------------------------------------------------------------ JSON shapes
142
+
143
+ /**
144
+ * The shape of a JSON document: key NAMES, where the rows are, and the field
145
+ * NAMES and value TYPES of a row. No value ever leaves: there is no sample
146
+ * option, because a truncated row still prints real emails, ids and keys.
147
+ * Self-contained (injected into the page).
148
+ */
149
+ export function summarizeJson(value, options = {}) {
150
+ const maxDepth = options.maxDepth ?? 3;
151
+ const isObject = (node) => node !== null && typeof node === "object" && !Array.isArray(node);
152
+ // A map keyed by email or id would leak data through its key names.
153
+ const safeKey = (key) =>
154
+ key.includes("@") || key.length > 40 || /^\d{4,}$/.test(key) || /^[0-9a-f-]{24,}$/i.test(key)
155
+ ? "(id-like key)"
156
+ : key;
157
+ const keyNames = (node) => [...new Set(Object.keys(node).slice(0, 40).map(safeKey))];
158
+
159
+ let best = null;
160
+ const better = (candidate) => {
161
+ if (!best) return true;
162
+ if (candidate.objects !== best.objects) return candidate.objects;
163
+ return candidate.count > best.count;
164
+ };
165
+ const visit = (node, trail) => {
166
+ if (Array.isArray(node)) {
167
+ const candidate = {
168
+ path: trail.length ? trail.join(".") : "(root)",
169
+ count: node.length,
170
+ objects: isObject(node[0]),
171
+ first: node[0],
172
+ };
173
+ if (better(candidate)) best = candidate;
174
+ return;
175
+ }
176
+ if (!isObject(node) || trail.length >= maxDepth) return;
177
+ for (const key of Object.keys(node).slice(0, 200)) visit(node[key], [...trail, safeKey(key)]);
178
+ };
179
+ visit(value, []);
180
+
181
+ // What KIND of value a field holds - never the value. Enough to write a
182
+ // handler (is it a date? cents or a string?) without a row leaving the page.
183
+ const typeOf = (node) => {
184
+ if (node === null) return "null";
185
+ if (Array.isArray(node)) return "array";
186
+ if (typeof node !== "string") return typeof node;
187
+ if (/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2})?/.test(node)) return "string:date";
188
+ if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(node)) return "string:email";
189
+ if (/^https?:\/\//i.test(node)) return "string:url";
190
+ if (/^\d+$/.test(node)) return "string:digits";
191
+ if (/^[0-9a-f-]{16,}$/i.test(node)) return "string:id";
192
+ return "string";
193
+ };
194
+ const fieldTypes = (node) => {
195
+ const out = {};
196
+ for (const key of Object.keys(node).slice(0, 40)) out[safeKey(key)] = typeOf(node[key]);
197
+ return out;
198
+ };
199
+
200
+ const summary = {
201
+ rootType: Array.isArray(value) ? "array" : isObject(value) ? "object" : typeof value,
202
+ topLevelKeys: isObject(value) ? keyNames(value) : [],
203
+ rows: best ? { path: best.path, count: best.count } : null,
204
+ fieldNames: best && best.objects ? keyNames(best.first) : [],
205
+ // No rows (a "me" or settings document): the document's own fields.
206
+ fieldTypes: best && best.objects ? fieldTypes(best.first) : isObject(value) ? fieldTypes(value) : {},
207
+ };
208
+ return summary;
209
+ }
210
+
211
+ // ---------------------------------------------------------------- screen
212
+
213
+ const NOISE_URL =
214
+ /(analytics|telemetry|experiment|metrics|beacon|segment|sentry|datadog|amplitude|mixpanel|feature_?flags?|pixel|\/collect\b|intercom|hotjar|fullstory|launchdarkly|\/track(ing)?\b|\/logs?\b)/i;
215
+
216
+ export function isNoiseUrl(url) {
217
+ return NOISE_URL.test(String(url || ""));
218
+ }
219
+
220
+ /** Only successful JSON answers to GET XHR/fetch requests can be a data source. */
221
+ export function isCandidateRequest(request) {
222
+ return (
223
+ String(request?.method || "").toUpperCase() === "GET" &&
224
+ /^(xhr|fetch)$/i.test(request?.type || "") &&
225
+ request?.status >= 200 &&
226
+ request?.status < 300 &&
227
+ /json/i.test(request?.mimeType || "") &&
228
+ !isNoiseUrl(request?.url)
229
+ );
230
+ }
231
+
232
+ export function parseMatchWords(input) {
233
+ return String(input || "")
234
+ .toLowerCase()
235
+ .split(/[,\s]+/)
236
+ .map((word) => word.trim())
237
+ .filter(Boolean);
238
+ }
239
+
240
+ /**
241
+ * The tab's own URL, fit to print: after sign-in it can carry an OAuth code, a
242
+ * reset token or an email address. Route-like parts stay, because on a
243
+ * single-page app `?view=deals` or `#/deals` IS the screen.
244
+ */
245
+ export function printableUrl(url) {
246
+ let parsed;
247
+ try {
248
+ parsed = new URL(url);
249
+ } catch {
250
+ return String(url || "").split(/[?#]/)[0];
251
+ }
252
+ const secretName = /(code|token|key|secret|pass|auth|session|sig|state|nonce|email|ticket|otp|jwt|sso)/i;
253
+ for (const [name, value] of [...parsed.searchParams.entries()]) {
254
+ if (secretName.test(name) || value.length > 40 || value.includes("@")) {
255
+ parsed.searchParams.set(name, "(hidden)");
256
+ }
257
+ }
258
+ if (parsed.hash && !/^#!?\/[^=&]*$/.test(parsed.hash)) parsed.hash = "";
259
+ return parsed.href.replace(/%28hidden%29/g, "(hidden)");
260
+ }
261
+
262
+ /** Same-origin URLs read better as a path; the query is part of the request. */
263
+ export function displayUrl(url, origin, { query = true } = {}) {
264
+ try {
265
+ // Same hiding as printableUrl: a request's query string can carry a token,
266
+ // an email or a search the customer typed.
267
+ const parsed = new URL(printableUrl(url));
268
+ const tail = parsed.pathname + (query ? parsed.search.replace(/%28hidden%29/g, "(hidden)") : "");
269
+ const shown = parsed.origin === origin ? tail : parsed.origin + tail;
270
+ return shown.length > 300 ? `${shown.slice(0, 300)}...` : shown;
271
+ } catch {
272
+ return String(url);
273
+ }
274
+ }
275
+
276
+ /** A word in the URL is worth more than the same word among the key names. */
277
+ export function rankCandidates(candidates, words = [], max = 8) {
278
+ const scored = candidates.map((candidate, order) => {
279
+ const url = String(candidate.path || "").toLowerCase();
280
+ const names = [
281
+ ...(candidate.topLevelKeys || []),
282
+ candidate.rows?.path || "",
283
+ ...(candidate.fieldNames || []),
284
+ ]
285
+ .join(" ")
286
+ .toLowerCase();
287
+ const score = words.reduce(
288
+ (total, word) => total + (url.includes(word) ? 3 : 0) + (names.includes(word) ? 1 : 0),
289
+ 0,
290
+ );
291
+ return { candidate, score, order };
292
+ });
293
+ scored.sort(
294
+ (a, b) =>
295
+ b.score - a.score ||
296
+ (b.candidate.rows?.count || 0) - (a.candidate.rows?.count || 0) ||
297
+ a.order - b.order,
298
+ );
299
+ return scored.slice(0, max).map(({ candidate, score }) => ({ ...candidate, score }));
300
+ }
301
+
302
+ // ------------------------------------------------------------------ read
303
+
304
+ const WRITE_FLAGS = ["method", "data", "body", "header", "headers", "json", "post", "put", "delete"];
305
+
306
+ /**
307
+ * `observe read` is GET only. There is deliberately no way to pick a method,
308
+ * add a header or send a body: anything else belongs in an action handler the
309
+ * customer has approved, not in a discovery tool.
310
+ */
311
+ export function refuseWriteFlags(flags = {}) {
312
+ const asked = WRITE_FLAGS.filter((name) => name in flags);
313
+ if (asked.length) {
314
+ throw new Error(
315
+ `observe read is GET only and never sends a body (refused: ${asked
316
+ .map((name) => `--${name}`)
317
+ .join(", ")}).`,
318
+ );
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Where `read` may send a request - with the customer's session attached.
324
+ * Only: the page's own origin, a host configured for this project, or an
325
+ * origin this page was SEEN calling (recorded by `screen` / `auth`). The
326
+ * "same site" approximation above is never enough on its own: on a shared
327
+ * suffix (herokuapp.com, vercel.app, github.io) it accepts a stranger's site.
328
+ */
329
+ export function readDestinationAllowed(url, pageUrl, hosts = [], observedOrigins = []) {
330
+ try {
331
+ const target = new URL(url);
332
+ const page = new URL(pageUrl);
333
+ if (!/^https?:$/.test(target.protocol)) return false;
334
+ return (
335
+ target.origin === page.origin ||
336
+ hostMatchesAny(target.hostname, hosts) ||
337
+ observedOrigins.includes(target.origin)
338
+ );
339
+ } catch {
340
+ return false;
341
+ }
342
+ }
343
+
344
+ export function planRead({ target, flags = {}, extra = [], pageUrl, hosts = [], observedOrigins = [] }) {
345
+ refuseWriteFlags(flags);
346
+ if (extra.length) throw new Error(`unexpected argument '${extra[0]}'`);
347
+ const raw = String(target ?? "").trim();
348
+ if (!raw) throw new Error("usage: foldspace observe read <path-or-url>");
349
+ if (raw.startsWith("//") || raw.includes("\\")) {
350
+ throw new Error(`"${raw}" is not a path or an http(s) URL.`);
351
+ }
352
+ let resolved;
353
+ try {
354
+ resolved = new URL(raw, pageUrl);
355
+ } catch {
356
+ throw new Error(`"${raw}" is not a path or an http(s) URL.`);
357
+ }
358
+ if (!/^https?:$/.test(resolved.protocol)) {
359
+ throw new Error(`"${raw}" is not a path or an http(s) URL.`);
360
+ }
361
+ if (!readDestinationAllowed(resolved.href, pageUrl, hosts, observedOrigins)) {
362
+ throw new Error(
363
+ `${resolved.origin} is not part of the app that is open (${new URL(pageUrl).origin}), and this page has not been seen calling it. ` +
364
+ "observe read only goes to the app's own origin, a host configured for this project, or an origin `observe screen` saw the page call.",
365
+ );
366
+ }
367
+ return { method: "GET", url: resolved.href };
368
+ }
369
+
370
+ export function readHint(status) {
371
+ return status === 401 || status === 403
372
+ ? "cookies alone were refused - run: foldspace observe auth"
373
+ : null;
374
+ }
375
+
376
+ // ------------------------------------------------------------------ auth
377
+
378
+ const STANDARD_HEADER = [
379
+ /^:/, // HTTP/2 pseudo headers
380
+ /^accept($|-)/,
381
+ /^content-/,
382
+ /^sec-/,
383
+ /^if-/,
384
+ /^(host|origin|referer|user-agent|cookie|connection|cache-control|pragma|priority|dnt)$/,
385
+ /^(range|te|upgrade-insecure-requests|purpose|x-client-data)$/,
386
+ ];
387
+
388
+ /** True for headers the browser adds by itself; x-requested-with is NOT one. */
389
+ export function isStandardHeader(name) {
390
+ const lower = String(name || "").toLowerCase();
391
+ return STANDARD_HEADER.some((pattern) => pattern.test(lower));
392
+ }
393
+
394
+ const MIN_SECRET_LENGTH = 8; // "1", "true", "web" would match everywhere
395
+
396
+ function splitScheme(value) {
397
+ const match = /^(Bearer|Token|Basic|JWT)\s+(.+)$/i.exec(String(value));
398
+ return match ? { scheme: match[1], secret: match[2] } : { scheme: null, secret: String(value) };
399
+ }
400
+
401
+ function matchKind(secret, candidate) {
402
+ if (typeof candidate !== "string" || !candidate) return null;
403
+ if (candidate === secret) return "exact";
404
+ let decoded = candidate;
405
+ try {
406
+ decoded = decodeURIComponent(candidate);
407
+ } catch {
408
+ // not URL-encoded
409
+ }
410
+ if (decoded === secret) return "exact";
411
+ if (candidate.includes(secret) || decoded.includes(secret)) return "contains";
412
+ if (candidate.length >= MIN_SECRET_LENGTH && secret.includes(candidate)) return "part";
413
+ return null;
414
+ }
415
+
416
+ // Walk string leaves; a leaf that is itself JSON (redux-persist and friends) is
417
+ // walked too, so the reported path goes all the way to the token.
418
+ function walkLeaves(node, trail, visit, depth = 0) {
419
+ if (depth > 8) return;
420
+ if (typeof node === "string") {
421
+ const text = node.trim();
422
+ if (depth < 8 && /^[[{]/.test(text)) {
423
+ try {
424
+ walkLeaves(JSON.parse(text), trail, visit, depth + 1);
425
+ return;
426
+ } catch {
427
+ // an ordinary string that happens to start with a bracket
428
+ }
429
+ }
430
+ visit(node, trail);
431
+ return;
432
+ }
433
+ if (typeof node === "number") {
434
+ visit(String(node), trail);
435
+ return;
436
+ }
437
+ if (Array.isArray(node)) {
438
+ node.slice(0, 50).forEach((entry, index) => walkLeaves(entry, [...trail, String(index)], visit, depth + 1));
439
+ return;
440
+ }
441
+ if (node && typeof node === "object") {
442
+ for (const key of Object.keys(node).slice(0, 200)) {
443
+ walkLeaves(node[key], [...trail, key], visit, depth + 1);
444
+ }
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Where a header's value comes from. `sources` holds real values; the result
450
+ * holds names, key paths and lengths ONLY.
451
+ *
452
+ * sources = { cookies: [{name, value, httpOnly}], localStorage: {k: v},
453
+ * sessionStorage: {k: v}, metas: [{name, content}],
454
+ * responses: [{request: "GET /api/me", json}] }
455
+ */
456
+ export function findValueSources(headerValue, sources = {}) {
457
+ // A storage key or a JSON path can itself be data (a map keyed by email).
458
+ const safeName = (name) => {
459
+ const key = String(name);
460
+ return key.includes("@") || key.length > 40 || /^\d{4,}$/.test(key) || /^[0-9a-f-]{24,}$/i.test(key)
461
+ ? "(id-like key)"
462
+ : key;
463
+ };
464
+ const safeTrail = (trail) => trail.map(safeName).join(".");
465
+ const { secret } = splitScheme(headerValue);
466
+ if (secret.length < MIN_SECRET_LENGTH) return [];
467
+ const found = [];
468
+ const seen = new Set();
469
+ const add = (entry) => {
470
+ const id = JSON.stringify([entry.kind, entry.name, entry.key, entry.request, entry.jsonPath]);
471
+ if (seen.has(id)) return;
472
+ seen.add(id);
473
+ found.push(entry);
474
+ };
475
+
476
+ for (const cookie of sources.cookies || []) {
477
+ const match = matchKind(secret, cookie.value);
478
+ if (match) {
479
+ add({ kind: "cookie", name: cookie.name, httpOnly: Boolean(cookie.httpOnly), match });
480
+ }
481
+ }
482
+ for (const kind of ["localStorage", "sessionStorage"]) {
483
+ for (const [key, stored] of Object.entries(sources[kind] || {})) {
484
+ walkLeaves(stored, [], (leaf, trail) => {
485
+ const match = matchKind(secret, leaf);
486
+ if (match) add({ kind, key: safeName(key), jsonPath: trail.length ? safeTrail(trail) : null, match });
487
+ });
488
+ }
489
+ }
490
+ for (const meta of sources.metas || []) {
491
+ const match = matchKind(secret, meta.content);
492
+ if (match) add({ kind: "meta", name: meta.name, match });
493
+ }
494
+ for (const response of sources.responses || []) {
495
+ walkLeaves(response.json, [], (leaf, trail) => {
496
+ const match = matchKind(secret, leaf);
497
+ if (match) {
498
+ add({ kind: "response", request: response.request, jsonPath: safeTrail(trail) || "(root)", match });
499
+ }
500
+ });
501
+ }
502
+ const rank = { exact: 0, contains: 1, part: 2 };
503
+ return found.sort((a, b) => rank[a.match] - rank[b.match]).slice(0, 6);
504
+ }
505
+
506
+ /**
507
+ * requests = [{ method, path, headers: {name: value} }] -> one entry per
508
+ * non-standard header, with NO values.
509
+ */
510
+ export function analyzeAuthHeaders(requests, sources = {}) {
511
+ const byName = new Map();
512
+ for (const request of requests) {
513
+ for (const [name, value] of Object.entries(request.headers || {})) {
514
+ if (isStandardHeader(name)) continue;
515
+ const id = name.toLowerCase();
516
+ if (!byName.has(id)) {
517
+ byName.set(id, { name, values: new Set(), methods: new Set(), paths: new Set(), requests: 0 });
518
+ }
519
+ const entry = byName.get(id);
520
+ entry.values.add(String(value));
521
+ entry.methods.add(String(request.method || "GET").toUpperCase());
522
+ entry.paths.add(request.path);
523
+ entry.requests += 1;
524
+ }
525
+ }
526
+ return [...byName.values()].map((entry) => {
527
+ const first = [...entry.values][0];
528
+ const { scheme, secret } = splitScheme(first);
529
+ const sources_ = [];
530
+ for (const value of [...entry.values].slice(0, 3)) {
531
+ for (const source of findValueSources(value, sources)) {
532
+ if (!sources_.some((known) => JSON.stringify(known) === JSON.stringify(source))) {
533
+ sources_.push(source);
534
+ }
535
+ }
536
+ }
537
+ return {
538
+ name: entry.name,
539
+ scheme,
540
+ valueLength: secret.length,
541
+ sameOnEveryRequest: entry.values.size === 1,
542
+ seenOn: {
543
+ requests: entry.requests,
544
+ methods: [...entry.methods],
545
+ paths: [...entry.paths].slice(0, 3),
546
+ },
547
+ sources: sources_,
548
+ };
549
+ });
550
+ }
551
+
552
+ export function describeSource(source) {
553
+ if (!source) return "a value not found in cookies, storage, meta tags or responses (built in page memory)";
554
+ const trail = source.jsonPath ? ` -> ${source.jsonPath}` : "";
555
+ const part = source.match === "exact" ? "" : source.match === "contains" ? " (inside it)" : " (part of it)";
556
+ if (source.kind === "cookie") {
557
+ return `cookie "${source.name}"${source.httpOnly ? " (HttpOnly: page code cannot read it)" : ""}${part}`;
558
+ }
559
+ if (source.kind === "meta") return `<meta name="${source.name}">${part}`;
560
+ if (source.kind === "response") return `${source.request} response${trail}${part}`;
561
+ return `${source.kind}["${source.key}"]${trail}${part}`;
562
+ }
563
+
564
+ export function buildAuthSummary({ headers = [], cookieOnlyGet = null } = {}) {
565
+ const recipe = headers
566
+ .map((header) => {
567
+ const prefix = header.scheme ? `"${header.scheme} " + ` : "";
568
+ return `${header.name} = ${prefix}${describeSource(header.sources[0])}`;
569
+ })
570
+ .join("; ");
571
+ const probe = cookieOnlyGet ? `GET ${cookieOnlyGet.path} -> ${cookieOnlyGet.status}` : null;
572
+ if (!headers.length) {
573
+ return cookieOnlyGet?.ok
574
+ ? `The app sends no custom headers; reads work with cookies alone (${probe}).`
575
+ : `The app sent no custom headers in this window${probe ? `, yet cookies alone got ${probe}` : ""}. Open a screen that loads data and run auth again.`;
576
+ }
577
+ if (cookieOnlyGet?.ok) {
578
+ return `Reads work with cookies alone (${probe}). The app also sends ${recipe}; expect writes to need that.`;
579
+ }
580
+ if (cookieOnlyGet) {
581
+ return `Cookies alone were refused (${probe}). Requests need ${recipe}.`;
582
+ }
583
+ return `The app sends ${recipe}. No GET could be replayed to check whether cookies alone are enough.`;
584
+ }
585
+
586
+ // ----------------------------------------------------------------- login
587
+
588
+ // Anchored to the start of a path segment so /authors or /design-sso-guide do
589
+ // not read as a login page; `auth(?!or)` keeps /auth, /auth0, /authorize.
590
+ const LOGIN_PATH = /(^|\/)(log[-_]?in|sign[-_]?in|sso|oauth|auth(?!or)|session\/new)/i;
591
+
592
+ export function isLoginPath(pathAndHash) {
593
+ return LOGIN_PATH.test(String(pathAndHash || ""));
594
+ }
595
+
596
+ /** One poll of `wait-login`. The caller wants two in a row. */
597
+ export function looksLoggedIn({ url, hasPasswordField = false, hosts = [] }) {
598
+ let parsed;
599
+ try {
600
+ parsed = new URL(url);
601
+ } catch {
602
+ return false;
603
+ }
604
+ if (!/^https?:$/.test(parsed.protocol)) return false;
605
+ if (!hostMatchesAny(parsed.hostname, hosts)) return false;
606
+ if (hasPasswordField) return false;
607
+ return !isLoginPath(parsed.pathname + parsed.hash);
608
+ }
609
+
610
+ // ---------------------------------------------------------------- styles
611
+
612
+ /** Computed colours come back as rgb()/rgba(); anything else passes through. */
613
+ export function toHex(cssColor) {
614
+ const text = String(cssColor || "").trim();
615
+ const match = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i.exec(text);
616
+ if (!match) return text || null;
617
+ const channel = (value) =>
618
+ Math.max(0, Math.min(255, Math.round(Number(value))))
619
+ .toString(16)
620
+ .padStart(2, "0");
621
+ let alpha = 1;
622
+ if (match[4] !== undefined) {
623
+ alpha = match[4].endsWith("%") ? Number(match[4].slice(0, -1)) / 100 : Number(match[4]);
624
+ }
625
+ const hex = `#${channel(match[1])}${channel(match[2])}${channel(match[3])}`;
626
+ return alpha >= 1 ? hex : `${hex}${channel(alpha * 255)}`;
627
+ }
@@ -0,0 +1,42 @@
1
+ // One line per thing the human experienced, so a run can be scored without
2
+ // reconstructing it from a transcript. Local only; never page content.
3
+ //
4
+ // .foldspace-dev/events.jsonl {"t": 1789940272818, "event": "login_seen", ...}
5
+
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+
9
+ export const SESSION_EVENTS = Object.freeze([
10
+ "chrome_opened",
11
+ "login_seen",
12
+ "agent_visible",
13
+ "observe",
14
+ "ask",
15
+ "run",
16
+ ]);
17
+
18
+ export function eventsPath(projectDir) {
19
+ return path.join(projectDir, ".foldspace-dev", "events.jsonl");
20
+ }
21
+
22
+ export function recordEvent(projectDir, event, data = {}) {
23
+ try {
24
+ const file = eventsPath(projectDir);
25
+ fs.mkdirSync(path.dirname(file), { recursive: true });
26
+ fs.appendFileSync(file, `${JSON.stringify({ t: Date.now(), event, ...data })}\n`);
27
+ } catch {
28
+ // Scoring is a convenience. It must never fail a command.
29
+ }
30
+ }
31
+
32
+ export function readEvents(projectDir) {
33
+ try {
34
+ return fs
35
+ .readFileSync(eventsPath(projectDir), "utf8")
36
+ .split("\n")
37
+ .filter(Boolean)
38
+ .map((line) => JSON.parse(line));
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
@@ -54,8 +54,8 @@ npm run attach:daemon # same, but return after inspect_registration
54
54
  ```
55
55
 
56
56
  Run `inject` before `attach`. Sign in to the product in the Chrome window that
57
- `inject` opens. Observe the real product workflow in that window **before**
58
- attach if you need customer API evidence; `attach` owns the debug port.
57
+ `inject` opens. To see how the product loads its data, use `npx foldspace
58
+ observe` - it reads that same window, read-only, and works while `attach` runs.
59
59
  `inject` does not generate an application extension; `attach` loads
60
60
  `dist/index.js` directly through CDP. An empty local registry is valid,
61
61
  so you can attach before implementing handlers. Coding agents should use