π raw markdown for agents/LLMs: /CLAUDE.md Β· all docs
Bluebut β developer guide
β NEVER EVER DO THIS β couple logic to the presentation layer instead of the data model
A separation-of-concerns / layering violation: control flow must depend on the stable data contract (the model/state), never on the rendered output meant for humans (display strings).
Sub-names, same root:
- Programming against the view, not the model β reading what the page renders instead of the state it renders from.
- Stringly-typed control flow β branching on free-form text where a typed enum/flag was available.
- Depending on an incidental, unstable representation β display copy carries no stability contract; it can change any release. Building logic on it = relying on a non-guarantee.
This repo's positive form is the truth ladder: read the source of truth β cookie/jar β inlined Relay state β persisted GraphQL
doc_idβ and treat the rendered DOM as the last resort. Matching localizedinnerText(e.g."TillΓ₯t alla cookies") is the bottom rung dressed up as a check. Correct example: detect FB's cookie banner via the server-rendered flag"shouldShowCookieBanner":true, not the button's label. Skills:surface-extract,local-browser.Operationally this means: new extraction adapters are WIRE-BASED GraphQL replay, and the legacy DOM scrapers are a migration backlog. See β Extraction doctrine under Adapters.
Facebook outreach operator dashboard for the Bofrid housing-acquisition funnel. Groups are the primary object today, but the spine (jobs, accounts, data planes) is site- and object-agnostic β a page, a person, any object on any site rides the same machinery.
Four pages: Groups (home, / β /groups) + Jobs, Accounts, Tools. Opening a group is a full-page workspace (/groups/<id>[/<tab>], pages/groups/GroupWorkspace.tsx), tabs Overview + People (admins). Posting is not a tab β broadcasting is a cross-group batch from the Groups-index checkbox panel.
Architecture β the mental model
One object, one verb, one log. The operator works on objects (groups today); every action is a job; every job's trail lands in HyperDX + the durable job ledger, keyed by job_id. None of it is FB- or group-specific.
π§ ARCHITECTURE IN MIGRATION β chromebox β cloakbox consumer
Bluebut is being migrated from owning a browser fleet (the chromebox + its own fingerprint/proxy/session/HAR/tap stack) to being a CONSUMER of the cloakbox identity platform (gologin/pbox), which owns all of that. Bluebut then owns ONLY its FB domain verbs. The target architecture, remove-list, and current status are inlined below.
β TRANSPORT β consume cloak-cdp over its HTTP API, NOT the MCP
The platform is reached over two surfaces β
keyvault(pick accounts) andcloak-cdp(launch + drive). For everything bluebut runs unattended (the runnerbin/bluebut-adapter.mjs, the bridge,bin/*scripts, cron), cloak-cdp is consumed over its plain HTTP API, NOT the MCP JSON-RPC endpoint. The MCP (.../mcp) is for interactive agent sessions; it can be absent/interactively-authed in headless/cron runs (the exact failure the harness warns about) β so runtime code MUST use the HTTP API.
- Base:
https://cloak-cdp.hostbun.ccΒ· auth:?key=ddashor headerX-Api-Key: ddash(envCLOAK_API_KEY).POST /api/callbody{"tool":"<name>","args":{β¦}}β{"result":β¦}β the one call for every verb (cdp_load/cdp_save/cdp_catalog/cdp_drive/wiretap_*).GET /api/tools(full input schemas) Β·GET /api/launchable(=cdp_catalog;?service=facebook&runnable=1&summary=1) Β·GET /api/health.GET /api/tools(full input schemas) Β·GET /api/launchable(=cdp_catalog) Β·GET /api/health(liveness). Quick check:curl -s 'https://cloak-cdp.hostbun.cc/api/health?key=ddash'.- Seam:
adapters/_lib/cloak-client.jsβ the ONE client, and it speaksPOST /api/call(stateless, no handshake). Every verb (cdpLoad/cdpSave/cdpCatalog/cdpDrive/wiretap*) goes through itscallTool. Nothing else calls the platform directly; never re-introduce the MCP JSON-RPC transport.Status (2026-07-09): CUT OVER (code) β the runner is cloakbox-only. The
BLUEBUT_CLOAKBOXgate is GONE:bin/bluebut-adapter.mjsalwayscdp_loads β drives βcdp_saves; the chromebox (fb.chrome.blpk.cc) is retired at the deploy level. Live catalog: run=5 Β· mint-new=300 Β· birth=0 β only 5 accounts have a Windows cloakbox device; the other 300 need a device minted (per-account human VNC login viaadd-account). Remaining violations to migrate off: the runner still runs its OWN capture stack (netcapture/har/gql-tap/graphql-storeβ own MinIO + bofrid.dev) β the platform wiretap already captures everything (bluebut-wiretapconsumes it). CAPTURE = platform; RECOGNIZE (*-wire.js) = bluebut.β NO CLOAKBOX DEVICE β NO JOB (for that account, for ANY verb)
Every job runs through
cdp_load(service, slug), andcdp_loadcan only load an account that has a Windows cloakbox device (cdp_catalogverdictrun). An account with only a mac/linux device or none (verdictmint-new/birth) has nothing to load β the job dies atcdp_loadwith "no seeded device for facebook/β¦ nothing to load." This is not scrape-specific: it is true for read, scrape, post, admin, messenger β every verb. So "can this account run" is exactly "does it have a cloakbox device", nothing else (notlogged_in, not membership, not admin-ness).Live reality: only 5 of 305 facebook accounts are runnable (
run=5 Β· mint-new=300).joel-skola,philip,pojtieare among them; most named + alldf-*farm accounts are NOT. To make one runnable, MINT a device (cdp_newβ human VNC login βcdp_save) via theadd-accountskill.Enforced in code β never pick a non-runnable actor. The cloakbox-runnable set is
getCloakboxCatalog()βmakeDrivable(sessions, cloak)(ui/src/lib/util.tsx);drivable(slug)is the hard gate. Every place that chooses WHICH account drives a job filters todrivableFIRST and returns null / [] when none qualifies β the action is then DISABLED (with the "mint a device" hint), never fired as a doomed job:
GroupWorkspace.pickActor()β refresh / scrape / admin-edit actor (drivable is the filter, scoring only orders survivors).broadcastActor.candidatesForGroup()+makeAutoActorfallback β the broadcast WHO-posts router (hard-filtered, no non-drivable tail).The trap this closes: stale
fb_account_groupedges for purged Page pointers (uthyra-se,bofrid, β¦) β Pages that were never cloakbox accounts (the pointer-model purge removed the kv rows but left the membership edges). A blindmemberAccts[0]pick floated one of these first βcdp_load facebook/uthyra-seβ dead.drivableexcludes them because they have no device. (Cleaning the ghost edges is DB hygiene, separate from the gate.)
Execution path β UI β job β bridge β runner β box
operator click
β useJobs().runJob / startJob (ui/src/state/jobs.tsx) β UI job: in-memory + toast + HyperDX
β runCli / runCliLive (ui/src/api/index.ts) β threads job_id
β POST /exec/stream (SSE) (ui/bridge/opencli-bridge.mjs, VITE_BRIDGE_URL)
β spawn runner (bin/bluebut-adapter.mjs, env BLUEBUT_JOB_ID)
β import adapters/<site>/<cmd>.js
β cloak-client POST /api/call {tool:cdp_load,args:{slug}} β resolvePageWs
β cloakserve (Windows seed browser on pbox, platform-gated) β Facebook
β POST /api/call {tool:cdp_save} at the end
The runner is cloakbox-only (the BLUEBUT_CLOAKBOX gate is gone): every FB job calls the platform's cdp_load over the HTTP API (POST /api/call, key ddash β see the transport banner above) which resolves+egress-verifies the proxy, gates the fingerprint, and injects the session, then cdp_saves at the end β and SKIPS the retired chromebox-only proxy-gate/hardenSession/egress-guard/ensureFbSession (the platform owns them). adapters/_lib/cloak-client.js is the ONE seam; adapters are unchanged.
Three data planes
Under cloakbox: keyvault stays the account store but the platform owns identity writes (
cdp_save); the Traffic firehose + HAR planes are RETIRED (platform wiretap captures every seed).
| Plane | Where | Holds | Access |
|---|---|---|---|
| Accounts | keyvault | FB identity / creds / cookies β "are our accounts working?" | R/W |
| Entity graph + activity | bluebut-db (own Postgres) via bluebut-rest (https://bluebut-rest.blpk.cc, PostgREST) β cutover off shared hostbun db.hostbun.cc is DONE/LIVE |
groups, memberships, people, chats, group_post, the job ledger |
R/W |
| Traffic firehose | bofrid.dev (supabase.bofrid.dev) |
har_recording + traffic_entry (every captured request/response + WS frames, ~277k rows/day) + fb_traffic_live (24h-purge live mirror) |
R/W (the write exception) |
| HAR blobs | bluebut-minio (MinIO/S3, private har bucket) β cut over from the hostbun har bucket |
the full gzipped lossless recordings; traffic_entry.recording_id β har_key β bucket |
R/W |
| Legacy history | bofrid.dev (supabase.bofrid.dev) |
fb-bot's sent DMs + broadcasts (fb_sent_messages/fb_broadcast_posts), fb_scraped_posts, fb_ws_captures |
read-only (except the traffic + scraped-feed writes) |
bofrid.dev is read-only EXCEPT the deliberate write tables: the traffic firehose (har_recording/traffic_entry/fb_traffic_live) and the fb-bot scraped-posts feed (fb_scraped_posts). Everything else on bofrid.dev (DMs, broadcasts) stays read-only. The heavy traffic index lives on bofrid.dev, not hostbun, to keep the shared operational DB lean β traffic-store.js creds() resolves BOFRID_SUPA_URL first.
Dev principles
- One object, one verb, one log. Object = the thing (group/page/person/account); job = every action; HyperDX +
jobledger = the trail (keyed byjob_id). - Every action is a job. Observability is built in β the job ships steps + screenshot-on-fail + one copy-log button. Debug by checking the job first.
- Agnostic by default. A job links to what it acted on via the generic
(site, target_type, target_id)triple β never a site-/object-specific column. New object or site = no schema change. - Self-heal over diagnose-then-act. One smart verb that figures out what's needed beats N manual verbs.
ensureFbSessionheals cold sessions mid-action. Expose manual rungs as escape-hatches, never the default. - Never trust a click β re-read the authoritative end-state. Mutating adapters re-open the menu / reload
/people/and only return ok once the state actually flipped (okWith/failWith). Optimistic UI lies. - One source of truth per plane. Accounts β keyvault. Live entity state +
jobledger β bluebut-db (via bluebut-rest; cutover off hostbundb.hostbun.ccDONE/LIVE). Traffic firehose (traffic_entry/har_recording/fb_traffic_live) + scraped-feed β bofrid.dev (R/W). Legacy history (DMs/broadcasts) β bofrid.dev (read-only). HAR blobs β bluebut-minioharbucket. Writing the wrong plane is a bug. - The accounts are the asset. Anti-detect rules are enforced in code, not convention. Survival beats throughput.
- Adapters are first-class repo code. Owned in
adapters/, picked up on next run β no install/symlink. Fork upstream fixes down; don't edit toolbox up. - Fail loud with forensics.
failWith()attaches a screenshot + selector strategies tried. Swallow errors only where a comment says it's deliberate. - Precise terminology. cookie β storage_state β session.
- Keep the gate green.
npm run check(lint + typecheck + build + test) on every push. Errors block. - Run long-running scripts in a subagent, not the main loop. Any script that occupies the session β the group-creation queue, the login-box flow, a long scrape/batch β launch it via the Agent tool (background or foreground subagent) so the main agent stays free to monitor and inspect the running job (tail logs, check the
jobledger / HyperDX, answer questions) instead of being blocked waiting on it. The subagent owns the script's lifecycle; the main agent observes. Don'tBash-block the main loop on a script that runs for minutes.
Running locally
npm install # repo root β @jackwener/opencli (lib) + runner
cd ui && npm install
cd .. && npm run bridge # terminal 1 β local adapter bridge (BRIDGE_PORT, .env β :8790)
cd ui && npm run dev # terminal 2 β http://localhost:5173
β LOCAL-FIRST β verify locally, DON'T push to test
git pushdeploys to PROD (Coolify auto-deploys@main). It is NOT a test button. The bridge + adapters + UI are all local code β the same files prod runs β so every change is observable on your machine in seconds. A push is the LAST step, once the change is proven locally, not a way to see whether it works. Don't burn a prod deploy per edit.The loop is
.env-wired end-to-end:BRIDGE_PORT=8790+VITE_BRIDGE_URL=http://localhost:8790(headroom squats the 8787 default, so we run on 8790), so a locally-running UI drives the locally-running bridge β same code path as prod. Test recipe by what you touched:
Changed See it without deploying bridge ( ui/bridge/*)restart npm run bridge(no HMR; ~1s boot).curl localhost:8790/healthlisting text / compose ( _lib/listing-post-text.js+/listings/compose)curl -s localhost:8790/listings/ads?limit=1β pipe one row intoPOST /listings/composeβ read the text. No FB post, no browser.adapter ( adapters/**)picked up on the NEXT runner spawn β no restart. Run it: node bin/bluebut-adapter.mjs adapters/facebook/<cmd>.js --account joel-skola β¦messenger ingest (daemonβbridge handleE2eeIngest)the whatsmeow daemon is PROD-only and POSTs to the PROD bridge, so a local daemon can't drive it. Test the bridge's persistence by POSTing a sample DecryptedMessageto the LOCAL bridge:curl -X POST -H "authorization: Bearer $BRIDGE_INGEST_TOKEN" localhost:8790/messenger/e2ee/ingest -d '{"messages":[{"account":"pojtie","thread_key":"<peer_fbid>","message_id":"local-1","sender_id":"<peer>","timestamp_ms":<now>,"message_type":"image","text":"","is_self":false,"attachments":[{"attachment_type":"image","mimetype":"image/jpeg"}]}]}'β it writes the SAME bluebut-db (local bridge reads the prod DSN), so checkfb_message/fb_message_attachment/fb_thread/fb_contact. NO prod deploy, no live send.UI ( ui/src/*)Vite HMR β already live on :5173, no restart pure logic ( _lib/*)npm test(node --test) β a sibling*.test.mjsis faster than any endpointPush only a change that (a) is verified locally and (b) is actually meant for prod. Then follow the global "verify after deploy" rule. If a change is bridge/adapter/UI-only, the local run IS the proof β the deploy just ships it.
Onboarding new accounts
Cloakbox is the only onboarding path. New account = the add-account skill β creds+proxyβkeyvault, cdp_new mint device, cdp_save GROUPED before login, HUMAN VNC login, cdp_save session. NEVER login.py/Mac. Proxy assignment: npm run proxy:pick -- --account <a> --geo SE (proxyless accounts self-provision via _lib/proxybun.js). Known: capsolver self-hosted down β hand-solve captcha. (The old Mac-local login path β local-dev/, login-container/, npm run local* β is DELETED.)
Without the bridge, action buttons fail; read-only pages still work. Single root .env (Vite reads via envDir: ".."; bridge/runner/sniffer/canary/hyperdx all read it):
VITE_SUPABASE_URL=https://bluebut-rest.blpk.cc # entity graph + job ledger (self-owned bluebut-db via PostgREST)
VITE_SUPABASE_KEY=<service_role_jwt> # UI-ONLY (baked into the browser bundle at build). Server-side (runner/bridge/canaries) reads this JWT from KEYVAULT β `bluebut/config/rest-service-key` β not env.
VITE_BOFRID_SUPA_URL=https://supabase.bofrid.dev
VITE_BOFRID_SUPA_KEY=<service_role_jwt>
VITE_CHROMEBOX_URL=https://fb.chrome.blpk.cc # box: CDP gateway + /chrome/* + /accounts/*
VITE_BRIDGE_URL=http://localhost:8790 # local opencli bridge (8790: headroom squats the 8787 default)
# self-owned data plane (Coolify; cutover DONE β entity graph on bluebut-rest, HAR on MinIO)
S3_ENDPOINT=http://minio-malct88jd5gp0miiag4jhslm:9000 # bluebut-minio; HAR blobs β `har` bucket
S3_ACCESS_KEY=<minio_root_user> # also MINIO_ROOT_USER / MINIO_ROOT_PASSWORD
S3_SECRET_KEY=<minio_root_password>
# BLUEBUT_HAR_BUCKET=har # default
Deployment & infrastructure β Coolify (hostbun)
Everything runs on the hostbun Coolify (coolify.hostbun.cc, MCP coolify-hostbun), project bluebutchromebox / env production (environment_id=21), one server: funnelsites-bluebut-hexabyte-chrm = Hexabyte e3, 194.104.94.173, Traefik proxy on docker network coolify. (NOT hetzner β that old bluebut app is a stale dupe.) Apps auto-deploy from devdashco/bluebut@main on push; one push redeploys UI + bridge together.
β WORK ON
mainβ NO FEATURE BRANCHESCoolify deploys
bluebut@main, somainIS the deploy branch. Commit straight tomainand push β do NOT open a feature branch or a PR for normal work. Keep the gate green (npm run check) before every push; a green push deploys. (The one exception is a genuinely risky, long-lived change the operator explicitly asks to stage off-mainβ otherwise alwaysmain.)
Apps (project bluebutchromebox)
| App | Domain | Repo / branch | Role |
|---|---|---|---|
| bluebut | https://bluebut.blpk.cc |
bluebut@main | The Vite UI. VITE_BRIDGE_URL baked β token-gated bridge. |
| bluebut-bridge | https://bluebut-bridge.blpk.cc |
bluebut@main | opencli bridge: {site,cmd} β runner over box CDP. BRIDGE_TOKEN gates; Dockerfile.bridge. |
| bluebut-db-mcp | https://bluebut-db-mcp.blpk.cc |
bluebut@main | MCP over bluebut-db (in the devdash marketplace). |
| bluebut-e2ee | β (no domain) | bluebut@main | Go/whatsmeow E2EE daemon β registers an FB E2EE device + decrypts type-15 Messenger bodies. Register-once store = bluebut-e2ee-db. |
| chromebox-sniffer | β (no domain) | bluebut@main | Always-on traffic firehose daemon β HyperDX + bofrid.dev fb_traffic_live. Dockerfile.sniffer. |
| chromebox-fb | https://fb.chrome.blpk.cc Β· https://selkies.fb.chrome.blpk.cc:8081 |
devdashco/toolbox@chromebox-fb |
The box: per-account Chromium + CDP gateway (/cdp/<acct>) + /chrome/* + /accounts/* + Selkies. Profiles bind-mounted (survive rebuild). |
Data planes β the self-owned move (cutover DONE)
bluebut has moved off the shared hostbun Supabase β its own private Postgres + MinIO in the Coolify project:
| Resource | Coolify name | Internal DSN / endpoint | Holds |
|---|---|---|---|
| Entity-graph DB | bluebut-db |
postgres://bluebut:***@p3jif8hgier7tzxnsze7ibik:5432/bluebut |
entity graph + job ledger + messenger. Replaces the shared hostbun Supabase. |
| REST contract | bluebut-rest (PostgREST) |
https://bluebut-rest.blpk.cc (Traefik strips /rest/v1); PGRST_DB_URI=β¦authenticator@β¦/bluebut, same JWT secret so the existing service-role key validates |
the /rest/v1 surface the UI+adapters speak (Supabase-shaped). |
| E2EE register store | bluebut-e2ee-db |
private PG | the E2EE device register-once Postgres (see messenger/e2ee/PLAN.md). |
| Blob storage | bluebut-minio (MinIO/S3) |
internal http://minio-malct88jd5gp0miiag4jhslm:9000; env S3_ENDPOINT/S3_ACCESS_KEY/S3_SECRET_KEY, BLUEBUT_HAR_BUCKET (default har) |
private har bucket (HAR blobs β account-takeover material) + fb-media. Replaces Supabase storage. |
Migration status: DONE / LIVE. Entity graph + job ledger served by bluebut-db via bluebut-rest.blpk.cc; HAR on MinIO; chromebox_sessions retired (keyvault sole account store). bofrid.dev unchanged β traffic firehose + scraped-feed (R/W) and legacy DMs/broadcasts (read-only) stay there.
Secrets are not in this file β S3_*, BRIDGE_TOKEN, JWT/service-role keys, DB passwords live in keyvault + the Coolify app env (*** masked). Internal DSN hosts are the Coolify resource UUIDs, reachable only on the coolify docker network. Observability β shared hyperdx-v2 (otel.hyperdx.hostbun.cc, ServiceName='bluebut'/'chromebox-traffic').
Tech stack
React + Vite + TypeScript; TanStack Query.
Use the component library β never hand-roll UI primitives. (1) app primitives in
ui/src/components/Ui.tsx(Button,Input,Select,Card,Row, β¦) β Ui.tsx IS the primitive layer, the only file allowed to render the raw elements; (2) MUI v9 (themed dark,ui/src/theme.ts) for everything else. The lookup table:Need Use Never modal <Dialog>a position:fixed; inset:0backdrop div, raw<dialog>side panel <Drawer anchor="right">a bespoke slide-over menu / popover <Menu>/<Popover>an absolutely-positioned div dropdown <TextField select>+<MenuItem>(slotProps={{ select: { displayEmpty: true } }}when the empty option is the placeholder)a raw <select>+ inlinestyletabular list <AppDataGrid>(MUI X DataGrid)a raw <table>clickable list rows <List>/<ListItemButton selected>styled <button>cardserror / warning box <Alert>+<AlertTitle>a red-bordered div loading placeholder <Skeleton>a gradient div small status pill <Chip size="small">a styled <span>text / layout <Typography>/<Box>/<Stack>on theme colors<div style={{ color: "#9ca3af" }}>(MUI v9 gotchas: paper styling is
slotProps={{ paper: { sx } }}; system props are gone offTypography/Stackβ putfontWeight/alignItems/flexWrapinsx.)Enforced, not merely documented:
ui/eslint.config.jsno-restricted-syntaxmakes a hand-rolled backdrop, a raw<dialog>, a raw<select>and a raw<table>a CI error naming the replacement.Ui.tsxis exempt (it's the primitive). The ~11 legacy sites carry a line-scopedeslint-disable-next-line β¦ -- legacy raw <select>/<table>: migrate to β¦β that comment IS the migration backlog; delete it by migrating, never copy it into new code.Color = design tokens, NEVER raw hex. ONE semantic palette is the source of truth: the CSS
@themeblock inui/src/index.css(--color-bg/-surface/-fg/-muted/-faint/-line/-primary/-info(-fg/-bg)/-success(-strong/-fg/-bg)/-warning(-fg/-bg)/-danger(-strong/-fg/-bg)/-violet(-fg/-bg)/-hover) with its JS mirror inui/src/tokens.ts(importtwhere a real color is needed β the MUItheme.ts, canvas/SVG-attr colors). Referencevar(--color-<role>)in every inlinestyle/sxand Tailwind utility; a hex literal in a component is drift (there were ~950 of them across 4 grays-for-one-role etc β collapsed to the token set). One role β one value, everywhere. Keepindex.css @themeandtokens.tspaired.Frontend/UI polish β the
impeccableskill. Any design/redesign/critique/audit of a UI surface β visual hierarchy, spacing, typography, color, motion, accessibility, empty/error states, responsive behavior β invoke/impeccablefirst. It governs how UI work is shaped here; the component-library table above is the primitive floor,impeccableis the taste layer on top.No routing library β a tiny History-API router (
ui/src/lib/router.ts). Route/<page>[/<sub>]; navigate withnavigate(page, sub?). Refresh/deep-link safe.
Conventions
Session terminology (use these exact words)
cookie β storage state (storage_state) β session (the account record)
c_user { cookies[], origins[] } { storage_state, fb_id, status, proxy, β¦ }
- cookie β ONE FB
name=valuepair (c_user,xs,datr,i_user). - storage state (
storage_state) β the auth bundle = cookies + localStorage origins (Playwright/CDP shape). The precise name for what we inject/dump/persist. Prefer over "jar". - session β the account record =
storage_state+ identity (fb_id,display_name) +status+proxy+ metadata. Contains a storage state.
jar survives only as casual shorthand + stable identifiers (decodeJar, jar-truth.js) β don't introduce it in new code.
Quality gate β npm run check
CI (.github/workflows/check.yml) runs it β plus the ui build β on every push to main + every PR. ERRORS block; WARNINGS tolerated. Coolify auto-deploys main independently, so CI is the TRIPWIRE (revert on a red β), not a deploy gate. Run npm run check locally before every push.
npm run lintβ root ESLint (Node:adapters/,bin/,ui/bridge/).npm run lint:uiβ UI ESLint (TS-aware).lint:all= both.npm run typecheckβtsc -binui/.npm testβ adapter unit tests (node --test adapters/_lib/*.test.mjs).- Underscore prefix (
_unused) marks an intentionally-unused var/arg/caught-error.
DB schema β NO ORM, NO migration tool (deliberate)
There is no Drizzle/Prisma/Kysely and no migration runner β and there shouldn't be. The DB is reached ONLY over PostgREST REST (_lib/db.js, UI dbGet), so an ORM's query layer would go unused; and the schema is self-extending at runtime (fb_schema_ingest RPC + ~77 ADD COLUMN IF NOT EXISTS), which a versioned-migration tool would fight. The three SCHEMA.sql files ARE the schema-as-code β idempotent (CREATE TABLE IF NOT EXISTS / ADD COLUMN IF NOT EXISTS), heavily commented, rebuildable; re-applying the whole file converges prod to the file. Apply them by hand via the bluebut-db MCP (or psql) β that is the "migration" step; there is no schema:apply script (running DDL from Node would need pg, an unwanted dep).
npm run db:typesβ generateui/src/api/db-types.gen.ts(oneDb<Table>interface per bluebut-db table) from the LIVE PostgREST swagger, ZERO deps (justfetch, readsVITE_SUPABASE_URL/_KEYfrom.env). This is a drift-check reference for the hand-written runtime interfaces inapi/index.ts(GroupPost,FeedPost, β¦) β NOT imported at runtime (several UI types are bridge/view SHAPES, not raw rows). Regenerate after a schema change and diff to catch a stale interface (the class of bug behindis_premiumvspremium_listing_price_ore).
Jobs β every action is a job (the agnostic spine)
Every operator action runs as a job β the only way the UI calls an adapter. The job is the unit of work and observability; not tied to groups or FB.
Taxonomy β four layers (think Skyvern: WorkflowRun β Task β Step β Action)
| Layer | Term | What it is | Identity |
|---|---|---|---|
| Envelope | Batch | one operator intent fanned across N targets (bulk post/admin/scrape). A single action is a batch of one. | batch_id |
| Unit | Job | exactly ONE adapter run against ONE target (or a self-driven chain). The atomic observable + retryable unit. | job_id |
| Phase | Step | a named phase inside a job β step() β job.event(). Drives the timeline. |
β |
| Atom | Action | the atomic CDP op a step performs (click/type/nav/GraphQL). Implicit β the chromebox-traffic trail. |
β |
Source of truth: adapters/_lib/taxonomy.js (LIFECYCLE/TERMINAL, ActionType, OPS with read/write per adapter, classify/rollup/opFor). UI re-declares the static enums in ui/src/lib/taxonomy.ts (no cross-boundary import); taxonomy.test.mjs guards drift. New automation β the new-automation skill.
- The Job is the unit β a chain of adapters is still ONE job with more steps.
- A Batch owns 1..N jobs and home for cross-target concerns (routing, jitter, cooldown, stop-on-block). Don't model a batch as a fake mega-job.
- A Step is a phase, not a second adapter call (that's a second Job in the same Batch).
The durable job ledger β object-agnostic (adapters/SCHEMA.sql)
One generic job table in the entity-graph DB (bluebut-db; site-agnostic schema at root; per-site tables in adapters/<site>/SCHEMA.sql). Durable record behind the bridge's wiped-on-restart in-memory list.
CREATE TABLE job (
job_id text PRIMARY KEY, -- BLUEBUT_JOB_ID (same id threaded to HyperDX)
batch_id text, site text, cmd text NOT NULL, op text, access text, account text,
target_type text, -- 'group' | 'page' | 'person' | 'account' | null
target_id text, target_url text, target_name text,
status text NOT NULL, -- running | ok | failed | partial | blocked | content_unavailable
ok boolean, aborted_reason text, summary jsonb,
har_key text, -- bucket key of this job's full HAR: job/<job_id>.har.gz
started_at timestamptz, ended_at timestamptz, duration_ms integer,
created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now()
);
-- indexes: (target_type,target_id,created_at DESC), (created_at DESC), batch_id, (account,created_at DESC)
The link is the generic (target_type, target_id) pair. Any object's history = same store, no schema change: a group's jobs = target_type='group' AND target_id=<fb_group_id>; page/person/account analogous. site records which adapter family ran it.
- Write β the runner writes one row per run, best-effort:
status=runningon start, terminal state on end. - Read β
Q.jobsForTarget(type, id)(ui/src/lib/util.tsx);GroupJobs.tsxuses it. Point any new object-history surface at this query β never a per-object bespoke log. - Still group-shaped, fix when extending: target resolution only maps
--group/--pageargs βtarget_type(the map is[['group','group'],['page','page']]); account/person-scoped commands leavetarget_type=null. The terminal update only enriches fromresult.group_*and falls back totarget_type='group'. Generalize both before relying on page/person history.
The model β ui/src/state/jobs.tsx
runJob({ label, account, site, cmd, args, summarize })β one-shot adapter call; auto status/toast/failure-log.startJob({ label, account, site, cmd })β{ event, succeed, warn, fail }β a multi-step flow the page drives (e.g. promoteβverifyβaccept inutil.tsx).- Status:
running | ok | warn | failed.warn= completed but not verified (invite sent, role unconfirmed) β distinct fromok(verified).
Observability is built in
- Every step + terminal state β HyperDX via
shipLog(ui/src/logger.ts):[job:step|ok|warn|fail], taggedjob_id,ServiceName='bluebut'. job_idthreads UI β bridge β runner (BLUEBUT_JOB_ID), so the FB network trail (ServiceName='chromebox-traffic',source=runner) shares the id.- On failure: a
diagnostic(adapters/_lib/diagnostics.jsfailWith()) β screenshot + selector strategies. Rendered inline in the Jobs dock. - One
β§ copy logbutton per job (jobToText) β id + steps + diagnostic + ready-to-run HyperDX query.
Where jobs surface
- GlobalJobsDock (bottom-left, app-global, live; expand to watch its tab).
- Jobs page (
/jobs) β history + inline live tab + bridge job list (fetchBridgeJobs). - Live tab screencast β bridge relays box Chromium's
Page.startScreencastoverWS /screencast?account=Xinto a canvas (components/LiveScreen.tsx). Watching a job = watching the real anti-detect Chrome.
Rule for any new feature: run it through a job, ship steps by job_id, link via (target_type, target_id), attach a diagnostic on failure, expose copy-log.
Adapters β project-owned, run via the local runner
Everything an adapter needs lives in this repo β no install, no symlink, no global opencli:
bluebut/
bin/bluebut-adapter.mjs # the runner β argv β register β CDP β JSON. Its `finally`
# is the post-job pipeline: stop capture β saveGraphqlCalls
# β mineHar (atlas) β harvestEntities (PASSIVE WIRE) β buildHar
adapters/
SCHEMA.sql # site-agnostic shared schema (the `job` ledger)
_lib/ # shared helpers β NO adapter is a "site"; everything cross-
# adapter lives here, ESM, with a sibling <name>.test.mjs:
# netcapture.js β CDP capture (GraphQL bodies + WS frames)
# fb-schema.js β the ONE Relay decoder (parseGqlJson,
# extractInlinedBlobs, mineTypes/mineHar)
# passive-wire.js β ambient harvester: WIRES registry +
# harvestEntities() (reuses fb-schema's decoder)
# db.js β the one PostgREST write client (auto-steps)
# sessions.js Β· group-sync.js Β· taxonomy.js Β· diagnostics.js Β· β¦
facebook/<cmd>.js # one cli({...}) per file (active/pull adapters)
facebook/SCHEMA.sql # per-site FB entity tables (groups, people, members, β¦)
messenger/ # the Messenger box (site:'messenger') β its OWN subsystem,
# NOT under adapters/ (jobs/, lib/, decode.js, e2ee/)
Where new extraction code goes (by the two axes β see the Extraction doctrine):
- Active/pull read or write of ONE object β a new
adapters/facebook/<cmd>.js(cli({...})), run as a job. - Passive/ambient harvest that should ride EVERY job β a new entry in
WIRESinsideadapters/_lib/passive-wire.js({ name, typenames, extract }) β never a new adapter, never a new folder. The runner already calls it. - A new Relay-decode primitive (parsing, id reassembly, blob extraction) β
adapters/_lib/fb-schema.js, so there stays exactly ONE decoder both the atlas miner and the passive wire share. - Every
_lib/module gets a sibling*.test.mjs(run bynpm test:node --test adapters/_lib/*.test.mjs messenger/lib/*.test.mjs). New entity tables / column ALTERs β the matchingSCHEMA.sql(site-agnostic β root; FB entity tables βadapters/facebook/SCHEMA.sql).
opencli is a library (@jackwener/opencli/registry for cli({...}) + Strategy); the runner does dispatch + CDP lifecycle. Pinned to 1.7.8 (1.7.22+/1.8.x enforce access:'read'|'write' on every cli({...}) β a bounded future change).
β Extraction doctrine β wire-based GraphQL replay is the DEFAULT; DOM scraping is DEPRECATED
This is the operational form of the truth-ladder rule at the top of this file. Any adapter that READS data from FB reads it off the same persisted-GraphQL wire the page itself reads β it does NOT scrape rendered HTML. DOM scraping (innerText / querySelector / role="article" / aria-* / matching localized labels) is the bottom rung: it breaks on every locale change and every FB UI refresh, fakes timestamps, and can't see hidden data (folders, real ids). New read adapters are wire-based, full stop. The legacy DOM read-scrapers are a migration backlog, not a template to copy.
Two axes: where the data lives Γ how we consume it
Axis A β the SOURCE (where a fact lives) has three answers. Axis B β the MODE (how an adapter gets it) has two.
Axis A β source (3, ranked by the truth ladder):
| Source | What it is | A "wire"? | Read it via |
|---|---|---|---|
| GraphQL | The persisted-Relay data the page reads β whether XHR'd on demand (doc_id + end_cursor) or server-inlined into the document at first paint (<script type="application/json"> blobs: visibility, viewer_join_state, if_viewer_* gates). Same __typename/id records, two delivery paths. |
Yes | replay the doc_id (list-joined-groups) or parse the inlined blob (surface-extract skill) |
| Socket | Realtime push frames β Messenger/LightSpeed, MQTT publish+ack, DataGateway RPCs. Lives on a separate CDP target from the page. | Yes | browser-level WS tap (netcapture wsApply) β _webSocketMessages β ws-decode.js / messenger/decode.js |
| DOM | Rendered HTML / localized innerText / aria-*. Carries no __typename, no stability contract. |
No β the bottom rung, last resort only | querySelector (legacy scrapers β migration backlog) |
A wire = a structured source you read by typed field, not by label β GraphQL and Socket are wires; the DOM is not. "Wire-based" = read off a wire.
Axis B β consumption mode (2):
| Mode | What it means | When |
|---|---|---|
| Active (pull) | An adapter drives a surface and reads the wire it triggers β replay the doc_id, parse the inlined node, open the thread. On-demand, per-object. |
You need a specific object's data NOW. The default for a new adapter. |
| Passive (ambient) | A single agnostic harvester decodes whatever wire traffic already flowed on any job and writes the freshest value home β job-, site-, object-agnostic. Zero extra FB calls. | The fact rides along anyway (a member roster carries User avatars; a group response carries our own actor). Run group-sync, and if our profile pic changed, update it on the spot. |
The passive wire β adapters/_lib/passive-wire.js (full playbook: the passive-wires skill). Every job buffers its GraphQL + inlined-Relay bodies in-process. harvestEntities(network, ctx) runs in the runner's finally (sibling to mineHar), walks every Relay record once, merge-upserts what it recognizes. ALWAYS-ON β harvest + every capture prerequisite default ON (opt-out !== '0': BLUEBUT_PASSIVE_WIRE/_CAPTURE_GQL_BODIES/_CAPTURE_PAGES/_WORKER_HTTP; WS tap always attaches), at BOTH sites (box runner finally + local rolling capture). Recognizers, three kinds: (1) __typename-routed entries in the WIRES registry; (2) __typename-less signature-detected handlers in collect() (Relay strips __typename off fragments β group-about-fragment, join-config, invite); (3) self-scoped freshenOwn* wires. Routed by __typename+numeric id+strong signature, never a DOM label.
β THE FULL-OBJECT MANDATE β mirror FB's object into ours. When you wire an entity, harvest every field the node carries β the aim is to replicate FB's object in our DB; a field skipped now is an extra FB call (= detection risk) later. Discipline: (1) dump the whole node first and read its full key union before writing the extractor; (2) reassemble across fragments β match by signature field + numeric id, merge by id (never assume one node = the whole object); (3) structural fields only (
privacy_info.icon_name), a display string only when FB exposes it nowhere else; (4) grow the schema to fit βALTER β¦ IF NOT EXISTS+NOTIFY pgrst(expected, not scope-creep); (5) reject phantoms β require a strong entity-only signal + a test; (6) prove it live against bluebut-db. Every wire change updates the docs in the same commit (this field list, thenew-automationskill, a memory entry). Thegroupwire is the reference.
personβUsernodes βfb_person(the ONE User/lead/actor hub, canonical on numericfb_id; INSERT-new-only so a passing node never clobbers a richer scrape). fb_user + fb_profile were redundant second/third User tables β folded into fb_person + dropped 2026-07-08. See [[reference_fb_identity_model]].groupβGroupnodes βfb_group(the FULL about-object: name, member_count, privacy viaprivacy_info.icon_name, cover,has_membership_questions, description viadescription_with_entities, location +location_fb_idfromgroup_locations[],created_timefromgroup_history). FB spreads these across the headerGroupnode AND__typename-less about-card fragments β a signature detector picks up the fragments,collect()reassembles byfb_group_id. Active twin =probeGroupWire(_lib/facebook.js, same inlined/aboutblobs +group_admin_profilesroster), called WIRE-FIRST byprobeGroup(DOM drift-fallback only).pageβPagenodes β ENRICH-ONLYfb_page(PATCH name/username matched bypage_fb_id). APageis its OWN typename, never folded into thefb_personUser hub via the passivepersonwire (would poison it). NB: a Page CAN still legitimately appear IN fb_person as an ACTOR/lead when it POSTS (a company page posting a listing) or is a group member β fb_person is the actor hub,is_companydistinguishes; the rule is only that the passiveperson/Userwire must not create Page rows. A barePagenode in passing traffic doesn't carry theasset_id(page_key), so this wire can't INSERT β that's themanaged-pagewire's job (below). An account can act AS a page, so a Page's pic also feeds the avatar wire.managed-pageβ aProfileSwitcherEligibleProfilenode (FB inlinesprofile_switcher_eligible_profileson ~every document) β INSERTfb_page+ thefb_account_pageaccountβpage link. The ONE node carryingdelegate_page_id(=asset_id=page_key), the key a barePagelacks β so unlike the enrich-onlypagewire it creates both rows. Requiresdelegate_page_id(β a real managed page). SharestoPageRowwith activelist-pages(_lib/pages-wire.js); additive. Also denormalizes onto the account's KEYVAULT row (managed_pages = [{page_key, asset_id, page_fb_id, name, username}], viafreshenManagedPages) so a downstream LOADER (fb-bot) can load an account β act AS a page without touching bluebut-db. bluebut-dbfb_account_page= source of truth;keyvault.managed_pages= its loader-facing projection.postβ group feedStorynodes βfb_post(merge-upsert onpost_url) + poster promotion, viaadapters/_lib/post-sync.js. Wire twin of the deprecatedscrape-group: reads STRUCTURAL fields off the Story β numericpost_id(dedup key),permalink_url, epochcreation_time, owningUser, body via a bounded comment-safe walk β off any job that loaded a group feed. Scoped to/groups/. Poster promotion =fb_personupserted on canonicalfb_id+fb_person_signal'posted' (samepost-sync.jshelperget-group-feedcalls). Passive wire does NOT feed the bofrid.dev fb-bot pipeline (that'sget-group-feed's deliberate write).commentβ group-postCommentnodes βfb_comment(merge-upsert oncomment_id=legacy_fbid) viapost-sync.jsextractStoryComment/persistComment. Reads STRUCTURAL fields:legacy_fbid(dedup),feedback.id,feedback.url(β derivespost_url+group_fb_idfrom the comment itself),body.text, epochcreated_time,depth,author{id,name,url}, parentcomment_direct_parent.legacy_fbid, reply count. Reactions COUNTS-ONLY (feedback.reactors.count+top_reactions) β no per-reactor table (paginated query = detection risk). Scoped to/groups/.fb_comment.post_urlFKsfb_postβpersistCommentretriespost_url=nullon 23503. Comments ride the permalink document (inlined Shape B) / UFI XHR, NOT the feed wire; active twinget-post-comments(_lib/comment-wire.js, pure inlined read, no DOM click).message(LightSpeed twin β NOT a RelayWIRESentry) β Messengerfb_thread/fb_message/fb_contact/fb_thread_participant/fb_message_reactionviamessenger/lib/passive-messages.jsharvestMessages. Threads ride FB's LightSpeed step-VM payload (lightspeed_web_request), the SAME wiremessenger/sync-inbox/sync-threadread.collectLsCallsgathers LS blobs from all three passive sources β/api/graphqlresponses, SSR-inlined initial sync (pages[].html), binaryws/lightspeedframes (lsPseudoCallsFromWs) β then runs the SAMEextractPayloadsβdecodeCapturedβpersistTables(ADDITIVE) path. E2EE caveat:thread_type=15threads carry METADATA + the ENCRYPTED envelope, never a plaintext body β bodies come only from the bluebut-e2ee daemon. GatedBLUEBUT_PASSIVE_MESSAGES(default on); wired at all three harvest sites.is_selfoffreadActingIdentity. Test:passive-messages.test.mjs.join-configβ a group's JOIN-DIALOG node (GroupsCometRequestToParticipateOnJoinDialogQuery'sgroup) β the join contract:fb_groupflags (is_rules_agreement_enabled,has_email_verification_question,join_questionnaire,join_email_config) +fb_group_join_question+fb_group_rule, viajoin-config-wire.jsextractJoinConfig+group-sync.jsupsertGroupJoinConfig. Node is__typename-less β a STRONG signature detector (isJoinConfigNode: numeric id +community_participation_questions[]+is_rules_agreement_enabled) routes it incollect(). Additive-only (activeget-group-join-configowns the authoritative replace). NB: FB's legacyhas_membership_questionsβ the moderncommunity_participation_questionsgate β this wire never writeshas_membership_questions(thegroupwire owns it).notificationβ EVERY notifications-jewel notif βfb_notification(per-account:notif_type/actor_fb_id/target_id/body/seen_state/created_time). Signature-detected off the notif'strackingJSON blob (STRUCTURALnotif_type, never localized body). The jewel (CometNotificationsDropdownQuery) rides ~every job β comments/mentions/tags/friend-accepts self-harvest. The group-invite subset also lands infb_group_invite. Additive;bodydisplay-only. Scoped toctx.account.my-membership-edgeβ the VIEWER's own edge off aGroupnode carryingviewer_join_stateand/or the per-node admin gates βis_membertofb_account_group(always safe, additive) + ourroletofb_group_member(OUR fb_id, fromreadActingIdentity). Role is written ONLY when the reliableif_viewer_is_admin/if_viewer_is_not_admingate is present (a group-page visit inlines it) β NEVER a guessedmember, so a passingall_joined_groupsnode can't downgrade a known admin (the roster DELETE stays banned passively;get-user-groupsowns the authoritative role split via theADMIN_MODERATOR_GROUPSlistType). So visiting any group we admin self-heals our admin edge.- own-avatar β the account's own actor (
Useror acted-asPage) β if the scontent asset token changed,saveAvatarToStorage+ repointavatar_urlin keyvault (token cached inavatar_src; matched by storedfb_id). Bytes βfb-mediaMinIO bucket (_lib/media-store.js), served via the bridge's publicGET /media/fb-media/<key>(un-tokened, unlike/har/blob).avatar_url = ${BLUEBUT_MEDIA_BASE}/media/fb-media/avatars/<account>.<ext>. - own-session β when FB server-asserted the account is live this job (
sessionSaveDecision), re-dump the jar to keyvault β but ONLY under a 6-rung gate so it can never replace a working session with a broken one: (1) dumped jar logged-in (c_userβ 0 +xs), (2) complete (datrpresent), (3) FB served an authenticated document (readActingIdentitynon-zeroUSER_ID), (4) no integrity error (accessFromNetworkβ {checkpoint,blocked,auth} β a walled session still inlines aUSER_ID, so the access verdict closes that hole), (5) same account (actingId===c_user), (6)xsrotated. Prior jar stashed tostorage_state_prev; stampsstorage_state_saved_at. - Device bundle is NOT passive (deliberate). The full anti-detect profile β FB device tokens (
datr= the ~2-year FB device id;sb/dbln/fr) + client fingerprint (UA/Client Hints, screen/DPR, hardwareConcurrency/deviceMemory, tz/locale, WebGL vendor+renderer, canvas/audio hash, platform, webdriver tell) β is captured ACTIVELY bypersistDeviceProfile(_lib/device-profile.js) at login + load-session only, stored asdevice_profile+fb_device. Binding one stable profile per account is what stops the logouts. No passive device wire. - Writes are additive merge-upserts (or enrich-only PATCH for pages) β never the destructive roster DELETE
upsertMembersdoes (passive sees one node, not an authoritative set); role edges + accountβpage links are deliberately NOT written passively. Everything best-effort; never touches the job result. - Backlog (high-value, doc_id already known): the notifications jewel is the free-rider β
CometNotificationsDropdownQuery(doc_id27646303031640503), fired on ~every page, andget-user-groupsalready decodes group-admin invites off it (notif_type=group_invited_to_groupβfb_group_invite). Add aninvite/notificationwire toWIRESthat recognizes those story nodes passively, so admin/group invites self-refresh on any job instead of only whenget-user-groupsruns. (Future: a generalfb_notification.)
The (active) wire pattern (copy from a reference impl, don't reinvent):
- The cloakbox wiretap (the surface-extract skill β captures every seed's traffic incl. WS) on the target surface β its backing
doc_id+variables+ the response path (β¦edges[].node+page_info). - Author
adapters/facebook/<cmd>.jswith an in-page replay (pageExpr): readfb_dtsg/lsd/uidLIVE from the page,fetch('/api/graphql/', β¦)with thatdoc_id, page onend_cursoruntilhas_next_page:false. - Extract by structural field name + stable enum key (
viewer_join_state,tab_groups_list.edges) β NEVER a DOM label. doc_idROTATES β keep it in an env-overridable const and re-capture (cloakbox wiretap) when the query starts erroring; fail loud (failWith) with the doc_id in the message. See the doc_id lifecycle doctrine below for the exact discipline.
β doc_id lifecycle β the discipline (enforced by eslint + skill fb-doc-id)
A doc_id is FB's persisted-query id: the server stores the query text; the client sends id + variables + fb_dtsg. How every FB read AND mutation is replayed. We handle it like the mature players β hardcoded constants, patched by hand, detected-on-failure.
1. Per-FB-build, NOT per-account. Same id for every account on the same FB release. Per-account/session = the tokens + variables (fb_dtsg/lsd, uid=c_user, vars), read LIVE off the page each run. Never store fb_dtsg.
2. Pin every doc_id in an ENV-OVERRIDABLE const keyed by FriendlyName (mirrors mautrix-meta's hand-patched {DocID, FriendlyName} map):
const DEMOTE_DOC = process.env.BLUEBUT_DEMOTE_ADMIN_DOC_ID || '27831756479762517'; // GroupsCometMembersRemoveAdminMutation
Env override = patch a rotated id without a rebuild. A bare inline doc_id literal is an eslint error β named const + env fallback + FriendlyName comment. Two flavors: doc_id (default) and client_doc_id (client-persisted, e.g. IG avatar); capture tells you which β don't assume.
3. The PAGE is the truth-source; a const can drift. doc_ids live in FB's JS bundles β discovery = load the page, capture the id (cloakbox wiretap). Rotation check = capture live, grep the FriendlyName, diff vs the const. Read the id live off the page instead of pinning when you can (the Messenger box does β zero hardcoded doc_id).
4. Rotation is DETECTED, never scheduled. A stale doc_id can't fake success β FB returns a loud PersistedQueryNotFound-class error (HTTP 400, or 200 + {"errors":[β¦]}), never silent wrong data (the whole reason wire > DOM). failWith() with the doc_id in the message; npm run canary:admin (cron) catches a rotation the day it ships. Re-capture, swap the const/env, done.
Reference implementations (read before writing a new extractor):
list-joined-groups.jsβ joined groups (doc_id9974006939348139). The wire replacement forlist-groups.get-activity-log.jsβ paginated stories (doc_id27054591887516604,category_keyswapped β 134 categories one query).set-account-language.jsβ a mutation replay (doc_id23915562351374516) β proof WRITES can be wire.create-group-post.js+_lib/create-post-wire.jsβ group-post mutation (ComposerStoryCreateMutation, doc_id28568493719405999). Text (own + act-as-Page) fires the persisted mutation directly; the response hands back id+permalink (no feed re-scan). Act-as-Page is wire: identity rides the envelope asav===__user===input.actor_id===<page fb_id>signed by the manager's unchangedfb_dtsg(i_user-flipped session shares it). IMAGE posts still fall to the DOM composer. On success mirrors intofb_post(persistOwnPost,authored_by_us, numeric id + base64story_gid).delete-group-post.js+_lib/delete-post-wire.jsβ DELETE twin (useCometFeedStoryDeleteMutation, doc_id36213403264942057). β targetsinput.story_id=base64story_gid, NOT numericpost_id(resolveStoryIdExprreads it off the permalink's inlined Relay). Ownership-gated onfb_post.authored_by_us; re-reads to confirm gone; tombstones the row.- The whole Messenger box (
messenger/) β DMs off the LightSpeed wire.
External references (prior art, cloned in meta-reverse-engineered/): the "Reverse-Engineering Messenger" case study (intuitiveexplanations.com/tech/messenger β FB web request shape); raxod502/unzuckify (our session model: durable jar reused, fb_dtsg re-scraped live, login only on auth-fail; doc_id discovery by grepping the served JS for the FriendlyName β what bin/canary-doc-ids.mjs does); mautrix/meta (the Go client decode.js is ported from β hand-patched {DocID, FriendlyName} map).
Migration backlog β DOM read-scrapers β wire replay (each entry: don't reintroduce the DOM scraper):
list-groups/list-member-groups/list-group-invitesβ DELETED β replaced bylist-joined-groups+get-user-groups.sync-messages+adapters/facebook/send-message.jsβ DELETED β superseded bymessenger/jobs/sync-inbox/send-message(LightSpeed wire).scrape-groupβ DELETED β replaced byget-group-feed(GroupsCometFeedRegularStoriesPaginationQuery; MANY groups per warm session via--groups; full structural post viapost-sync.jsβfb_post+fb_person+fb-bot feed). Passivepostwire = ambient twin.scrape-new-membersβ WIRE βActivitiesListPaginationQuery(doc_id27418668794433773) +GroupsCometAdminActivityLogItemSeeDetailsDialogQuery(doc_id37221570147456563) via_lib/member-request-wire.js. Full member object (fb_id, profile_url, account_created_at, city+city-page-id, education/work/about, membership+rules enums, questionnaire answers incl. email).scrape-profileβ WIRE β inlined Relay (Shape B,_lib/profile-wire.js),Userreassembled across fragments β identity + all public about fields/sections + timeline_context, stored infb_person.profile_raw. Privacy-gated.list-friendsβ WIRE β inlinedviewer.all_friends(Shape B) off/friends/list. Fail-loud if it overflows the inlined page and the pagination doc_id is unset.list-friend-requestsβ WIRE β incoming = inlinedviewer.friend_requests; outgoing =FriendingCometOutgoingRequestsDialogQuery(doc_id9355113201284242).get-group/ingest-groups/sync-new-groups(sharedprobeGroup) β WIRE βprobeGroupWire(_lib/facebook.js) reads the about-object off inlined/about+ thegroup_admin_profilesroster on/members/admins; wire-first, DOM drift-fallback only. LIST enumeration also WIRE via sharedenumerateJoinedGroups(_lib/joined-groups-wire.js,GroupsCometAllJoinedGroupsSectionPaginationQuerydoc_id9974006939348139, paged to exhaustion). Prune-safety requiresenumeration.complete.list-pagesβ WIRE β inlinedactor.profile_switcher_eligible_profiles.nodes[].profile(Shape B,_lib/pages-wire.js): Page fb_id/name/pic/delegate_page_id/is_profile_plus. GOTCHA: read LIVE DOM<script data-sjs>(a bgfetch()returns a stripped shell), walk depth β₯60.get-page-insightsβ still DOM, NOT a doc_id case: Business Suite streams figures over the DataGateway WebSocket (gateway.facebook.com/ws/streamcontroller) as a subscription (useMAIBAAsyncMessageSubscription, doc_id27434986989461233). Wire migration = a DGW-subscription decoder (future).
Mutations β wire replay by default; no DOM fallback (rotation fails loud, caught by npm run canary:doc-ids). Rule #5 binds: never trust the send; re-read the end-state. DONE wire-only (doc_ids): create-group-post (ComposerStoryCreateMutation), create-group (useGroupsCometCreateMutation 37309644325300927), invite-to-group (useGroupsCometInviteLinkCreateWithNotificationBulkMutation 27043683338577412, _lib/group-invite-wire.js), accept-friend-requests (FriendingCometFriendRequestConfirmMutation 27351021931180810, _lib/friend-wire.js), add-friend (FriendingCometFriendRequestSendMutation), update-group-admins promote/demote/remove (GroupsCometMembersInvite/RemoveAdminMutation + GroupsCometRemoveMemberMutation 27148641238078140), approve-join-request (GroupsCometApprove/DeclinePendingMemberMutation 27132805893014911/27119718754378916), delete-group-post (useCometFeedStoryDeleteMutation 36213403264942057, input.story_id=base64 story_gid), set-account-language. Input SHAPE is captured off FB's OWN served JS (the commit-function variables:{input:{β¦}}); doc_id from _facebookRelayOperation or a browser-level CDP tap (autoAttach flatten β catches worker-target mutations the page misses). Capturing a lazy mutation module needs its UI state to exist first. Email-based FB jobs (invite-email-to-group, accept-group-invite-email, add-account-email) were DELETED.
Runner in-page-fetch capture β FIXED (adapters/_lib/gql-tap.js). The in-page page.evaluate fetch('/api/graphql') a replay fires does NOT surface on the CDP Network.* socket (a replay job used to show "0 graphql"), so the passive wires + schema miner + fb_graphql_call got nothing from the jobs richest in FB data. Now the runner mounts an in-page GraphQL tap (buildGqlTapSource β wraps fetch+XHR over the SAME bridge page.evaluate uses, clones each /api/graphql response before the app reads it, reports via the __bbGqlTap CDP binding + a window-array fallback) and foldGqlTap merges them into network.gqlCalls at stop() β identical shape to a natively-captured XHR. So every wire-replay job now feeds the passive wires: proven live β list-joined-groups went from 0 graphql β folded 13 Β· 20 req Β· 13 graphql β passive harvest 90 group Β· 90 membership. Gated BLUEBUT_INPAGE_GQL_CAPTURE (default ON). The source:'inpage_tap' tag marks folded calls.
Run an adapter
The bluebut-rest DB key is sourced from keyvault (bluebut/config/rest-service-key) by _lib/db.js β no env export needed (keyvault needs no creds: defaults to keyvault.hostbun.cc + bearer ddash). The runner is cloakbox-only β it resolves the account through the cloak-cdp HTTP API (POST /api/call, key ddash), no OPENCLI_CDP_ENDPOINT:
node bin/bluebut-adapter.mjs adapters/facebook/<cmd>.js --account <slug>
# β cloak-client POST https://cloak-cdp.hostbun.cc/api/call {tool:cdp_load,args:{slug}}
# (gates + egress-verifies + injects the session) β drive β {tool:cdp_save}.
# Only accounts with a cloakbox device run (catalog verdict 'run'; the other
# ~300 need a device minted β add-account skill).
# Override the platform base/key with CLOAK_API_BASE / CLOAK_API_KEY (default ddash).
β NEVER ssh-tunnel to pbox β the gateway is PUBLIC
cdp_loadreturns a public gateway origin (https://cloakbox.hostbun.cc?fingerprint=<seed>). Both drive paths reach it from any box with no tunnel:resolvePageWsβ the publicwss://cloakbox.hostbun.cc/fingerprint/<seed>/β¦(openclipageAPI), andcdp_driveβ server-side over the publicPOST /api/call. Proven live from a laptop:node bin/bluebut-adapter.mjs adapters/facebook/whoami.js --account joel-skolaβok:true, no tunnel. Do NOTssh -fNL 39555:127.0.0.1:39555 pboxand do NOT setCLOAK_ENDPOINT_BASEto a tunnel β that loopback path is retired.CLOAK_ENDPOINT_BASEsurvives only as a legacy origin-override (private/on-box gateway); unset (the default) uses the public origin. Collapsing tocdp_drive-only (kill the direct ws) is an OPTIONAL simplification, not a reachability need.
Sanity-check the platform + list runnable accounts without launching anything:
curl -s 'https://cloak-cdp.hostbun.cc/api/health?key=ddash'
curl -s 'https://cloak-cdp.hostbun.cc/api/launchable?key=ddash&service=facebook&runnable=1&summary=1'
- Streamed events β stderr (JSON lines); final result β one JSON object on stdout.
fbcli() { # usage: fbcli <account> <cmd> [argsβ¦] (cloakbox-only; over the public HTTP API)
local acct="$1" cmd="$2"; shift 2
node "$PWD/bin/bluebut-adapter.mjs" "$PWD/adapters/facebook/$cmd.js" --account "$acct" "$@"
}
From the dashboard
runCli/runCliStream POST to the bridge (VITE_BRIDGE_URL); it maps {site, cmd} β adapters/<site>/<cmd>.js, spawns the runner, streams stderr as SSE. Missing file β 404. Hosted is not view-only: prod bakes VITE_BRIDGE_URL β a token-gated bridge (bluebut-bridge.blpk.cc, Coolify, Dockerfile.bridge); BRIDGE_TOKEN gates it, build bakes VITE_BRIDGE_TOKEN so bridgeAuth() sends the bearer.
Source of truth + discovery
adapters/IS the source. Vendored originally fromdevdashco/toolbox, owned here now β toolbox is NOT in the runtime path. Edit the file; next run picks it up. Cherry-pick upstream fixes manually.- Box:
chromebox-fbathttps://fb.chrome.blpk.cc(imagedevdashco/toolbox@chromebox-fb). The old sharedchrome.blpk.ccis NOT ours. GET <box>/chrome/profilesβ accounts,cdp_url, spawn status.<box>/selkies/?token=<VNC_PASSWORD>β Selkies remote desktop (UI "VNC" button,vncUrl()). Token =SELKIES_MASTER_TOKEN=VNC_PASSWORD(ddash), proxied at/selkies/byproxy.py. noVNC (/vnc/) is a fallback.
Adapter reference (facebook site)
- Session/identity:
load-session(from keyvault via the account's proxy),login(re-auth stale creds),whoami. Page act-as =--as-page(see the pointer-model block);load-page-sessionis retired. Fresh onboarding = theadd-accountskill (cloakbox), not a box adapter. - Messaging: via the Messenger box (
site:'messenger') βsync-inbox(βfb_thread/fb_message, all folders) +send-messageβ (BROWSER-FREEbrowser:falseβSendMessageTaskover the account's PERSISTENT LightSpeed socket via daemon/ls-send, type-15 β whatsmeow; no cdp_load, no DOM composer). - Groups β discovery/membership:
list-joined-groupsβ (doc_id9974006939348139),get-user-groupsβ (groups + role + invites),get-group(detail+admins; write-back by default--persistβfb_group+fb_group_member+fb_account_group),get-group-join-configβ (GroupsCometRequestToParticipateOnJoinDialogQuerydoc_id27156146040745454,_lib/join-config-wire.jsβ join questions/rules/flags),ingest-groups,create-groupβ (useGroupsCometCreateMutation),join-group,accept-admin-invite,approve-join-requestβ (GroupsCometApprove/DeclinePendingMemberMutation),invite-to-groupβ (_lib/group-invite-wire.js). - Groups β editing (admin):
update-group-adminsβ (--promote/--demote/--remove, self-verifying),update-group-location,update-group-description,update-group-cover. - Groups β posting/scraping:
create-group-postβ (ComposerStoryCreateMutationtext, own + act-as-Page--as-pageβgroup_post+fb_post; DOM fallback for IMAGE only),delete-group-postβ (useCometFeedStoryDeleteMutationdoc_id36213403264942057, targets base64story_gid, ownership-gated, verifies gone),get-group-feedβ (feed replay βfb_post+fb_person+fb-bot feed;--group/--groups/--all= every joined group,--max-groups Nstalest-first),scrape-new-membersβ (_lib/member-request-wire.js),get-post-commentsβ (inlined permalink,_lib/comment-wire.jsβfb_comment, no DOM click),get-group-insightsβ (GroupsCometForumInsightsGrowthRootQuerydoc_id26096051466719514, admin only). - Social graph:
add-friendβ,accept-friend-requestsβ,list-friend-requestsβ,list-friendsβ,list-users. - Pages:
list-pagesβ (_lib/pages-wire.jsβfb_page/fb_account_page),get-page-insights(DOM β BizKit DGW WS subscription, not a doc_id).
Bulletproofing: every mutating admin adapter follows never trust a click.
failWith()attaches screenshot + context + strategies and reclassifiesstatus:'blocked'on an FB block/checkpoint;okWith()reports the verified end-state. The UI chain (promoteAdminChain/addAccountAsAdmin) adds an independentget-groupconfirmation βverdict(verified | pending_unverified | blocked | failed). Drift canary:npm run canary:adminruns the full cycle, ships to HyperDX, exits non-zero on drift.
Semantic model
Layer 1 β Infrastructure: are our accounts working?
FB account record β keyvault (canonical). Client = adapters/_lib/keyvault.js (MCP keyvault.hostbun.cc/mcp, bearer ddash); sessions.js reads/writes it for site==='facebook'.
One namespace, owner-as-field: facebook/accounts/<slug|id> (ACCOUNT_PREFIX) holds ALL FB accounts. Full rows carry owner=bluebut (R/W); droidfleet farm blobs carry tag df-sync/no owner (droidfleet is their writer, bluebut reads them as a sync source). Tell ours apart by owner, NEVER by a key prefix.
keyvault is the SOLE account store β chromebox_sessions is retired. kv_list caps at ~100 β use kvListAll()/kvListAccountRows().
β PAGES ARE POINTERS, NOT ACCOUNT ROWS β the strict pointer model
A Facebook Page has no login of its own β FB has no page credentials; you "act as" a Page by overlaying
i_user=<page_fb_id>on its managing PROFILE's authed session. So the invariant:
- ONE keyvault account row per real login identity = the manager PROFILE (
account_type='profile', its creds +storage_state+ proxy + device). A keyvault row is NEVERaccount_type='page'β enforced at the write boundary (saveSessionthrows onaccount_type:'page'; test insessions.test.mjs).- Pages live only as POINTERS:
fb_account_page(bluebut-db = source of truth) + the manager row'smanaged_pages[](kv projection the loader reads). Each pointer ={page_key(=asset_id), page_fb_id, name, switch_method}, self-healed by themanaged-pagepassive wire.- Act as a page =
--account <manager> --as-page <page_fb_id|name>(runnerresolvePageActorreads the pointer off the manager'smanaged_pages; overlaysi_user; VERIFIES FB flipped). No page account, no jar copy, no separate proxy β the page rides the manager's session/proxy/device.- Retired: the standalone
facebook/accounts/<page>rows +load-page-session/loadPageSession(a page row was a drifting copy of the manager's jar+i_userthat duplicated identity/proxy/fingerprint and caused the philipβbofrid.se-page identity corruption). The 5 legacy page rows were purged 2026-07-07 (backup in the migration scratchpad);load-page-sessionnow throws a deprecation β--as-page. The passivefreshenOwnIdentitynever infersaccount_type(ownIdentityPatch).- UI: the Accounts grid nests Pages under their manager sourced from
managed_pages(synthetic view-only rows,Accounts.tsxbyManager/pageSessions); a Page is view-only with one action (Act as Page β--as-page), never an editable account row.
Bridge/UI: GET /accounts/registry (canonical list), POST /accounts/sync-keyvault, POST /keyvault/account (writeback).
The UI/registry sources the account LIST + proxy DISPLAY from keyvault (the sessions query β /accounts/registry); the box /accounts view only AUGMENTS live runtime state (spawn status/pid/idle) β never gate account data on box reachability (a truth-ladder violation: read the canonical store, not the presentation surface). All account R/W goes through keyvault.js; chromebox_sessions REST is dead. proxy is keyvault-canonical (only cdp_port is box-local).
~90+ accounts (named + ~80 df-* farm), all account_type='profile' (Pages are pointers). Named profiles: pojtie, joel-skola, mrkoll, william, philip, oskar-wik. Active: pojtie, joel-skola. philip manages 9 pages (incl. bofrid.se).
joel-skolais the TEST account. Use it for any manual/live end-to-end check (cloakboxcdp_loaddrives, wire probes, passive-wire smoke tests). It's the default cloakbox test account (migrated 2026-07-08, seed 13269) and manages the Uthyra.se page. Don't burn a real outreach account on a test.
Layer 2 β Entity graph: what do we know about the FB world?
| Entity | Table (bluebut-db) | Source |
|---|---|---|
| FB group | fb_group |
list-joined-groups, get-user-groups, get-group |
| Account β group | fb_account_group β is_member only |
list-joined-groups, get-user-groups |
| Group member edge (role) | fb_group_member β fb_user_id is an ACTOR (User in fb_person OR Page in fb_page; no FK) |
get-group |
| Person / lead / actor (the ONE User hub) | fb_person (canonical on fb_id) |
scrape-new-members, get-group-feed, passive person wire |
| Messenger contact (per-viewer) | fb_contact |
messenger sync-inbox |
| Observable event | fb_person_signal |
scrapers |
| Group post | fb_post |
get-group-feed, passive post wire |
| Classified listing (offer) | post_extract_listings |
classify-posts (LLM) |
| Classified request (demand) | post_extract_seekers |
classify-posts (LLM) |
| Group-post comment | fb_comment |
get-post-comments, passive comment wire |
| DM thread/message | fb_thread / fb_message |
messenger box sync-inbox (LightSpeed wire) |
| Group post log | group_post |
broadcast flow |
fb_group_memberis FB's membership edge β the SINGLE source of admin-ness. One row per(fb_group_id, fb_user_id)withrole(admin | moderator | member). No separate admin table, nois_admin/my_roleonfb_account_group. "Our admins of group G" is derived:role='admin'β© our accounts' fb_ids (ourAdminsOfGroupinutil.tsx). Roster roles written viaupsertMembersin_lib/group-sync.js.fb_user_idis a bare numeric ACTOR id (NO FK): a group member/poster is a User (fb_person) OR a Page (fb_pageβ a page joined-as-page or posting-as-page; our own pages are members of 100+ group slots). A single-table FK would reject Page members.fb_personis the ONE User/lead/actor hub β canonical on the numericfb_id(UNIQUE; the only stable FB identity β vanity/pfbid/profile_url are aliases; see [[reference_fb_identity_model]]). Holds Users AND company/posting Pages (an actor), distinguished byis_company/company_name. Fields:fb_id,profile_url(last-seen alias),email(admin_scrape/questionnaire β high value),intent(seeker|landlord|unknown, set byclassify-posts),intent_confidence(explicit|inferred),outreach_status(new|contacted|replied|β¦, the DM-dedup surface),first_seen_channel,city. fb_user + fb_profile (redundant User tables) merged in + dropped 2026-07-08.fb_person_signalβ immutable event log, one row per action (joined_group,posted,dm_reply). Append only.group_postβ every group post Bluebut made:fb_group_id,group_url,chromebox_account,body,post_url,status(pending|posted|failed|skipped),skip_reason(cooldown|no_session|access_denied|blocked),posted_at.
Layer 3 β Activity log: what have we done?
fb_sent_messages (DMs) + fb_broadcast_posts (group posts) live on bofrid.dev β legacy fb-bot, read-only (queried for dedup + cooldown). Never write it.
Facebook anti-automation β the most important operational constraint
FB monitors posting behaviour across accounts/groups; it shapes every architectural decision. The accounts are the asset β temp-block = can't post/scrape/DM; perm-block = gone.
What FB detects: velocity (3+ groups/~1hr spikes risk; 2+ hrs rarely triggers), content fingerprinting (identical text = spam hash), timing regularity (use jitter), cross-account coordination (overlapping content/window), IP reputation.
Rules enforced in code:
- Min 8 min between posts/account, randomised 8β15 min. Never fixed.
- Max ~4 posts/account/hr. Exceed β route to another account or slow down.
- Per-group cooldown β₯ 7 days. Check
group_post(bluebut-db, posted) andfb_broadcast_posts(bofrid.dev, success) before including; show "last posted N days ago" for override. - Text variation. Never identical text to >3 groups/session β warn + prompt.
- Stop on block.
aborted_reason~ "blocked"/"restricted" β stop the batch for that account, surface prominently. - Dedup before DM β "have we messaged this person? block". PERSON-level, keyed on the canonical numeric
fb_id(messenger/lib/dm-dedup.jsalreadyMessagedβ checksfb_message is_selfacross the peer's threads + thefb_personhubmessaged_at/outreach_status;stampMessagedon send). Wired intosend-messagebefore ALL paths (plain/E2EE/DOM) via--abort-on-prior(default on). The fb-bot legacyfb_sent_messages(bofrid.dev) is folded in asoutreach_status='contacted'where resolved (the ~10kβfb_id import is a pending backfill).
Two acquisition channels (don't conflate)
- Channel A β Admin scraper (
scrape-new-members, admin, WIRE): replays theadmin_activitiesparticipation-change list + the per-activity "See details" dialog (_lib/member-request-wire.js) β the FULL member-request object: numeric fb_id, real profile_url,account_created_at, city (+ city Page id β geo), education, work, about_me, membership + rules-agreement enums, the structured questionnaire answers (where email + intent live), and the action enum βfb_person+fb_person_signal. Highest value β email reaches them via the Bofrid platform. - Channel B β Feed scraper (
get-group-feed, member, wire): replays the feed GraphQL for/groups/<id>β post content + poster + engagement/media βfb_post+ lazyfb_person.
Categorization (in-repo, WIRE + LLM β the funnel Β§3, SHIPPED 2026-07-08): classify-posts (browserless job, Strategy.PUBLIC) sends each scraped fb_post to llm.hostbun.cc (claude-haiku-4-5, _lib/llm.js + _lib/judge.js) β ONE typed struct: post_type (rental-listing | listing-seeker | none | unknown) + is_company/company_name + confidence + structured housing fields. By type it writes post_extract_listings (offers: rent/rooms/sqm/area/company/contactβ¦) or post_extract_seekers (demand: budget/desired_area/household/move_inβ¦), stamps fb_post.classified_at, and refreshes the fb_person.intent cache. Replaces the old regex classifyPost + the external fb-bot classifier. NOT fb_judgment/housing_extract (an earlier draft β superseded by the two standard rental tables).
Signal β Person promotion: when get-group-feed/the passive post wire resolves a poster, promotePersonFromPost (post-sync.js) upserts fb_person on the canonical fb_id + links the post + appends fb_person_signal 'posted'.
Group post broadcast model
Different from fb-bot's listing broadcaster: content is a campaign message, groups picked manually by the present operator, output β group_post (bluebut-db), dedup (group_url, 7 days), no queue/poller/approval/manager β the operator IS the approval; direct runCli per group with live feedback.
Account routing (computed fresh): (1) fb_account_group is_member=true (admin via fb_group_member role=admin β© our fb_ids) β (2) filter to active session (last_status='logged_in') β (3) prefer admin β (4) prefer UI-selected β (5) none active β skip no_session.
β Three things are called "post" β never conflate them
The Broadcast page (ui/src/pages/Broadcast.tsx) is the ONE "one content β many groups" surface (the old separate Listings page was consolidated INTO it 2026-07-09; /listings is now an alias route). Its listing table + the per-listing detail drawer (ui/src/pages/broadcast/ListingDetail.tsx) sit on top of three confusable objects:
| Term | What it is | Table | Identity / link |
|---|---|---|---|
| Listing | the bofrid rental OBJECT (source content) | bofrid_listings on bofrid PROD supabase (remote; read live via bridge /listings/ads) β NOT in any bluebut SCHEMA |
uuid id |
| Group post (a "broadcast") | a FB group post WE authored FROM a listing | group_post (bluebut-db) |
linked to the listing by group_post.listing_id ONLY |
Scraped fb_post |
OTHER people's feed posts | fb_post (bluebut-db) |
post_url; authored_by_us flag |
The listingβour-posts link is group_post.listing_id β nothing else. Never a URL/text match, never fb_post. Because the listing object lives on a DIFFERENT database, listing_id is a bare text cross-DB pointer with no FK possible (same shape as the geo slug-pointer) β it can dangle; treat it as a soft reference. "Never broadcast" (zero group_post rows for a listing_id) is a first-class state, not an error.
Two rollup VIEWs already exist β use them, don't re-derive:
group_post_statsβ per-GROUP (posts_count,last_post_at,listings_count,last_listing_at). Powers the Groups index columns.listing_broadcastβ per-LISTING (attempts/posted/failed/blocked/groups_live/verified_posts/batches). The authoritative "how did each listing's broadcast go" rollup β the same thing the detail drawer shows. The drawer reads a per-listinggroup_post?listing_id=eq.<id>query (unbounded); the Broadcast grid's rollup column re-derives it in JS off the page's global 500-rowgroupPostscache (fine for recent listings; point at the view if a bulk/old-listing view needs it uncapped).
Where to build it β bluebut vs fb-bot vs platform
| System | Repo | DB | Role |
|---|---|---|---|
| bluebut | devdashco/bluebut |
bluebut-db R/W | Active operator dashboard. Where new code goes. |
| fb-bot | devdashco/fb-bot |
bofrid.dev (read-only here) | Legacy queue/poller/approval for overnight broadcasts. Don't extend. History of record. |
| bofrid platform | admin-fb-leads, geo-bofrid, manager, cron-fb-growth | ecosystem + bofrid.dev | Listing intake, geo, growth. bluebut consumes results. |
Default to bluebut when a human operator does something with FB (a human is present β no queue/poller/approval). Not bluebut for unattended automation (β fb-bot/cron-fb-growth), listing/intake/geo (β bofrid platform), or read-only history display (query bofrid.dev directly).
Recipe β "add X for bofrid": (1) FB work β adapter in adapters/<site>/; (2) state β idempotent SCHEMA.sql (site-agnostic β root); (3) UI β workspace tab (per-group) or batch page (cross-group) using Ui.tsx + runCli; (4) enforce anti-automation before any FB write; (5) the operator is the queue.
Traffic capture β sniffer + HAR
Entire subsystem is PLATFORM-OWNED under cloakbox: the platform's always-on wiretap captures every seed into a
wiretapschema + MinIO + per-session HAR (wiretap_grep_traffic/_search_payloads/_export_har). The sniffer,netcapture.js,har*.js,gql-tap.js,traffic-store.js+ the capture loop below all move to the DELETE list once migrated; bluebut keeps only its*-wire.jsrecognizers, run over theentitiesacdp_drive gqlreturns or the warehouse. CAPTURE = platform, RECOGNIZE = bluebut.
chromebox-sniffer (bin/chromebox-sniffer.mjs) β always-on daemon, ships ALL box browser activity to HyperDX tagged by account (polls /chrome/profiles, browser-level CDP per profile, records request+nav). OTLP β service.name=chromebox-traffic. npm run sniffer; bin/Dockerfile.sniffer.
Every job is captured by the RUNNER, not the daemon β per call it drops a kind:'job' start/end pair + the full per-request trail (source=runner) carrying account/cmd/job_id. The daemon is a best-effort firehose for traffic the runner can't see (manual noVNC).
Debug from traffic (MCP hyperdx_sql, connectionId='6a0dd8f074371384ffe07f18'):
SELECT Timestamp, SeverityText, LogAttributes['kind'] AS kind, LogAttributes['status'] AS status, Body
FROM otel_logs
WHERE ServiceName='chromebox-traffic' AND LogAttributes['account']='<acct>' AND LogAttributes['job_id']='<id>'
ORDER BY Timestamp;
Rule of thumb: session/nav bug β kind='nav' (/login/?next= = cold/blocked, /checkpoint/); API/permission β SeverityText='error' + gql_name; "reaching FB?" β the job-start/job-end pair + ok/duration_ms.
HAR β full-fidelity archive (everything: headers, cookies incl. live c_user/xs UNREDACTED, postData, response bodies up to 16 MB lossless, WebSocket frames as _webSocketMessages β Messenger/MQTT publish+ack; HAR 1.2 β Chrome DevTools β Network).
- Two stores, by design: the gzipped HAR blobs live in the private
harbucket (bluebut-minio MinIO/S3,public:falseβ account-takeover material; cut over from the hostbun Supabase bucket,har-store.jsβS3_ENDPOINT); the tabular index (har_recording+traffic_entry, one row per request incl. WS sockets) lives on bofrid.dev, NOT hostbun (it's a ~277k-row/day firehose β kept off the shared operational DB).traffic-store.jscreds()βBOFRID_SUPA_URL. The link istraffic_entry.recording_idβhar_keyβ bucket. Read only via the token-gated bridge. - WS capture gate
WS_CAPTURE=1(cheap, frames only) is independent of the heavy full-bodyHAR_CAPTURE;HAR_CAPTURE=1implies it. E2EE: we capture the encrypted publish+ack (proof of send), never plaintext/read-receipts. - β οΈ ALWAYS capture WebSocket frames β the page CDP target alone NEVER sees them. FB's realtime sockets (lightspeed/realtime/streamcontroller/edge-chat/e2ee) each live on a separate CDP target; a page-only capture records ZERO frames while hundreds fly. Fix (both engines): a second, browser-level CDP connection (
/json/versionβwebSocketDebuggerUrl) withTarget.setAutoAttach({autoAttach,flatten}), re-armed on eachattachedToTarget, routingNetwork.webSocketFrame*into the sockets map (lazy-materialize on first frame β long-lived MQTT never fireswebSocketCreated). Runner:netcapture.jswsApply+ the tap. Under cloakbox the platform wiretap owns this browser-level WS capture. If a HAR shows 0 WS frames, the browser-level tap is missing β that's the bug. - Keys: per job β
job/<job_id>.har.gz(linked onjob.har_key); sniffer rolling β<account>/sniffer/<ISO-ts>.har.gz. Local copy next to the JSON artifact. - Per job (guaranteed): runner
finallyβnetcapture.stop()βbuildHarβgzipHarβuploadHar('job/<id>.har.gz')β setshar_key. Best-effort. Pieces:adapters/_lib/har.js,har-store.js,netcapture.js. HAR blobs use the S3/MinIO creds (S3_*); the DB key comes from keyvault. - Sniffer always-on (OFF by default):
HAR_CAPTURE=1on the sniffer's Coolify app; tuningHAR_ROLL_MS/HAR_ROLL_MAX/HAR_BODY_MAX/HAR_INFLIGHT/BLUEBUT_CAPTURE_BINARY. Captures livec_user/xsfleet-wide β deliberate outward decision. - Retrieve:
GET /har/blob?job=<id>(or?key=<bucket-key>),GET /har/list?account=<acct>(or?prefix=job).gunzipβ drag into DevTools. - Analyze offline (reads
.gzdirectly): point DuckDB at the HAR gzips (wiretap_export_harpulls them from the platform) β the UI's DuckDB-WASM panel runs the same SQL. Do the FB-error scan keyed on the structural error CODE (368/1675004/1404xxx/1390xxx, never localized text) that catches rate-limit/abuse errors riding inside HTTP 200. For bulk sweeps:read_json('*.har.gz', maximum_object_size=2e8)+UNNEST(log.entries), filterresponse.status>=400 OR response.content.text LIKE '%"errors"%'.
Messenger box β read/send DMs through whatsmeow (the bluebut-e2ee daemon)
A self-contained subsystem at repo root messenger/ (NOT adapters/messenger/) that works FB Messenger off Facebook's own LightSpeed (LS) sync wire, not the rendered DOM β the truth ladder applied to Messenger. The legacy adapters/facebook/sync-messages.js scrapes localized aria-text (bottom rung: no hidden folders, no real ids, faked timestamps). FB web Messenger has no chat store in the DOM β it runs an in-browser LightSpeed DB the server drives with LS.sp(...) ops streamed in a /api/graphql/ response (lightspeed_web_request.payload). The box reads that same wire.
β TARGET ARCHITECTURE (2026-07-09): whatsmeow is the ONE messaging path β LightSpeed is being DELETED
Messaging goes through whatsmeow ONLY (the
bluebut-e2eedaemon = a registered encrypted-messaging device per account). The entire LightSpeed subsystem β the browsersync-inbox/sync-threadjobs, the persistentls-session.gosocket,lightspeed.go/ls-ingest.go,bin/ls-keeper.mjs,bin/poll-messenger-inbox.mjs,messenger/lib/decode.js/ls-procedures.js/passive-messages.js, the/ls-connect//ls-send//ls-ingestendpoints, thels-keepercron,MESSENGER_LS_PERSISTβ is being removed. Say "encrypted messages", never "E2EE".The model: whatsmeow holds a standing socket per account, decrypts EVERY inbound message (text, image, video, audio, file, sticker, location, contact, view-once β
handler.gonever drops a type) and POSTs it to the bridge. A decrypted message is self-contained (DecryptedMessage: thread_key + sender_id + timestamp_ms + message_id + message_type + text + attachments). The bridge materializes the inbox from that stream (handleE2eeIngest): UPSERTfb_message(+fb_message_attachment), UPSERTfb_threadso the conversation appears, UPSERTfb_contactwith the sender name resolved from thefb_personentity graph (whatsmeow only gives the numeric id). Send =send-messageβ the daemon's whatsmeow send.The accepted losses (why this is a deliberate trade): no thread-list enumeration of idle conversations (a thread appears when it gets a message), no history (whatsmeow is realtime-only), no folders/unread, and no plain type-1 threads (older/business/page/group DMs that aren't encrypted are unreachable). Contact names come from
fb_person, not a Messenger roster. Keep-alive = the reconnect-sweep (CAT minted via the cloakbox once/~24h; the one browser touch) + a register-once device β there is nols-keeperanymore.Status: cutover IN PROGRESS. Stage 1 DONE (
handler.gocaptures all types; the bridge buildsfb_thread/fb_contactfrom the stream). Stages remaining:send-messagewhatsmeow-only; delete the LightSpeed files/endpoints/jobs/cron/env; UI + doc rename. The prose below still describes the old LightSpeed subsystem β it is the migration backlog, not the target.
- How it runs:
messenger/jobs/<cmd>.jsdeclarecli({ site:'facebook' })so the runner applies every protection (proxy/freeze gate,ensureFbSession, HAR, job ledger). Bridge routessite:'messenger'βmessenger/jobs/<cmd>.js. UI =ui/src/pages/Messenger.tsx. - No hardcoded doc_id β
installLsTap(document-start) observes the page's own LS call,decode.js(JS port of mautrix-meta's LightSpeed step-VM) decodes via positional field maps (lib/ls-procedures.js),replayLspages deeper.sync-inboxfails loud if it captures zero LS payloads. - Jobs:
sync-inbox(all folders incl. hidden Requests/Spam/Archived),sync-thread(one thread's full history),send-messageβ (auto-routed by thread type: PLAIN type-1 βSendMessageTasklabel 46 over LightSpeed via the daemon'smessagixclient; E2EE type-15 β the daemon's whatsmeowSendFBMessage(<peer>@msgr)β a plain LS task into a type-15 thread is accepted-then-DROPPED by FB, so E2EE MUST go through whatsmeow.send-message.jsauto-detects type-15 viafb_thread;--transport=e2ee/domforce),manage-thread(mark-readverified; archive/delete/mute gated pending capture). - E2EE runs on hostbun, self-heals tunnel-free. The
bluebut-e2eedaemon is a Coolify app on hostbun (internal:8099, public fqdnhttps://bluebut-e2ee.blpk.cc; opens its own Noise socket to FB via the account proxy). Registers a per-account device (ICDC via a CAT), decrypts inbound type-15 β bridge/messenger/e2ee/ingestβfb_message(is_e2ee), and sends E2EE. CAT expires ~24h, only the live browser mints it. Durability = the reconnect-sweep:bin/e2ee-reconnect-sweep.mjsβcat-connect.jsmintCatAndConnectmints viacloak-cdpcdp_drive+ POSTs the daemon/connectwith the storedwa_device_id(register-once). Two Coolify cron tasks:e2ee-reconnect-heal(*/15) +e2ee-cat-refresh(41 */6 --force). Register-once record = keyvaultbluebut/e2ee-device/<slug>.- β The CAT mint MUST drive the PUBLIC cloakbox gateway (
https://cloakbox.hostbun.cc), NOT a pbox loopback.cloak-client.jsDEFAULT_DRIVE_BASEwashttp://127.0.0.1:39555on a stale "cdp_drive runs on pbox" assumption β every mint gotECONNREFUSED 127.0.0.1:39555and E2EE could not self-heal. Fixed to the public gateway (envCLOAK_DRIVE_BASEoverrides only for a private/on-box gateway). Proven: reconnect-sweep mints + connects philip/pojtie; a full browser-free round-trip (philipβpojtie) landsis_self=true decrypted_by=selfon the sender ANDis_self=false decrypted_by=daemonon the recipient, samemessage_id.
- β The CAT mint MUST drive the PUBLIC cloakbox gateway (
- β WHAT MUST BE UP for realtime send+receive (per account) β and it RUNS IN PROD. Two daemon-held sockets per account, both on the prod
bluebut-e2eedaemon: (1) the persistent LightSpeed socket β plain (type-1) receive+send, thread list, folders; auth = keyvault jar+proxy, browser-free, held up bybin/ls-keeper.mjs(*/5,pickAccountsrunnable set). (2) the whatsmeow/E2EE socket β type-15 receive(decrypt)+send; auth = a CAT (the ONLY browser touch: minted once per <24h per account by driving the account's cloakbox seed), held up by the reconnect-sweep crons above. Both sockets live IN-MEMORY in the daemon, so every daemon restart (deploy) drops all of them β the keepers re-establish (LS fast+browser-free; E2EE via a CAT mint each). Keep daemon restarts rare. Local dev CANNOT hold these sockets β you consume the PROD daemon over its public URL (E2EE_DAEMON_URL=https://bluebut-e2ee.blpk.cc, tokenE2EE_DAEMON_TOKEN) and the public cloak gateway;send-message/sync-inboxrun locally but the standing sockets + the keeper crons must run in prod (Coolify on hostbun) for anything to stay online. - β Persistent LightSpeed sessions β REALTIME inbox, NO polling (daemon-held).
messenger/e2ee/daemon/ls-session.goholds a long-livedmessagixsocket per account (a few MB, NOT a Chrome). This is the go-forward replacement for the browsersync-inboxpoll (bin/poll-messenger-inbox.mjs, now DISABLED).- DOES IT NEED SYNC? NO. A standing socket receives every new message in realtime β FB pushes it down the open socket β decoded β bridge
POST /messenger/ls-ingestβfb_thread/fb_message, the instant it arrives. There is exactly ONE sync per connection: the initial mailbox sync that fires once when a socket (re)connects, to catch up on anything that landed while it was disconnected. After that it is pure push β no poll, nosync-inboxjob, no cron "refresh". If you're running a sync to see new messages, the socket isn't up; fix the socket, don't poll. (Carries metadata + previews + non-E2EE bodies; E2EE type-15 bodies still come only from the whatsmeow side.) Auth = the account's web jar (c_user/xs/datr) + its own proxy β no cloakbox device needed to READ. - The daemon is dumb; the keeper HOLDS the socket (it does NOT sync) (principle 12).
bin/ls-keeper.mjs= the node keeper: enumerate the cloakbox-runnable accounts via the sharedpickAccounts()(_lib/cloak-client.jsβcdpCatalog, verdictrunβ NOT every logged-in jar; that pulled in dead/checkpointed g2g/df accounts we never use), load each jar+proxy from keyvault,POST /accounts/<slug>/ls-connect {fbid,proxy,cookies}. Its ONLY job is to keep the socket OPEN: each run it CHECKSls_connected(the daemon/statusreports it beside the E2EEconnectedβ key onls_connectedONLY, E2EE-up β mailbox-up) and RECONNECTS only the DOWN ones β a healthy socket is left untouched. Any drop β a Telegram alert (via_lib/tg-notify.js) + non-zero exit. Daemon gateMESSENGER_LS_PERSIST(*= accept any slug the keeper pushes β the keeper is the sole gate); a real proxy is mandatory (a proxyless raw socket egresses the daemon IP β mass burn). - STATUS (2026-07-09): FIXED + working. The earlier "flap" was NOT the sockets β they hold fine (
/accountsshowed 5/6connected:true); it was the keeper killing healthy sockets because the OLD/statusreported the E2EE session (down post-restart) not the LS one β 404 β blind reconnect βstart()stops-then-replaces the live socket. Fix (deployed):/statusnow reportsls_connecteddistinct from the E2EEconnected, and the keeper keys on it β it leaves healthy sockets alone (proven: keeper run reportsjoel-skola/philip/pojtie β up, 0 reconnects; philip + pojtie ingested ~30 realtime msgs each in a 30-min window).s7-amelia-bontinis flaky (LSHTTP 502on connect, won't hold β account-specific, open). Thels-keeperCoolify cron is currently REMOVED (was churning under the old bug); re-add*/5now that it no longer churns.
- DOES IT NEED SYNC? NO. A standing socket receives every new message in realtime β FB pushes it down the open socket β decoded β bridge
- Schema (
messenger/SCHEMA.sql): per-viewer-scoped βfb_thread/fb_message/fb_contact/fb_thread_participant/fb_message_reaction/fb_message_attachment.rpc/fb_schema_ingestself-applies new columns. - WS link: encrypted MQTT publish+ack frames land in the per-job HAR as
_webSocketMessages(WS_CAPTURE=1) β proof-of-send, never plaintext.
A messenger job's decoded wire + _webSocketMessages sit in its per-job HAR (GET /har/blob?job=<id> β gunzip β DevTools). 0 graphql in the step log is expected β LS rides one lightspeed_web_request payload, not named-query traffic.
UI pages
Four pages β ui/src/App.tsx. Nav: Groups β Groups; Automation β Jobs; Admin β Accounts, Tools.
| Page | Purpose |
|---|---|
Groups (home, /groups) |
Metrics + group grid (sort/filter/search, checkbox bulk panel: MultiGroupPostForm, BulkAdminForm, MultiGroupAIJobForm). Row β workspace. |
Group workspace (/groups/<id>[/<tab>]) |
Tabs ["overview","admins"]. Overview = cover/info/location editors + our accounts + recent posts + cooldown + Refresh. People = roster + promote/demote (GroupAdminPromoter). |
Jobs (/jobs) |
Live + historical; expand to watch its tab (CDP screencast). |
| Accounts | Session health, proxy, credential editing; add account; "Sync from keyvault". |
| Tools | One-off CLI β run any adapter. |
A retired lens (Pipeline/Inbox/Outreach/Scraper) comes back as a workspace tab (per-group) or batch page (cross-group), never a top-level one-off.
Geo association for FB groups (planned)
Each FB group covers a geography. Polygons live in the geo project (geo_regions/municipalities/geo_areas, served by geo-bofrid geo.bofrid.eu/api/geo); fb_group is in bluebut-db β cross-DB FKs impossible. Plan: slug-only β add geo_slug to fb_group; (geo_level, geo_slug) is the canonical pointer, resolved on demand via geo-bofrid. Out of scope: storing geom, replicating geo tables, moving fb_group into geo.