Fibgleinternal twin · not Google

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 /write composer and the POST that 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).

  1. CONNECT — authorize at /o/oauth2/v2/auth, then exchange the code at /token for an access + refresh pair. Replaying the same authorization code within ~120s is idempotent and returns the same token pair.
  2. ENUMERATE — list the bearer user's accounts at /v1/accounts, then their locations at /v1/accounts/:accountId/locations. readMask is REQUIRED on the Business Information v1 reads (absent/empty → 400 INVALID_ARGUMENT).
  3. SYNC (poll) — poll the legacy v4 reviews surface at /v4/accounts/:a/locations/:l/reviews. Walk nextPageToken to the last page (nextPageToken is absent on the final page) and keep rows where updateTime > cutoff since the previous poll.
  4. SYNC (reply) — upsert a reply with PUT .../reviews/:reviewId/reply. The upsert bumps the parent review's updateTime, so the row resurfaces on the next incremental poll.
  5. 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/pack with {pack:"amsterdam-real-businesses"} (or nl-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 exercise accounts.list paging and a multi-account catalog.
  • Provision individual profilesPOST /admin/accounts creates one account + a connectable grant; POST /admin/accounts/:accountId/locations adds a location under it. (The same thing in one JSON file: the create-account / create-location scenario ops, linked to reviews by named ref.)
  • Human, via the public UI — open a place page's /m/locations/:locationId/write composer and post a review using the mock identity picker.
  • Script reviews, via the operator consolePOST /admin/seed/baseline installs the baseline world; POST /admin/seed runs JSON scenarios (create/reply/edit/delete/bulk, plus an optional createTime to backdate — "time-travel" — any create/bulk); the single-row /admin/reviews/* endpoints edit one review; and POST /sim/tokens/expire force-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_TOKEN is unset, these routes return 404 — there is no open console.
  • When set: callers must present the matching token via the x-fibgle-seed-token header 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.

MethodPathPurposeAuth
GET/healthzliveness; {status:"ok", service:"fibgle"}public
GET/build-infodeploy-proof provenance {commit, buildTime}; Cache-Control: no-storepublic
GET/this in-app home / how-to-use docs pagepublic
GET/openapi.jsonmachine-readable OpenAPI 3.1 contract for every surface; Cache-Control: no-storepublic
GET/docsrendered API reference (Redoc over /openapi.json)public

oauth

accounts.google.com / oauth2.googleapis.com shapes. No data-API authenticate(); FLAT {error, error_description} envelope.

MethodPathPurposeAuth
GET/o/oauth2/v2/authauthorize/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 userpublic (own client/redirect check)
POST/tokenauthorization_code (PKCE S256 + ~120s idempotent replay = same pair) and refresh_token grants; refresh omits refresh_tokenpublic (timing-safe client_id+secret)
POST/oauth2/v4/tokenalias of /token, same handlerpublic (timing-safe client_id+secret)
POST/revokeresolves an access OR refresh token to its grant, sets grant.revoked=true; empty 200public
GET/v1/userinfoOpenID-style userinfo; FLAT OAuth 401 on missing/unknown/revoked/expired; does NOT require business.manage; quotaGate skips this pathbearer (own check, not authenticate())

accounts-v1

Account Management v1. All call authenticate() (Bearer + business.manage) + quotaGate on /v1/*; google.rpc.Status envelope.

MethodPathPurposeAuth
GET/v1/accountsaccounts.list; bearer user's accounts via account_grants; role per-(user,account); pageSize default/max 20bearer + business.manage
GET/v1/accounts/:accountIdaccounts.get; unknown/unowned → 404 NOT_FOUNDbearer + business.manage

locations-v1

Business Information v1. authenticate() + quotaGate on /v1/*; readMask REQUIRED (absent/empty → 400 INVALID_ARGUMENT, field=read_mask).

MethodPathPurposeAuth
GET/v1/accounts/:accountId/locationslocations.list; readMask REQUIRED; accepts accounts/{id} or accounts/-; pageSize default 10 max 100; totalSize only when a filter is presentbearer + business.manage
GET/v1/locations/:locationIdlocations.get (bare name); readMask REQUIRED; 404 if missing, 403 if caller doesn't own the location's accountbearer + 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.

MethodPathPurposeAuth
GET/v4/accounts/:accountId/locations/:locationId/reviewsreviews.list; keyset paging, orderBy whitelist, aggregates every page, nextPageToken ABSENT on last page; pageSize ≤50bearer + business.manage (+ ownership)
GET/v4/accounts/:accountId/locations/:locationId/reviews/:reviewIdreviews.get; NOT_FOUND (404) if absentbearer + business.manage (+ ownership)
PUT/v4/accounts/:accountId/locations/:locationId/reviews/:reviewId/replyreviews.updateReply UPSERT {comment,updateTime}; bumps parent updateTimebearer + business.manage (+ ownership)
DELETE/v4/accounts/:accountId/locations/:locationId/reviews/:reviewId/replyreviews.deleteReply; {} on success, NOT_FOUND if no replybearer + business.manage (+ ownership)
POST/v4/accounts/:accountId/locations:batchGetReviewsbatchGetReviews (P2 shape-only stub); {locationReviews:[{name,review}]} no aggregates; rosie does not call itbearer + business.manage (+ per-batch ownership)

public

/m SSR simulation surfaces. No authenticate(), no seedGate(); mock identity picker; histogram is UI-only.

MethodPathPurposeAuth
GET/m/locations/:locationIdSSR place page (card + reviews + UI-only histogram); HTML 404 page if missingpublic
GET/m/locations/:locationId/writeSSR composer with persona picker; optional ?rating=N; HTML 404 page if missingpublic
POST/m/locations/:locationId/reviewsinsert one review (P0 simulation write), 303 back to place page; bare-JSON 404 (not HTML) if missingpublic

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.

MethodPathPurposeAuth
GET/adminSSR operator console: accounts/profiles + grants, locations by account, view-as a mock user, token management (mint/expire/revoke), and the scenario runnerseed-token (404 when unset/wrong)
POST/admin/seed/baselineidempotently install baseline world; {ok:true, seeded:"baseline"}seed-token
POST/admin/seedrun 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/packstand 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 distributionseed-token
POST/admin/accountscreate one account + a connectable grant (idempotent on a supplied accountId)seed-token
POST/admin/accounts/:accountId/locationscreate one location under an existing account (404 if the account is unknown)seed-token
POST/admin/locationscreate 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/mintissue an access+refresh pair for a mock user without the consent round-trip (one-click connect-as)seed-token
POST/sim/grants/:grantId/revokerevoke a grant by id (operator twin of /revoke; grant-authoritative cascade)seed-token
POST/admin/reviews/:reviewId/replyoperator upsert reply {comment} (404 if no row, 400 if empty)seed-token
POST/admin/reviews/:reviewId/editoperator edit {starRating?, comment?} + bump updateTime (404 if not found)seed-token
POST/admin/reviews/:reviewId/deleteoperator delete by id (404 if not found)seed-token
POST/sim/tokens/expireforce-expire access token(s) {accessToken?} (one or ALL) to trigger 401→refresh→retryseed-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.
  • batchGetReviews is 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 the oauth/accounts-v1/locations-v1/reviews-v4 tags 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.