# Bluebut Read API (v1)

Read-only HTTP/JSON access to Bluebut's Facebook **accounts** and operational
**state** (groups, people, posts, jobs) — so people, scripts, and other Bofrid
systems can fetch what the dashboard sees without the UI, the supabase
service-role key, or any knowledge of keyvault/hostbun internals.

- **Base URL (local):** `http://localhost:8787/api/v1`
- **Base URL (hosted):** `https://bluebut-bridge.blpk.cc/api/v1`
- **Method:** `GET` only. Everything is read-only.
- **Format:** JSON. Every response has an `ok: true|false`.
- **Served by:** the bridge (`ui/bridge/opencli-bridge.mjs` → `api-v1.mjs`). Run
  it with `npm run bridge`.

## Auth

Every **data** endpoint needs the shared password (default `ddash`). Pass it
either way:

```bash
# header
curl -H "Authorization: Bearer ddash" http://localhost:8787/api/v1/accounts
# or query param
curl "http://localhost:8787/api/v1/accounts?token=ddash"
```

`/openapi.json` and `/docs` are **open** (schema + a static page, no data).
Override the password with `API_TOKEN` in the root `.env`. Set `API_TOKEN=` (empty)
to disable auth entirely.

> **Secrets are never returned.** FB passwords and cookie jars are stripped from
> every account row before it leaves the process — callers see identity, status,
> and cookie *presence* (`has_cookies`, `cookie_count`), never the credentials.

## Interactive docs

- **`GET /api/v1/docs`** — a self-contained page (no CDN) listing every endpoint
  with a "Try it" button. Paste the password once; it's kept in `localStorage`.
- **`GET /api/v1/openapi.json`** — the OpenAPI 3.1 spec (import into Postman /
  Insomnia / generate a client).

## Endpoints

| Method | Path | Returns |
|---|---|---|
| GET | `/accounts` | All FB accounts (keyvault registry, secrets stripped) |
| GET | `/accounts/{slug}` | One account |
| GET | `/accounts/health` | Cookie-only liveness sweep (no browser) |
| GET | `/accounts/intel` | Offline cookie-truth (drift / contamination / stale / dead-page) |
| GET | `/groups` | FB group list |
| GET | `/groups/{id}` | Group detail: row + members (roles) + recent posts + our memberships |
| GET | `/people` | People / leads (`fb_person` identity hub) |
| GET | `/posts` | Group-post log (what Bluebut has posted) |
| GET | `/jobs` | Live + recent job/queue state |
| GET | `/summary` | Counts: accounts, groups, people, posts last 7d |

### `GET /accounts`
The canonical account list = union of the bluebut-owned namespace and the
droidfleet farm (ours wins). Secrets stripped.

```json
{
  "ok": true,
  "count": 96,
  "accounts": [
    {
      "account": "pojtie",
      "display_name": "…",
      "account_type": "profile",
      "fb_id": "61…",
      "last_status": "logged_in",
      "kv_status": "active",
      "source": "chromebox_sessions",
      "proxy": { "scheme": "socks5h", "host": "…", "port": 1080 },
      "has_cookies": true,
      "cookie_count": 9,
      "updated_at": "2026-06-25T…"
    }
  ]
}
```

### `GET /accounts/{slug}`
`{ "ok": true, "account": { … } }` or `404 { "ok": false, "error": "no account \"x\"" }`.

### `GET /accounts/health?account=<slug>`
Replays each stored cookie jar against facebook.com over plain HTTPS and reads
the inlined `USER_ID` → live/dead. No browser. Omit `account` for the whole
fleet; pass it to check one (and get an egress-IP echo).

```json
{ "ok": true, "total": 96, "live": 41,
  "accounts": [ { "account": "pojtie", "valid": true, "status": "logged_in",
    "fb_id": "61…", "expected_id": "61…", "name": "…", "http": 200,
    "via": "proxy", "egress_ip": "…", "ms": 740, "error": null } ] }
```

### `GET /accounts/intel?account=<slug>`
Decodes + reconciles every jar **offline** — surfaces drift / contamination /
stale / dead-page that the stored fields hide. Verdicts:
`ok | page | drift | contaminated | stale | logged_out`.

```json
{ "ok": true, "total": 96, "counts": { "ok": 40, "page": 3, "stale": 12 },
  "accounts": [ { "account": "uthyra-se", "verdict": "contaminated",
    "detail": "jar identity is pojtie", "uid": "61…",
    "identity_account": "pojtie", "identity_name": "…",
    "stored_fb_id": "61…", "session_age_days": 4, "cookie_count": 9 } ] }
```

### `GET /groups`
Query: `limit` (1–500, default 100), `offset`, `search` (case-insensitive name),
`is_test` (`true`|`false`).

```bash
curl -H "Authorization: Bearer ddash" \
  "http://localhost:8787/api/v1/groups?search=lidkoping&limit=20"
```

### `GET /groups/{id}`
`{id}` = `fb_group_id`. Returns the group row plus:
- `members` — `fb_group_member` edges (`fb_user_id`, `role`)
- `recent_posts` — last 20 `group_post` rows
- `our_memberships` — `fb_account_group` (which of our accounts are members)

> Need **all** of a group's associated data (full roster with member identity,
> every post, scrape signals) in one shot, or to fetch it for *every* group at
> once? Hit the raw entity-graph surface directly — see
> [Direct entity-graph access](#direct-entity-graph-access-postgrest) below.

### `GET /people`
Query: `limit`, `offset`, `intent` (`seeker|landlord|unknown`),
`outreach_status` (`new|queued|contacted|replied|converted|closed`), `city`,
`first_seen_channel` (`admin_scrape|feed_post|dm_reply`).

### `GET /posts`
Query: `limit`, `offset`, `fb_group_id`, `status`
(`pending|posted|failed|skipped`). Newest first.

### `GET /jobs`
Live bridge job state: `{ active, queued, recent, busyAccounts }`. Same shape the
dashboard's Jobs page uses.

### `GET /summary`
```json
{ "ok": true,
  "accounts": { "total": 96, "active": 90, "logged_in": 41 },
  "groups": { "total": 412, "admin": 18 },
  "people": { "total": 5230 },
  "posts": { "last_7d": 12 },
  "generated_at": "2026-06-25T…" }
```

## Direct entity-graph access (PostgREST)

The curated `/api/v1` endpoints above cover the common cases. For **arbitrary
reads of the whole entity graph** — every group with every association embedded,
ad-hoc filters, joins the curated API doesn't expose — query the raw PostgREST
surface that the dashboard itself speaks.

- **Base URL:** `https://bluebut-rest.blpk.cc/rest/v1`
- **Auth:** the Supabase-shaped service-role JWT (`apikey` + `Authorization:
  Bearer` headers). Same key as `VITE_SUPABASE_KEY` in the root `.env`.
- **Shape:** standard [PostgREST](https://postgrest.org) — `select=`, `order=`,
  `limit=`, `*.eq.`/`*.in.` filters, and **resource embedding** of related rows.

### Fetch every group + ALL associated data in one query

As of **2026-06-30** the entity graph carries the foreign keys PostgREST needs
to embed a group's children, so a single `select=` pulls the whole tree:

```bash
KEY=<service-role-jwt>
curl -s -H "apikey: $KEY" -H "Authorization: Bearer $KEY" \
  "https://bluebut-rest.blpk.cc/rest/v1/fb_group?select=\
fb_group_id,name,member_count,\
fb_account_group(chromebox_account,is_member),\
fb_group_member(role,fb_user(name)),\
group_post(status,created_at),\
fb_person_signal(signal_type),\
fb_post(post_url)"
```

Drop the trailing `&limit=1` to get **all 416 groups**; add `&order=member_count.desc`,
`&category=eq.housing`, paginate with `Range:` headers, etc.

**Embeddable relationships off `fb_group` (FK → `fb_group.fb_group_id`):**

| Embed | Table | Note |
|---|---|---|
| `fb_account_group(*)` | `fb_account_group` | which of our accounts are members |
| `fb_group_member(*)` | `fb_group_member` | FB roster + role; nest `fb_user(*)` for identity |
| `fb_group_invite(*)` | `fb_group_invite` | pending admin/group invites |
| `group_post(*)` | `group_post` | every post Bluebut published to the group |
| `fb_post(*)` | `fb_post` | feed scrape results (nest `fb_person(*)` for poster) |
| `fb_person_signal(*)` | `fb_person_signal` | observed events in the group |

`fb_group_member → fb_user`, `fb_post → fb_person`, and
`fb_person_signal → fb_person` are likewise embeddable, so the roster/post rows
carry the person identity inline.

> The FK declarations live in `adapters/facebook/SCHEMA.sql` (idempotent). If a
> `select=…(…)` embed ever returns `PGRST200 "could not find a relationship"`,
> the FK is missing or the PostgREST schema cache is stale — re-apply the schema
> (it ends with `NOTIFY pgrst, 'reload schema'`).

## Errors

| Status | Meaning |
|---|---|
| 401 | Missing / wrong password |
| 404 | Unknown account / group / endpoint |
| 405 | Non-GET method (API is read-only) |
| 502 | Upstream (hostbun / keyvault) failure — `error` carries the detail |

## Notes

- CORS is open (`*`) — callable from a browser.
- Account data is canonical in keyvault; groups/people/posts come from the
  self-owned entity graph (`bluebut-db` via PostgREST `bluebut-rest.blpk.cc`);
  jobs are the bridge's in-memory registry. See the repo `CLAUDE.md` "Three data
  planes" for the full model.
