From a160ad400b56a0fcecb21afac8de2b9ebbc8556a Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 13 Aug 2026 10:18:41 +0200 Subject: [PATCH] Add safe Bubble session export path for CSOC-WP-0001-T02 Document XDG/OpenBao credential custody (no secrets in chat) and add Playwright inventory export using storage-state for automated read access. --- .gitignore | 1 + docs/migration/bubble-auth-and-export.md | 127 +++++++++ scripts/export-bubble-inventory.mjs | 248 ++++++++++++++++++ scripts/export-bubble-session.sh | 13 + .../CSOC-WP-0001-bubble-io-exit-assessment.md | 2 +- 5 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 docs/migration/bubble-auth-and-export.md create mode 100755 scripts/export-bubble-inventory.mjs create mode 100755 scripts/export-bubble-session.sh diff --git a/.gitignore b/.gitignore index 00e3435..ce84097 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .auth/ **/storage-state.json **/*cookie*.txt +docs/migration/samples/_local/ scripts/node_modules/ scripts/package-lock.json diff --git a/docs/migration/bubble-auth-and-export.md b/docs/migration/bubble-auth-and-export.md new file mode 100644 index 0000000..df4a931 --- /dev/null +++ b/docs/migration/bubble-auth-and-export.md @@ -0,0 +1,127 @@ +# Bubble auth for automated export (CSOC-WP-0001-T02) + +## Do not put credentials in chat or git + +| Artefact | Where it belongs | +|----------|------------------| +| Email / password | Local `0600` files and/or OpenBao — **never** chat, PRs, workplans | +| Playwright `storage-state.json` | `~/.config/coulomb-social/auth/` mode `0600` (gitignored) | +| Export dumps | Local under auth dir or `docs/migration/samples/_local/` (gitignored) | + +Agents will **not** accept a password pasted into the conversation. That would +land secrets in session logs. + +## What a member session can do + +With **your coulomb.social member** login, automation can: + +- Open authenticated Bubble routes (`/vw_pages`, `/vw_spaces`, …) +- Capture HTML/text/screenshots for mapping +- Intercept browser network calls (sometimes useful for JSON payloads) + +A **member** session usually **cannot**: + +- Full Bubble **editor** data-type field export +- Admin “export all data” unless your account is an app admin with that power + +If you need full schema dumps, use Bubble editor as the same human, or grant +editor access to a dedicated automation identity (still via OpenBao, not chat). + +## Path A — Interactive session (recommended if MFA/CAPTCHA) + +On the workstation: + +```bash +cd ~/coulomb-social +./scripts/capture-auth-state.sh +``` + +1. Browser opens `https://coulomb.social`. +2. Log in as **you**. +3. Open a real app surface (e.g. Research / Pages — not only the landing root). +4. Press **Enter** in the terminal. + +Session file: + +```text +~/.config/coulomb-social/auth/storage-state.json # mode 0600 +``` + +Probe (should **not** stay on bare `/` if login worked): + +```bash +cd ~/coulomb-social/scripts +node ./probe-auth-session.mjs +# expect something like …/vw_pages or another app path +``` + +Then: + +```bash +./scripts/export-bubble-session.sh +``` + +## Path B — Password files (only if no interactive MFA) + +You write secrets **yourself** on disk (agent never sees the values in chat): + +```bash +AUTH="$HOME/.config/coulomb-social/auth" +mkdir -p "$AUTH" +chmod 700 "$AUTH" + +# Replace with your real values — run in your terminal only +printf '%s' 'your@email.example' > "$AUTH/email" +printf '%s' 'your-password-here' > "$AUTH/password" +chmod 600 "$AUTH/email" "$AUTH/password" + +cd ~/coulomb-social +./scripts/capture-auth-state.sh --from-password-files +# If Bubble shows MFA/CAPTCHA, fall back to Path A. +``` + +Optional OpenBao (when CCR/policy applied — see `docs/design-extract-auth.md`): + +```text +tenants/binky/coulomb-social/bubble-member → EMAIL, PASSWORD +``` + +## Path C — Keep session warm for agents + +Once `storage-state.json` exists and probe looks authenticated: + +1. Tell the agent: *“Session is captured under the default XDG auth path; run export.”* +2. Do **not** paste the file contents. +3. Re-run `capture-auth-state.sh` when the session expires (probe lands on `/` only). + +Agents with local filesystem access can then run export without further human +login **until cookies die**. + +## Export outputs + +Default dump directory (not for commit of raw HTML): + +```text +~/.config/coulomb-social/auth/exports/YYYYMMDD-HHMMSS/ + probe-url.txt + routes/ + network/ + SUMMARY.md +``` + +Redacted notes for the repo go under `docs/migration/` after review. + +## Security checklist + +- [ ] Secrets only in `~/.config/coulomb-social/auth/` (700/600) +- [ ] No password in chat, Slack, or git +- [ ] No `storage-state.json` committed (`.gitignore`) +- [ ] Prefer a **dedicated** Bubble user with least privilege if this becomes long-lived automation +- [ ] Rotate password if it was ever pasted into an unsafe channel + +## Related + +- `docs/design-extract-auth.md` — designlang / OpenBao detail +- `scripts/capture-auth-state.sh` +- `scripts/export-bubble-session.sh` +- CSOC-WP-0001-T02 diff --git a/scripts/export-bubble-inventory.mjs b/scripts/export-bubble-inventory.mjs new file mode 100755 index 0000000..b5575ec --- /dev/null +++ b/scripts/export-bubble-inventory.mjs @@ -0,0 +1,248 @@ +#!/usr/bin/env node +/** + * Authenticated Bubble inventory export for CSOC-WP-0001-T02. + * + * Uses Playwright storageState (never prints secrets). Captures routes, page + * text/HTML samples, and network JSON-ish responses for offline mapping. + * + * Usage: + * ./scripts/capture-auth-state.sh # once + * node ./scripts/export-bubble-inventory.mjs + * + * Env: + * COULOMB_SOCIAL_STORAGE_STATE default ~/.config/coulomb-social/auth/storage-state.json + * COULOMB_SOCIAL_URL default https://coulomb.social + * COULOMB_SOCIAL_EXPORT_DIR default ~/.config/coulomb-social/auth/exports/ + */ +import { chromium } from "playwright"; +import { + mkdirSync, + writeFileSync, + chmodSync, + existsSync, + appendFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +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 = + process.env.COULOMB_SOCIAL_STORAGE_STATE || + join(AUTH_DIR, "storage-state.json"); + +const ROUTES = [ + "/", + "/vw_pages", + "/vw_spaces", + "/vw_chunks", + "/vw_search", + "/vw_feed", + "/vw_invites", + "/vw_likes", + "/vw_new", + "/reset_pw", +]; + +function stamp() { + const d = new Date(); + const p = (n) => String(n).padStart(2, "0"); + return ( + d.getUTCFullYear() + + p(d.getUTCMonth() + 1) + + p(d.getUTCDate()) + + "-" + + p(d.getUTCHours()) + + p(d.getUTCMinutes()) + + p(d.getUTCSeconds()) + ); +} + +function ensureDir(path, mode = 0o700) { + mkdirSync(path, { recursive: true, mode }); + try { + chmodSync(path, mode); + } catch { + /* best effort */ + } +} + +function writeText(path, text) { + writeFileSync(path, text, { mode: 0o600 }); + try { + chmodSync(path, 0o600); + } catch { + /* best effort */ + } +} + +async function main() { + if (!existsSync(STATE_PATH)) { + console.error(`ERROR: No session file at ${STATE_PATH}`); + console.error("Capture a session first (do not paste passwords into chat):"); + console.error(" ./scripts/capture-auth-state.sh"); + console.error("Docs: docs/migration/bubble-auth-and-export.md"); + process.exit(1); + } + + const outRoot = + process.env.COULOMB_SOCIAL_EXPORT_DIR || + join(AUTH_DIR, "exports", stamp()); + const routesDir = join(outRoot, "routes"); + const netDir = join(outRoot, "network"); + ensureDir(outRoot); + ensureDir(routesDir); + ensureDir(netDir); + + console.log(`Session: ${STATE_PATH} (present; not printed)`); + console.log(`Export → ${outRoot}`); + + const networkLog = []; + const browser = await chromium.launch({ headless: true }); + try { + const context = await browser.newContext({ storageState: STATE_PATH }); + const page = await context.newPage(); + + page.on("response", async (response) => { + try { + const url = response.url(); + const ct = (response.headers()["content-type"] || "").toLowerCase(); + const status = response.status(); + if (status < 200 || status >= 400) return; + const interesting = + ct.includes("json") || + url.includes("/api/") || + url.includes("elasticsearch") || + url.includes("userdata") || + url.includes("obj"); + if (!interesting) return; + let body = ""; + try { + body = await response.text(); + } catch { + return; + } + if (!body || body.length > 2_000_000) return; + const entry = { + url: url.slice(0, 500), + status, + contentType: ct.slice(0, 120), + bytes: body.length, + at: new Date().toISOString(), + }; + networkLog.push(entry); + const safe = String(networkLog.length).padStart(3, "0"); + writeText(join(netDir, `${safe}.meta.json`), JSON.stringify(entry, null, 2)); + writeText(join(netDir, `${safe}.body.txt`), body); + } catch { + /* ignore single response failures */ + } + }); + + // Warm session + await page.goto(BASE_URL, { waitUntil: "domcontentloaded", timeout: 60_000 }); + await page.waitForTimeout(2500); + const probeUrl = page.url(); + const probeTitle = await page.title().catch(() => ""); + writeText( + join(outRoot, "probe.txt"), + `url=${probeUrl}\ntitle=${probeTitle}\n`, + ); + console.log(`Probe: ${probeUrl} — ${probeTitle}`); + try { + const path = new URL(probeUrl).pathname; + if (path === "/" || path === "") { + console.warn( + "WARNING: Still on site root — session may be guest/expired. Re-run capture-auth-state.sh", + ); + } + } catch { + /* ignore */ + } + + const summary = []; + summary.push(`# Bubble export ${stamp()}`); + summary.push(""); + summary.push(`- base: ${BASE_URL}`); + summary.push(`- probe: ${probeUrl}`); + summary.push(`- title: ${probeTitle}`); + summary.push(`- session file: present (path not echoed in summary for safety)`); + summary.push(""); + summary.push("## Routes"); + summary.push(""); + + for (const route of ROUTES) { + const url = route.startsWith("http") ? route : `${BASE_URL.replace(/\/$/, "")}${route}`; + const slug = route.replace(/\W+/g, "_").replace(/^_|_$/g, "") || "root"; + console.log(` GET ${route}`); + try { + const resp = await page.goto(url, { + waitUntil: "domcontentloaded", + timeout: 60_000, + }); + await page.waitForTimeout(3500); + const finalUrl = page.url(); + const title = await page.title().catch(() => ""); + const status = resp?.status() ?? 0; + const html = await page.content(); + const text = await page.evaluate(() => document.body?.innerText || ""); + writeText(join(routesDir, `${slug}.html`), html); + writeText(join(routesDir, `${slug}.txt`), text); + writeText( + join(routesDir, `${slug}.meta.json`), + JSON.stringify( + { route, url, finalUrl, title, status, htmlBytes: html.length, textBytes: text.length }, + null, + 2, + ), + ); + summary.push( + `- \`${route}\` → ${status} ${title} (${text.length} chars text, final ${finalUrl})`, + ); + } catch (err) { + summary.push(`- \`${route}\` → ERROR ${err.message}`); + console.warn(` failed: ${err.message}`); + } + } + + // Public meta (no auth needed, still useful) + try { + const metaResp = await page.request.get(`${BASE_URL.replace(/\/$/, "")}/api/1.1/meta`); + const metaText = await metaResp.text(); + writeText(join(outRoot, "api-meta.json"), metaText); + summary.push(""); + summary.push("## API meta"); + summary.push(`- status ${metaResp.status()} (${metaText.length} bytes)`); + } catch (err) { + summary.push(`- api meta ERROR ${err.message}`); + } + + writeText( + join(outRoot, "network-index.json"), + JSON.stringify(networkLog, null, 2), + ); + summary.push(""); + summary.push("## Network captures"); + summary.push(`- ${networkLog.length} JSON/API-ish responses under network/`); + summary.push(""); + summary.push("## Next"); + summary.push("- Review routes/*.txt for Title/Abstractor/Visual/copy cues"); + summary.push("- Map into docs/migration/schema-mapping (redacted notes only in git)"); + summary.push("- Do not commit this export directory"); + summary.push(""); + + writeText(join(outRoot, "SUMMARY.md"), summary.join("\n")); + console.log(""); + console.log(`Done. Summary: ${join(outRoot, "SUMMARY.md")}`); + console.log(`Network hits: ${networkLog.length}`); + } finally { + await browser.close(); + } +} + +main().catch((err) => { + console.error(err.message || err); + process.exit(1); +}); diff --git a/scripts/export-bubble-session.sh b/scripts/export-bubble-session.sh new file mode 100755 index 0000000..7e0f01e --- /dev/null +++ b/scripts/export-bubble-session.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Authenticated Bubble inventory export (CSOC-WP-0001-T02). +# Uses storage-state only — never prints secrets. +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$DIR/.." && pwd)" +cd "$DIR" +if [[ ! -d node_modules/playwright ]]; then + echo "Installing playwright in scripts/ …" + npm install + npx playwright install chromium +fi +exec node ./export-bubble-inventory.mjs "$@" diff --git a/workplans/CSOC-WP-0001-bubble-io-exit-assessment.md b/workplans/CSOC-WP-0001-bubble-io-exit-assessment.md index 1ab4475..ae3f5fa 100644 --- a/workplans/CSOC-WP-0001-bubble-io-exit-assessment.md +++ b/workplans/CSOC-WP-0001-bubble-io-exit-assessment.md @@ -8,7 +8,7 @@ status: active owner: bernd topic_slug: coulomb-social created: "2026-08-09" -updated: "2026-08-12" +updated: "2026-08-13" origin: residual origin_ref: the-custodian/docs/coulomb-social-rebuild-seed.md state_hub_workstream_id: "ecefaeef-908a-4072-ae34-6406862d4cad"