#!/usr/bin/env node import fs from "node:fs"; const DEFAULT_LOGIN_URL = "https://boiler.saas-ctrl.com/login"; const DEFAULT_COOKIE_NAME = "RJ_session"; function fail(message) { console.error(`FAIL lighthouse cookie rotation: ${message}`); process.exit(1); } function requiredEnv(name) { const value = process.env[name]; if (typeof value !== "string" || value.trim().length === 0) { fail(`missing required env var ${name}`); } return value; } function optionalEnv(name) { const value = process.env[name]; return typeof value === "string" && value.trim().length > 0 ? value : null; } function assertSafeEnvValue(name, value) { if (/[\r\n]/.test(value)) { fail(`${name} must not contain newline characters`); } } function splitSetCookieHeader(headerValue) { if (!headerValue) return []; const cookies = []; let start = 0; for (let index = 0; index < headerValue.length; index += 1) { if (headerValue[index] !== ",") continue; const rest = headerValue.slice(index + 1); if (/^\s*[^=;,\s]+=/.test(rest)) { cookies.push(headerValue.slice(start, index).trim()); start = index + 1; } } cookies.push(headerValue.slice(start).trim()); return cookies.filter(Boolean); } function getSetCookieHeaders(headers) { if (typeof headers.getSetCookie === "function") { return headers.getSetCookie(); } return splitSetCookieHeader(headers.get("set-cookie")); } function parseCookiePair(setCookieHeader) { const [pair] = setCookieHeader.split(";"); const separator = pair.indexOf("="); if (separator <= 0) return null; return { name: pair.slice(0, separator).trim(), value: pair.slice(separator + 1).trim(), }; } function collectCookies(headers, jar) { for (const header of getSetCookieHeaders(headers)) { const parsed = parseCookiePair(header); if (parsed) { jar.set(parsed.name, parsed.value); } } } function cookieHeader(jar) { return [...jar.entries()].map(([name, value]) => `${name}=${value}`).join("; "); } function decodeHtmlAttribute(value) { return value .replaceAll(""", "\"") .replaceAll(""", "\"") .replaceAll(""", "\"") .replaceAll("'", "'") .replaceAll("'", "'") .replaceAll("'", "'") .replaceAll("&", "&"); } function parseAttributes(tag) { const attributes = new Map(); const attributePattern = /([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g; let match; while ((match = attributePattern.exec(tag))) { attributes.set(match[1].toLowerCase(), decodeHtmlAttribute(match[2] ?? match[3] ?? match[4] ?? "")); } return attributes; } function extractCsrfToken(html) { const inputPattern = /]*>/gi; let match; while ((match = inputPattern.exec(html))) { const attributes = parseAttributes(match[0]); if (attributes.get("name") === "csrf" && attributes.has("value")) { return attributes.get("value"); } } return null; } function appendGitHubEnv(values) { const githubEnv = process.env.GITHUB_ENV; if (!githubEnv) return; const lines = []; for (const [name, value] of Object.entries(values)) { if (typeof value !== "string" || value.length === 0) continue; assertSafeEnvValue(name, value); lines.push(`${name}=${value}`); } if (lines.length > 0) { fs.appendFileSync(githubEnv, `${lines.join("\n")}\n`, { mode: 0o600 }); } } function writeCookieOutput(cookieValue) { const outputPath = process.env.LIGHTHOUSE_SESSION_COOKIE_OUTPUT; if (!outputPath) return; assertSafeEnvValue("LIGHTHOUSE_SESSION_COOKIE_OUTPUT", outputPath); fs.writeFileSync(outputPath, `${cookieValue}\n`, { mode: 0o600 }); try { fs.chmodSync(outputPath, 0o600); } catch { // Best-effort on filesystems that do not support chmod. } } async function main() { const loginUrl = process.env.LIGHTHOUSE_LOGIN_URL || DEFAULT_LOGIN_URL; const email = optionalEnv("LIGHTHOUSE_LOGIN_EMAIL"); const password = optionalEnv("LIGHTHOUSE_LOGIN_PASSWORD"); const cookieName = process.env.LIGHTHOUSE_SESSION_COOKIE_NAME || DEFAULT_COOKIE_NAME; const cookieDomain = process.env.LIGHTHOUSE_COOKIE_DOMAIN || new URL(loginUrl).hostname; const fallbackCookie = optionalEnv("LIGHTHOUSE_SESSION_COOKIE"); if (!email || !password) { if (fallbackCookie) { appendGitHubEnv({ LIGHTHOUSE_SESSION_COOKIE: fallbackCookie, LIGHTHOUSE_SESSION_COOKIE_NAME: cookieName, LIGHTHOUSE_COOKIE_DOMAIN: cookieDomain, }); writeCookieOutput(fallbackCookie); console.log(`Using pre-provisioned Lighthouse session cookie ${cookieName} for ${new URL(loginUrl).origin}.`); return; } requiredEnv(!email ? "LIGHTHOUSE_LOGIN_EMAIL" : "LIGHTHOUSE_LOGIN_PASSWORD"); } const jar = new Map(); const getResponse = await fetch(loginUrl, { redirect: "manual", headers: { accept: "text/html,application/xhtml+xml", }, }); if (!getResponse.ok) { fail(`GET ${new URL(loginUrl).origin}/login returned HTTP ${getResponse.status}`); } collectCookies(getResponse.headers, jar); const html = await getResponse.text(); const csrf = extractCsrfToken(html); if (!csrf) { fail("login page did not contain an input[name=csrf] token"); } const form = new URLSearchParams({ action: "login", email, password, redirectTo: "/", csrf, }); const postResponse = await fetch(loginUrl, { method: "POST", redirect: "manual", headers: { accept: "text/html,application/xhtml+xml", "content-type": "application/x-www-form-urlencoded", cookie: cookieHeader(jar), "x-csrf-token": csrf, }, body: form, }); collectCookies(postResponse.headers, jar); if (postResponse.status < 200 || postResponse.status >= 400) { fail(`POST ${new URL(loginUrl).origin}/login returned HTTP ${postResponse.status}`); } const sessionCookie = jar.get(cookieName); if (!sessionCookie) { fail(`login did not return the ${cookieName} session cookie`); } appendGitHubEnv({ LIGHTHOUSE_SESSION_COOKIE: sessionCookie, LIGHTHOUSE_SESSION_COOKIE_NAME: cookieName, LIGHTHOUSE_COOKIE_DOMAIN: cookieDomain, }); writeCookieOutput(sessionCookie); console.log(`Minted Lighthouse session cookie ${cookieName} for ${new URL(loginUrl).origin}.`); } await main();