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.
This commit is contained in:
parent
0da8d36a73
commit
0bf740017e
6 changed files with 355 additions and 0 deletions
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -3,3 +3,15 @@
|
||||||
.claude/*
|
.claude/*
|
||||||
!.claude/rules/
|
!.claude/rules/
|
||||||
!.claude/rules/*.md
|
!.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
|
||||||
|
|
|
||||||
123
docs/design-extract-auth.md
Normal file
123
docs/design-extract-auth.md
Normal file
|
|
@ -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 <path>
|
||||||
|
```
|
||||||
|
|
||||||
|
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=<lane>-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.
|
||||||
161
scripts/capture-auth-state.mjs
Executable file
161
scripts/capture-auth-state.mjs
Executable file
|
|
@ -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);
|
||||||
|
});
|
||||||
11
scripts/capture-auth-state.sh
Executable file
11
scripts/capture-auth-state.sh
Executable file
|
|
@ -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 "$@"
|
||||||
9
scripts/package.json
Normal file
9
scripts/package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
39
scripts/run-design-extract.sh
Executable file
39
scripts/run-design-extract.sh
Executable file
|
|
@ -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 \
|
||||||
|
"$@"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue