Fibgle — internal Google Business Profile twin
This is the in-app home / how-to-use docs page. It is not the place page.
Fibgle is an internal-only, byte-faithful digital twin of the Google Business Profile API, so rosie's eventual real Google integration can run unmodified against a stable URL. It clones the shape of the Google surface — the same host/path shapes, byte-identical envelopes, and the exact edges rosie branches on — not the product. As the topbar says: internal twin, not Google. This is a testing tool, not a public Maps/Google product.
This is the in-app home, not the place page
There are two server-rendered surfaces; do not confuse them:
GET /(this page) — the how-to-use docs / landing page you are reading now.GET /m/locations/:locationId— the public "looks like Google" place page (a Google-style business card + reviews), plus its/writecomposer and thePOSTthat inserts a simulated review. That surface mimics Google; this one explains Fibgle.
The base-URL premise (why Fibgle exists)
rosie points its Google client at Fibgle by overriding the Google hosts. Because Fibgle answers on the same host/path shapes with byte-identical envelopes, rosie cannot tell it apart from Google — no client code changes, just a base-URL swap.
Recommended topology: a single path-routed Worker answering every Google surface; fallback: four custom domains (one per Google host).
Standing caveat: the base-URL premise is currently undemonstrated / blocked
pending rosie's real GBP client. rosie has no business.manage GBP client yet — only
better-auth Google login — so the single-Worker topology is recommended-pending-confirmation, not
proven end-to-end. Fibgle itself faithfully answers the Google shapes regardless of which topology rosie
eventually wires up.
How rosie drives it: CONNECT → ENUMERATE → SYNC
Documentation-altitude walkthrough (prose only — there are no live forms on this page).
- CONNECT — authorize at
/o/oauth2/v2/auth, then exchange the code at/tokenfor an access + refresh pair. Replaying the same authorization code within ~120s is idempotent and returns the same token pair. - ENUMERATE — list the bearer user's accounts at
/v1/accounts, then their locations at/v1/accounts/:accountId/locations.readMaskis REQUIRED on the Business Information v1 reads (absent/empty → 400 INVALID_ARGUMENT). - SYNC (poll) — poll the legacy v4 reviews surface at
/v4/accounts/:a/locations/:l/reviews. WalknextPageTokento the last page (nextPageTokenis absent on the final page) and keep rows whereupdateTime > cutoffsince the previous poll. - SYNC (reply) — upsert a reply with
PUT .../reviews/:reviewId/reply. The upsert bumps the parent review'supdateTime, so the row resurfaces on the next incremental poll. - Auth edges — a 401 is recoverable: do one refresh at
/token(grant_type=refresh_token) and retry once. A 403 is terminal (permission denied) — do not retry.
How to seed / simulate data
Several non-Google paths put data into the twin:
- Stand up a whole multi-business world (seed pack) —
POST /admin/seed/packwith{pack:"amsterdam-real-businesses"}(ornl-restaurants,us-diners, or a raw pack body) creates N accounts + locations with varied locales/categories and a starting review distribution, and returns every minted id. This is the fastest way to exerciseaccounts.listpaging and a multi-account catalog. - Provision individual profiles —
POST /admin/accountscreates one account + a connectable grant;POST /admin/accounts/:accountId/locationsadds a location under it. (The same thing in one JSON file: thecreate-account/create-locationscenario ops, linked to reviews by namedref.) - Human, via the public UI — open a place page's
/m/locations/:locationId/writecomposer and post a review using the mock identity picker. - Script reviews, via the operator console —
POST /admin/seed/baselineinstalls the baseline world;POST /admin/seedruns JSON scenarios (create/reply/edit/delete/bulk, plus an optionalcreateTimeto backdate — "time-travel" — any create/bulk); the single-row/admin/reviews/*endpoints edit one review; andPOST /sim/tokens/expireforce-expires access tokens to fire the 401 → refresh → retry path.
Invariant: every settable field round-trips through
GET .../reviews, every mutation recomputes the location's
averageRating / totalReviewCount, and a provisioned account/location reads
back byte-identically through the v1/v4 surfaces — indistinguishable from a fixture one (one shared write path).
Operator & simulation surfaces are gated (and hidden by default)
The operator surfaces — /admin, /admin/seed*,
/admin/reviews/*, and /sim/* — are gated by the
FIBGLE_SEED_TOKEN secret via seedGate.
- Secure default: when
FIBGLE_SEED_TOKENis unset, these routes return 404 — there is no open console. - When set: callers must present the matching token via the
x-fibgle-seed-tokenheader or a?token=query param; a wrong or missing token also returns an opaque 404 (never 401/403, so the surface's existence is not leaked).
This page documents only the env-var name and the mechanism — never a token value.
Complete route surface
Every route Fibgle serves, grouped by surface. METHOD · PATH · purpose · auth. The same surface as a machine-readable contract: GET /openapi.json (browsable at GET /docs).
meta
Public probes — no auth, no gating, always reachable.
| Method | Path | Purpose | Auth |
|---|---|---|---|
GET | /healthz | liveness; {status:"ok", service:"fibgle"} | public |
GET | /build-info | deploy-proof provenance {commit, buildTime}; Cache-Control: no-store | public |
GET | / | this in-app home / how-to-use docs page | public |
GET | /openapi.json | machine-readable OpenAPI 3.1 contract for every surface; Cache-Control: no-store | public |
GET | /docs | rendered API reference (Redoc over /openapi.json) | public |
oauth
accounts.google.com / oauth2.googleapis.com shapes. No data-API authenticate(); FLAT {error, error_description} envelope.
| Method | Path | Purpose | Auth |
|---|---|---|---|
GET | /o/oauth2/v2/auth | authorize/consent; validates client_id + exact-registered redirect_uri BEFORE redirecting (bad redirect = error PAGE); auto-approve via FIBGLE_AUTO_APPROVE or ?approve=1; ?fibgle_user= picks the mock user | public (own client/redirect check) |
POST | /token | authorization_code (PKCE S256 + ~120s idempotent replay = same pair) and refresh_token grants; refresh omits refresh_token | public (timing-safe client_id+secret) |
POST | /oauth2/v4/token | alias of /token, same handler | public (timing-safe client_id+secret) |
POST | /revoke | resolves an access OR refresh token to its grant, sets grant.revoked=true; empty 200 | public |
GET | /v1/userinfo | OpenID-style userinfo; FLAT OAuth 401 on missing/unknown/revoked/expired; does NOT require business.manage; quotaGate skips this path | bearer (own check, not authenticate()) |
accounts-v1
Account Management v1. All call authenticate() (Bearer + business.manage) + quotaGate on /v1/*; google.rpc.Status envelope.
| Method | Path | Purpose | Auth |
|---|---|---|---|
GET | /v1/accounts | accounts.list; bearer user's accounts via account_grants; role per-(user,account); pageSize default/max 20 | bearer + business.manage |
GET | /v1/accounts/:accountId | accounts.get; unknown/unowned → 404 NOT_FOUND | bearer + business.manage |
locations-v1
Business Information v1. authenticate() + quotaGate on /v1/*; readMask REQUIRED (absent/empty → 400 INVALID_ARGUMENT, field=read_mask).
| Method | Path | Purpose | Auth |
|---|---|---|---|
GET | /v1/accounts/:accountId/locations | locations.list; readMask REQUIRED; accepts accounts/{id} or accounts/-; pageSize default 10 max 100; totalSize only when a filter is present | bearer + business.manage |
GET | /v1/locations/:locationId | locations.get (bare name); readMask REQUIRED; 404 if missing, 403 if caller doesn't own the location's account | bearer + business.manage |
reviews-v4
Legacy GMB v4 — the poll surface. authenticate() + resolveLocation ownership + quotaGate on /v4/*; starRating word-enum (FIVE); aggregates on every page; google.rpc.Status.
| Method | Path | Purpose | Auth |
|---|---|---|---|
GET | /v4/accounts/:accountId/locations/:locationId/reviews | reviews.list; keyset paging, orderBy whitelist, aggregates every page, nextPageToken ABSENT on last page; pageSize ≤50 | bearer + business.manage (+ ownership) |
GET | /v4/accounts/:accountId/locations/:locationId/reviews/:reviewId | reviews.get; NOT_FOUND (404) if absent | bearer + business.manage (+ ownership) |
PUT | /v4/accounts/:accountId/locations/:locationId/reviews/:reviewId/reply | reviews.updateReply UPSERT {comment,updateTime}; bumps parent updateTime | bearer + business.manage (+ ownership) |
DELETE | /v4/accounts/:accountId/locations/:locationId/reviews/:reviewId/reply | reviews.deleteReply; {} on success, NOT_FOUND if no reply | bearer + business.manage (+ ownership) |
POST | /v4/accounts/:accountId/locations:batchGetReviews | batchGetReviews (P2 shape-only stub); {locationReviews:[{name,review}]} no aggregates; rosie does not call it | bearer + business.manage (+ per-batch ownership) |
public
/m SSR simulation surfaces. No authenticate(), no seedGate(); mock identity picker; histogram is UI-only.
| Method | Path | Purpose | Auth |
|---|---|---|---|
GET | /m/locations/:locationId | SSR place page (card + reviews + UI-only histogram); HTML 404 page if missing | public |
GET | /m/locations/:locationId/write | SSR composer with persona picker; optional ?rating=N; HTML 404 page if missing | public |
POST | /m/locations/:locationId/reviews | insert one review (P0 simulation write), 303 back to place page; bare-JSON 404 (not HTML) if missing | public |
operator
Hidden, NON-Google. Gated by the FIBGLE_SEED_TOKEN secret via seedGate. Secure default: 404 when the token is UNSET (no open console). When set, callers must present the matching token (x-fibgle-seed-token header or ?token=) or also get an OPAQUE 404 (never 401/403). Mechanism + env-var name only — never a value.
| Method | Path | Purpose | Auth |
|---|---|---|---|
GET | /admin | SSR operator console: accounts/profiles + grants, locations by account, view-as a mock user, token management (mint/expire/revoke), and the scenario runner | seed-token (404 when unset/wrong) |
POST | /admin/seed/baseline | idempotently install baseline world; {ok:true, seeded:"baseline"} | seed-token |
POST | /admin/seed | run a JSON scenario (raw body or dashboard __json): create-account/create-location (named refs) + review create/reply/edit/delete/bulk (backdate any create/bulk with an optional createTime for time-travel seeding) | seed-token |
POST | /admin/seed/pack | stand up a believable multi-business world in one call: {pack:"amsterdam-real-businesses"|"nl-restaurants"|"us-diners"} or a raw pack body → N accounts + locations + a review distribution | seed-token |
POST | /admin/accounts | create one account + a connectable grant (idempotent on a supplied accountId) | seed-token |
POST | /admin/accounts/:accountId/locations | create one location under an existing account (404 if the account is unknown) | seed-token |
POST | /admin/locations | create one location with the parent accountId in the body (console form twin of the path-param route; same write path) | seed-token |
POST | /sim/oauth/mint | issue an access+refresh pair for a mock user without the consent round-trip (one-click connect-as) | seed-token |
POST | /sim/grants/:grantId/revoke | revoke a grant by id (operator twin of /revoke; grant-authoritative cascade) | seed-token |
POST | /admin/reviews/:reviewId/reply | operator upsert reply {comment} (404 if no row, 400 if empty) | seed-token |
POST | /admin/reviews/:reviewId/edit | operator edit {starRating?, comment?} + bump updateTime (404 if not found) | seed-token |
POST | /admin/reviews/:reviewId/delete | operator delete by id (404 if not found) | seed-token |
POST | /sim/tokens/expire | force-expire access token(s) {accessToken?} (one or ALL) to trigger 401→refresh→retry | seed-token |
What Fibgle does NOT clone
- No posts, media, Q&A, insights, attributes, verification, service-items, or food-menus.
- No Pub/Sub push — rosie is poll-only.
- No rosie-side data model lives here.
batchGetReviewsis a shape-only stub rosie does not call.- The public UI and the seed/admin console are testing affordances, not part of the byte-faithful contract.
- The place-page histogram is UI-only — the v4 API returns only
averageRating+totalReviewCount, never a histogram. - No real Google sign-in — there is a mock identity picker / fixed mock user, overridable via
?fibgle_user=. - Dropped early-proposal items: no HMAC-signed tokens / no
FIBGLE_TOKEN_SIGNING_SECRET(tokens are opaque, validated by D1 lookup), and no KV (OAuth codes live in D1).
Where the source of truth lives
- API contract: the machine-readable OpenAPI 3.1 spec at
GET /openapi.json, rendered as a browsable reference at GET /docs. Only theoauth/accounts-v1/locations-v1/reviews-v4tags are byte-faithful to Google; the rest are Fibgle-only test tooling. - Design spec: the proposal at
docs/proposals/google-business-clone.md. - As-built:
apps/fibgle/docs/— on any disagreement, the as-built docs win.
Liveness / provenance probes: GET /healthz and
GET /build-info. The read-only place pages live at
GET /m/locations/:locationId.