From 0bf740017eb187d223a76768cf24e93ecf8e1777 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 9 Aug 2026 01:15:06 +0200 Subject: [PATCH] Add authenticated designlang extract path for coulomb.social Interactive Playwright session capture (XDG storageState, mode 0600) plus a designlang pack wrapper that never prints secrets. Document the intended OpenBao lane tenants/binky/coulomb-social/bubble-member for later password custody; interactive capture is the default for Bubble. --- .gitignore | 12 +++ docs/design-extract-auth.md | 123 +++++++++++++++++++++++++ scripts/capture-auth-state.mjs | 161 +++++++++++++++++++++++++++++++++ scripts/capture-auth-state.sh | 11 +++ scripts/package.json | 9 ++ scripts/run-design-extract.sh | 39 ++++++++ 6 files changed, 355 insertions(+) create mode 100644 docs/design-extract-auth.md create mode 100755 scripts/capture-auth-state.mjs create mode 100755 scripts/capture-auth-state.sh create mode 100644 scripts/package.json create mode 100755 scripts/run-design-extract.sh diff --git a/.gitignore b/.gitignore index e4e0199..ec534b6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,15 @@ .claude/* !.claude/rules/ !.claude/rules/*.md + +# Auth / secrets — never commit (see docs/design-extract-auth.md) +.auth/ +**/storage-state.json +**/*cookie*.txt +scripts/node_modules/ +scripts/package-lock.json + +# Local env overrides +.env +.env.* +!.env.example diff --git a/docs/design-extract-auth.md b/docs/design-extract-auth.md new file mode 100644 index 0000000..4dc3ed3 --- /dev/null +++ b/docs/design-extract-auth.md @@ -0,0 +1,123 @@ +# Design extract — authenticated access to coulomb.social + +designlang can crawl authenticated pages via Playwright cookies / storage state: + +```text +npx designlang … --cookie-file +``` + +Supported cookie file formats: JSON, Playwright `storageState`, Netscape `cookies.txt`. + +## Threat model + +| Artefact | Sensitivity | Where it lives | +|----------|-------------|----------------| +| Bubble member password | **High** — full account | OpenBao only (never Git/chat) | +| Browser `storageState` | **High** — session impersonation | Local XDG path, mode `0600`, gitignored | +| designlang output (tokens, screenshots) | Product UI — ok in repo after review | `docs/design-extract/` | + +Do **not** paste passwords, cookies, or storage-state JSON into chat, workplans, or commits. + +## Preferred flow (interactive session) + +Bubble login may involve MFA, email codes, or CAPTCHA. Prefer a **human-attended** headed browser once, then reuse session state for extracts. + +### 1. Capture session as your user + +```bash +cd ~/coulomb-social +./scripts/capture-auth-state.sh +``` + +1. A Chromium window opens on `https://coulomb.social`. +2. Log in as **your** member account and navigate until you see the authenticated UI you care about. +3. Return to the terminal and press **Enter**. +4. Session is written to: + +```text +~/.config/coulomb-social/auth/storage-state.json # mode 0600 +``` + +### 2. Run the extract + +```bash +./scripts/run-design-extract.sh +# or with extra flags: +./scripts/run-design-extract.sh --depth 5 --full +``` + +Default output: `docs/design-extract/` (safe to commit after review; no secrets). + +### 3. Refresh when the session expires + +Re-run `./scripts/capture-auth-state.sh`. There is no long-lived cookie vault unless you complete the OpenBao lane below. + +## OpenBao custody (password for re-login / automation) + +Use when you need **unattended** re-auth (CI, agent loops) without a headed browser. + +| Field | Value | +|-------|--------| +| Mount | `tenants` | +| Path | `tenants/binky/coulomb-social/bubble-member` | +| Fields | `EMAIL`, `PASSWORD` | +| Risk | `high` (member account) | +| Tenant | `binky` (Binky Hedgehog = client #1) | +| Workload | `coulomb-social` | +| Bundle | `bubble-member` | + +This follows `ops-warden/wiki/playbooks/tenant-secret-onboarding.md`. **Status today:** path and fields are **declared** for this repo; the CCR, policy, OIDC role, and catalog entry are **not yet applied**. Until then, use interactive capture only. + +### Founder provision (after CCR is applied) + +```bash +# values only in mode-0600 files — never argv or chat +printf '%s' 'you@example.com' > /tmp/cs-email +printf '%s' '…' > /tmp/cs-pass +chmod 600 /tmp/cs-email /tmp/cs-pass + +bao login -method=oidc -path=netkingdom # admin / net-kingdom-admins as required +bao kv put tenants/binky/coulomb-social/bubble-member \ + EMAIL=@/tmp/cs-email \ + PASSWORD=@/tmp/cs-pass + +shred -u /tmp/cs-email /tmp/cs-pass +``` + +### Worker fetch (never print) + +```bash +bao login -method=oidc -path=netkingdom role=-workload-kv-read +bao kv get -field=EMAIL tenants/binky/coulomb-social/bubble-member > ~/.config/coulomb-social/auth/email +bao kv get -field=PASSWORD tenants/binky/coulomb-social/bubble-member > ~/.config/coulomb-social/auth/password +chmod 600 ~/.config/coulomb-social/auth/email ~/.config/coulomb-social/auth/password +``` + +Automated login-from-password is optional (`./scripts/capture-auth-state.sh --from-password-files`) and only works if bubble accepts simple email/password without interactive MFA. + +## Routing (agents) + +```bash +warden route find "coulomb.social bubble member login" +``` + +Until a catalog id exists, treat this as: + +| Need | Owner | +|------|--------| +| Member password | OpenBao `tenants/binky/…` (founder provision) | +| Interactive session | You — `scripts/capture-auth-state.sh` | +| SSH / tunnels | ops-warden / ops-bridge (unrelated) | + +**Anti-patterns:** message ops-warden for the password; commit `storage-state.json`; put `EMAIL`/`PASSWORD` in `.env` in the repo. + +## designlang auth flags (reference) + +```bash +npx designlang https://coulomb.social \ + --cookie-file ~/.config/coulomb-social/auth/storage-state.json \ + -o docs/design-extract \ + --depth 5 +``` + +Chrome extension path (optional): log in in desktop Chrome, use designlang’s extension for a one-click handoff if you prefer not to use Playwright capture. diff --git a/scripts/capture-auth-state.mjs b/scripts/capture-auth-state.mjs new file mode 100755 index 0000000..5b4b2b2 --- /dev/null +++ b/scripts/capture-auth-state.mjs @@ -0,0 +1,161 @@ +#!/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(); + writeFileSync(STATE_PATH, JSON.stringify(state, null, 2) + "\n", { + mode: 0o600, + }); + chmodSync(STATE_PATH, 0o600); + + const cookieCount = state.cookies?.length ?? 0; + const originCount = state.origins?.length ?? 0; + console.log(""); + console.log( + `Saved storageState (${cookieCount} cookies, ${originCount} origins).`, + ); + 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); +}); diff --git a/scripts/capture-auth-state.sh b/scripts/capture-auth-state.sh new file mode 100755 index 0000000..4bcaaef --- /dev/null +++ b/scripts/capture-auth-state.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Wrapper so Node resolves playwright from scripts/node_modules. +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$DIR" +if [[ ! -d node_modules/playwright ]]; then + echo "Installing playwright in scripts/ …" + npm install + npx playwright install chromium +fi +exec node ./capture-auth-state.mjs "$@" diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..ecf078f --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,9 @@ +{ + "name": "coulomb-social-scripts", + "private": true, + "type": "module", + "description": "Local tooling for auth capture and design extract (no app runtime)", + "dependencies": { + "playwright": "^1.49.0" + } +} diff --git a/scripts/run-design-extract.sh b/scripts/run-design-extract.sh new file mode 100755 index 0000000..2a2bb98 --- /dev/null +++ b/scripts/run-design-extract.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Run designlang against coulomb.social using a captured Playwright storageState. +# Does not print secrets. Requires: ./scripts/capture-auth-state.mjs first. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +AUTH_DIR="${COULOMB_SOCIAL_AUTH_DIR:-$HOME/.config/coulomb-social/auth}" +STATE_PATH="${COULOMB_SOCIAL_STORAGE_STATE:-$AUTH_DIR/storage-state.json}" +OUT_DIR="${COULOMB_SOCIAL_EXTRACT_OUT:-$ROOT/docs/design-extract}" +URL="${COULOMB_SOCIAL_URL:-https://coulomb.social}" + +if [[ ! -f "$STATE_PATH" ]]; then + echo "ERROR: No auth session at $STATE_PATH" >&2 + echo "Capture one first (interactive login as your user):" >&2 + echo " $ROOT/scripts/capture-auth-state.sh" >&2 + echo "Docs: $ROOT/docs/design-extract-auth.md" >&2 + exit 1 +fi + +mode="$(stat -c '%a' "$STATE_PATH" 2>/dev/null || stat -f '%Lp' "$STATE_PATH" 2>/dev/null || echo '?')" +if [[ "$mode" != "600" && "$mode" != "0600" ]]; then + echo "warning: $STATE_PATH mode is $mode (prefer 600)" >&2 +fi + +mkdir -p "$OUT_DIR" + +echo "==> designlang extract" +echo " url: $URL" +echo " auth: $STATE_PATH (session file present; not printed)" +echo " out: $OUT_DIR" +echo " extra: $*" + +# Default pack is useful for agent rebuild; caller can pass more flags. +# --cookie-file accepts Playwright storageState JSON. +exec npx --yes designlang pack "$URL" \ + --cookie-file "$STATE_PATH" \ + -o "$OUT_DIR" \ + --name coulomb-social \ + "$@"