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:

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:

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.

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.

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

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; unowned → 403 PERMISSION_DENIED (not 404), no existence leakbearer + 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 dashboard (per-location title/count/avg)seed-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/time-travelseed-token
POST/admin/seed/packstand up a believable multi-business world in one call: {pack:"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/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

Where the source of truth lives

Liveness / provenance probes: GET /healthz and GET /build-info. The read-only place pages live at GET /m/locations/:locationId.