coulomb-social/scripts/capture-auth-state.mjs
tegwick 319dd00465 Add designlang public-shell pack; tighten auth capture checks
Ran authenticated extract against coulomb.social; session metadata
matched anonymous Bubble defaults, so treat docs/design-extract as a
public shell baseline (PROVENANCE.md). Capture script now refuses to
overwrite storage-state.json with thin anonymous sessions.
2026-08-09 01:22:15 +02:00

199 lines
6.5 KiB
JavaScript
Executable file

#!/usr/bin/env node
/**
* Capture a Playwright storageState for https://coulomb.social after you log in.
*
* Default: headed browser — you complete login, then press Enter in this terminal.
* Optional: --from-password-files reads EMAIL/PASSWORD from XDG auth dir (OpenBao-exported).
*
* Never prints secret values. Writes mode-0600 storage-state.json only.
*/
import { chromium } from "playwright";
import {
mkdirSync,
writeFileSync,
chmodSync,
existsSync,
readFileSync,
statSync,
} from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
const BASE_URL = process.env.COULOMB_SOCIAL_URL || "https://coulomb.social";
const AUTH_DIR =
process.env.COULOMB_SOCIAL_AUTH_DIR ||
join(homedir(), ".config", "coulomb-social", "auth");
const STATE_PATH = join(AUTH_DIR, "storage-state.json");
const EMAIL_PATH = join(AUTH_DIR, "email");
const PASSWORD_PATH = join(AUTH_DIR, "password");
const fromPasswordFiles = process.argv.includes("--from-password-files");
const headless = process.argv.includes("--headless");
function ensureAuthDir() {
mkdirSync(AUTH_DIR, { recursive: true, mode: 0o700 });
try {
chmodSync(AUTH_DIR, 0o700);
} catch {
/* best effort */
}
}
function readSecretFile(path) {
if (!existsSync(path)) {
throw new Error(`Missing secret file: ${path}`);
}
const mode = statSync(path).mode & 0o777;
if (mode & 0o077) {
console.warn(
`warning: ${path} is group/world-readable (mode ${mode.toString(8)}); chmod 600 recommended`,
);
}
return readFileSync(path, "utf8").trim();
}
async function waitForUserContinue() {
const rl = createInterface({ input, output });
console.log("");
console.log(">>> Log in as your coulomb.social user in the browser window.");
console.log(">>> Navigate to an authenticated page you want designlang to see.");
console.log(">>> Then return here and press Enter to save the session.");
console.log("");
await rl.question("Press Enter when logged in… ");
rl.close();
}
async function tryPasswordLogin(page, email, password) {
// Best-effort Bubble-style login. Interactive capture is preferred when MFA/CAPTCHA appears.
await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: 60_000 });
await page.waitForTimeout(1500);
const emailBox =
(await page.$('input[type="email"]')) ||
(await page.$('input[name*="email" i]')) ||
(await page.$('input[placeholder*="email" i]')) ||
(await page.$('input[placeholder*="Email" i]'));
const passBox =
(await page.$('input[type="password"]')) ||
(await page.$('input[name*="password" i]'));
if (!emailBox || !passBox) {
throw new Error(
"Could not find email/password fields automatically. Re-run without --from-password-files and log in interactively.",
);
}
await emailBox.fill(email);
await passBox.fill(password);
const submit =
(await page.$('button[type="submit"]')) ||
(await page.$('input[type="submit"]')) ||
(await page.$('button:has-text("Log in")')) ||
(await page.$('button:has-text("Login")')) ||
(await page.$('button:has-text("Sign in")'));
if (submit) {
await submit.click();
} else {
await passBox.press("Enter");
}
await page.waitForTimeout(4000);
console.log("Password login attempt finished (no secrets printed).");
console.log("If MFA/CAPTCHA is required, re-run in interactive mode.");
}
async function main() {
ensureAuthDir();
console.log(`Auth dir: ${AUTH_DIR}`);
console.log(`Target: ${BASE_URL}`);
console.log(`State → ${STATE_PATH}`);
const browser = await chromium.launch({
headless: Boolean(headless && fromPasswordFiles),
channel: process.env.COULOMB_SOCIAL_CHROME_CHANNEL || undefined,
});
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
});
const page = await context.newPage();
try {
if (fromPasswordFiles) {
const email = readSecretFile(EMAIL_PATH);
const password = readSecretFile(PASSWORD_PATH);
await tryPasswordLogin(page, email, password);
} else {
await page.goto(BASE_URL, {
waitUntil: "domcontentloaded",
timeout: 60_000,
});
await waitForUserContinue();
}
const state = await context.storageState();
const cookieCount = state.cookies?.length ?? 0;
const originCount = state.origins?.length ?? 0;
const lsKeys = (state.origins || []).flatMap((o) =>
(o.localStorage || []).map((e) => e.name),
);
// Bubble always sets coulomb_* session cookies for anonymous visitors.
// A real login usually adds more localStorage keys and/or more cookies.
const onlyDefaultBubbleCookies =
cookieCount <= 3 &&
(state.cookies || []).every((c) =>
String(c.name || "").startsWith("coulomb_"),
);
const thinLocalStorage =
lsKeys.length === 0 ||
(lsKeys.length === 1 && lsKeys[0] === "algoliasearch-client-js");
if (onlyDefaultBubbleCookies && thinLocalStorage) {
console.error("");
console.error(
"WARNING: This looks like an anonymous Bubble session, not a logged-in user.",
);
console.error(
` cookies=${cookieCount} (only default coulomb_* names)`,
);
console.error(` localStorage keys: ${lsKeys.join(", ") || "(none)"}`);
console.error("");
console.error("Do not use this for authenticated design extract.");
console.error("Re-run, fully log in, wait until the app UI is loaded,");
console.error("then press Enter. If login uses a popup, complete it first.");
process.exitCode = 2;
// Still write so you can inspect, but mark filename
const weakPath = STATE_PATH.replace(/\.json$/, ".anonymous.json");
writeFileSync(weakPath, JSON.stringify(state, null, 2) + "\n", {
mode: 0o600,
});
chmodSync(weakPath, 0o600);
console.error(`Wrote weak session for inspection only: ${weakPath}`);
return;
}
writeFileSync(STATE_PATH, JSON.stringify(state, null, 2) + "\n", {
mode: 0o600,
});
chmodSync(STATE_PATH, 0o600);
console.log("");
console.log(
`Saved storageState (${cookieCount} cookies, ${originCount} origins, localStorage keys: ${lsKeys.length}).`,
);
console.log(`File: ${STATE_PATH}`);
console.log("Next: ./scripts/run-design-extract.sh");
} finally {
await browser.close();
}
}
main().catch((err) => {
console.error(err.message || err);
process.exit(1);
});