#!/usr/bin/env node // Record a HAR of an interactive login, for learning a vendor API shape. // // node tools/har/record.mjs [outputHarPath] // // Opens a real browser window. You log in and browse by hand. The script polls // for a sentinel file and saves the HAR when it appears: // // touch # when you are done browsing // // The raw HAR contains your session cookie and bearer token in clear text, so // the script writes it 0600 and immediately produces a redacted twin that is // safe to read, diff and quote from. Both live outside the repository — never // commit either one. // // What to look at while browsing: the zone/domain list request, and the request // that fetches one zone's records. That pair is enough to learn the read API. // Do NOT save any DNS change you make while recording: the gateway applies it // publicly within seconds. import { chromium } from "playwright" import { writeFileSync, existsSync, chmodSync, readFileSync } from "node:fs" import { join } from "node:path" const URL_TO_OPEN = process.argv[2] ?? "https://cloud.schlundtech.com/portfolio/domains/" const OUT = process.argv[3] ?? "/var/folders/cz/75zr419d1fq1ptvqp1bwgmw80000gp/T/opencode/har/schlundtech.har" const SENTINEL = `${OUT}.done` const SENSITIVE_HEADERS = new Set([ "cookie", "set-cookie", "authorization", "proxy-authorization", "x-api-key", "x-auth-token", "x-csrf-token", "x-xsrf-token", ]) const SENSITIVE_KEYS = /^(?:.*_)?(?:password|passwd|secret|token|access_token|refresh_token|session|sessionid|csrf|xsrf|apikey|api_key|auth)$/i // Anything that looks like a long opaque bearer/JWT blob. const JWT = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\b/g const redactValue = (v) => (typeof v === "string" ? v.replace(JWT, "") : v) function scrubEntry(entry) { for (const h of entry.request?.headers ?? []) { if (SENSITIVE_HEADERS.has(h.name.toLowerCase())) h.value = "" } for (const h of entry.response?.headers ?? []) { if (SENSITIVE_HEADERS.has(h.name.toLowerCase())) h.value = "" } const url = entry.request?.url if (url) { entry.request.url = url.replace(/([?&](?:token|access_token|session|sessionid|auth|key)=)[^&]*/gi, "$1") } for (const side of [entry.request, entry.response]) { const text = side?.postData?.text if (typeof text === "string") { side.postData.text = text.replace(JWT, "") try { const json = JSON.parse(text) const walk = (node) => { if (Array.isArray(node)) return node.forEach(walk) if (node && typeof node === "object") { for (const [k, val] of Object.entries(node)) { if (SENSITIVE_KEYS.test(k)) node[k] = "" else walk(val) } } } walk(json) side.postData.text = JSON.stringify(json, null, 2) } catch { /* not JSON — the JWT scrub above already ran */ } } if (Array.isArray(side?.postData?.params)) { for (const p of side.postData.params) if (SENSITIVE_KEYS.test(p.name)) p.value = "" } } } console.log(`opening ${URL_TO_OPEN}`) console.log(`har ${OUT}`) console.log(`done touch ${SENTINEL}`) console.log("browsing…") // Prefer the real Chrome the user already has; fall back to Playwright's // bundled build if it is missing (or out of date relative to this Playwright). async function launch() { for (const opts of [{ channel: "chrome" }, {}]) { try { return await chromium.launch({ headless: false, ...opts }) } catch (err) { if (opts.channel) console.error(` ${opts.channel}: ${String(err).split("\n")[0]}`) } } throw new Error("no chromium available — run: npx playwright install chromium") } const browser = await launch() const context = await browser.newContext({ recordHar: { path: OUT, content: "embed" }, ignoreHTTPSErrors: false, }) const page = await context.newPage() await page.goto(URL_TO_OPEN, { waitUntil: "domcontentloaded" }) const deadline = Date.now() + 30 * 60 * 1000 while (!existsSync(SENTINEL)) { if (Date.now() > deadline) { console.error("timeout after 30 min — closing") break } await new Promise((r) => setTimeout(r, 1000)) } await context.close() await browser.close() if (!existsSync(OUT)) { console.error("no HAR written") process.exit(1) } chmodSync(OUT, 0o600) const har = JSON.parse(readFileSync(OUT, "utf8")) har.log.entries.forEach(scrubEntry) const redacted = OUT.replace(/\.har$/, ".redacted.har") writeFileSync(redacted, JSON.stringify(har, null, 2)) chmodSync(redacted, 0o600) console.log(`\nsaved ${OUT} (raw, 0600)`) console.log(`saved ${redacted} (redacted — this is the one to read)`) console.log(`entries ${har.log.entries.length}`)