Skip to content

Bing Webmaster Tools integration - #2

Merged
mlava merged 18 commits into
mainfrom
feat/bing-webmaster
Jul 25, 2026
Merged

Bing Webmaster Tools integration#2
mlava merged 18 commits into
mainfrom
feat/bing-webmaster

Conversation

@mlava

@mlava mlava commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Adds a Bing Webmaster Tools connection alongside the existing Search Console one: OAuth connect, per-project site mapping, a daily clicks/impressions page, and a read-only MCP tool. Bing is free and first-party, so like GSC it consumes no credits.

Verified end to end on a cloudflare_access deployment — connect, site selection, and daily rows all work against a real Bing account.

The design and the reasoning behind it are in specs/0009-bing-webmaster-integration.md.

Why it isn't just "GSC with a different logo"

Bing's API is not a second Search Console. GSC has one endpoint taking arbitrary date ranges, dimensions, filters and paging. Bing has ~15 fixed-shape methods with no date range, no dimensions, and no paging. That single fact drove most of the design decisions — most visibly, Bing gets its own page rather than a source toggle on Search Performance, because sharing that page's chrome would advertise controls Bing cannot honour.

Two questions were settled by probing the live API rather than trusting the docs, because Microsoft's own documentation is wrong in places and public bug reports suggested the integration might not be viable at all:

  • Refresh tokens. Public reports say Bing rotates refresh tokens and then rejects the rotated ones. That would have been fatal, since Better Auth overwrites the stored token with whatever a provider returns — grants would die within the hour. Not reproduced: Bing returns no refresh_token on refresh at all, replicated across two independent grants before and after regenerating the client secret. What remains untested is the multi-day complaint in those same reports.
  • Account identity. Bing has no userinfo endpoint and issues no id_token, so getUserInfo decodes webmasteruid and webmasteremail from the access token itself — no network call. That id survived a client-secret regeneration, so it keys the Bing account rather than the grant.

scripts/bing-oauth-spike.ts is the probe, retained deliberately: the spec asks for the refresh behaviour to be re-checked periodically, and its --step=call is how any further Bing endpoint's shape should be pinned before coding against it.

How to review

  1. Start with the spec. It records what was verified live, on what date, and what is still assumption.
  2. Then src/server/lib/bingClient.ts — the boundary where Bing's oddities are absorbed: a WCF d envelope on every response, /Date(ms±HHMM)/ timestamps, PascalCase fields, and a scope string that comes back as "Read" when webmaster.read was requested.
  3. Then BingService — the judgment calls live here: disconnect only unlinks a grant when no other project still uses it, and a dead grant surfaces as "reconnect" rather than breaking the whole site list.
  4. bing/selfHostedOAuth.ts deserves real scrutiny. It mirrors the Search Console equivalent (HMAC-signed state, code exchange, an account row encrypted exactly as setTokenUtil writes it) and it has been exercised end to end, but it has no automated test coverage and it handles tokens.
  5. The riskiest thing for existing behaviour is the shared card chrome extraction, which touches SearchConsoleConnectionCard — shipped code this feature otherwise doesn't go near. Mechanical (three props) and typechecked, but it is the one change that could regress something already working.

Deliberately out of scope, recorded in the spec: API-key connections (now only needed for local dev, since Bing refuses localhost redirect URIs), URL submission, crawl issues, and Bing keyword data.

Setup for another environment

Bing permits one redirect URI per registered OAuth client, so each environment needs its own client, registered against {origin}/api/bing/oauth/callback. Requires BING_CLIENT_ID, BING_CLIENT_SECRET, and a BETTER_AUTH_SECRET of 32+ characters — the card shows "Setup required" until all three are present.

Review notes

Findings from a five-axis review that were rejected after verification, flagged in case you disagree:

  • Merge BingSitePicker into the GSC SitePicker. Rejected: the claimed nine-line diff is really 19 hunks, "(no access)" and "(not verified)" mean opposite things to a user, and a merged component needs ten label props. SitePicker is also consumed by shipped onboarding.
  • Delete the auth_mode column as speculative. Rejected: removing it means regenerating head migrations in both dialects, then a third migration to restore it.

Unfixed nitpicks left for your judgment:

  • A Bing 429 gets retried three times by React Query's defaults, turning one rate-limited load into four calls. The GSC path behaves identically, so fixing only Bing would create asymmetry.
  • listSites() and getConnectedEmail() each mint a token, so one site-list render mints twice per grant. Token endpoint only, no Bing quota impact.
  • listBingSites returns accountId (the webmasteruid) to the browser. It's only ever the requester's own grant, but that value doubles as Bing's site verification code.
  • BingConnectionRepository writes updatedAt with SQLite-shaped current_timestamp, which is not ISO under Postgres. Copied verbatim from GscConnectionRepository, so it's a pre-existing repo bug replicated rather than introduced — worth fixing in both places or neither.
  • The two BingService test files duplicate a ~90-line mock harness. Sharing it is awkward because vi.mock must be called in the file it applies to; the split was forced by the 400-line lint cap.

Three checks on this branch were found to prove less than they claimed, each fixed and each fix verified by deliberately breaking the thing it guards: the schema parity test never imported the Bing schemas; a "makes no network call" assertion sat outside the describe that stubs fetch; and a test file was silently failing to collect after a new import reached env from cloudflare:workers.

Worth noting for calibration: the review found real defects, but the one bug that stopped the feature working — no self-hosted OAuth path, so Connect failed in the auth mode this repo defaults to — was found by clicking the button, not by any of the five axes or two verification passes, all of which only read code.

🤖 Generated with Claude Code

mlava and others added 18 commits July 25, 2026 18:28
Adds specs/0009 recording the Bing Webmaster Tools integration design,
verified against the live API rather than the docs.

Two unknowns blocked the design and were settled by scripts/bing-oauth-spike.ts:

- Refresh tokens: public reports describe Bing rotating refresh tokens and
  then rejecting the rotated ones, which would be fatal here since Better
  Auth overwrites the stored token with whatever a provider returns. Not
  reproduced — Bing returns no refresh_token on refresh at all, so the
  original is preserved and survives reuse. genericOAuth is safe.
- Account identity: Bing has no userinfo endpoint and issues no id_token,
  but the access token is base64url JSON carrying webmasteruid and
  webmasteremail. src/shared/bing.ts decodes it, so getUserInfo needs no
  network call.

The spike script is retained: the refresh finding rests on a short
observation window and specs/0009 calls for re-checking it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 1 of specs/0009: the three independent layers, built in parallel
isolated worktrees and integrated here.

- bing_connections across both dialects (SQLite/D1 + Postgres) with
  generated migrations, mirroring gsc_connections.
- createBingClient: mints tokens via Better Auth, unwraps Bing's WCF `d`
  envelope, parses /Date(ms)/ values, and maps 401/403/404/429 to typed
  errors. Reads are free — no credit metering, same as GSC.
- bingProviderConfig for genericOAuth with explicit endpoints (Bing has no
  discovery document) and a getUserInfo that decodes the access token
  instead of calling a userinfo endpoint that does not exist.
- Encrypted-at-rest API key helpers for self-hosters, who cannot complete
  an OAuth flow locally because Bing rejects localhost redirect URIs.

Two integration points were kept out of worker ownership and wired by hand:
registering the provider in auth-config, and exporting the new table from
the D1 barrel that drizzle-kit and the D1 client both read.

GetRankAndTrafficStats rows are surfaced as-is: only GetUserSites was
verified against the live API, so its field names are not yet pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 2a of specs/0009. Mirrors the GSC layering so the two integrations
read the same way.

BingConnectionRepository upserts on project_id, so re-selecting a site
replaces the mapping rather than adding a second, and coalesces
connected_account_email so a null never clobbers a stored value.

BingService gates site selection on Bing's isVerified boolean (Bing has no
permissionLevel string), and keeps GSC's disconnect semantics: the OAuth
grant is unlinked only when the disconnector is the connector and no other
project still uses that account. A dead grant surfaces as
requiresReconnect rather than throwing, so one revoked connection cannot
break the whole site list.

Deliberately out of scope, both flagged rather than stubbed: API-key
connections (the client is OAuth-only, so those rows raise instead of
silently failing) and any reshaping of GetRankAndTrafficStats rows, whose
field names are still unverified against the live API.

Reads stay free — no credit metering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BingService wrote connectedAccountEmail: null because the client had no way
to reach it — Bing publishes no userinfo endpoint, which is what GSC calls
for the equivalent. But the email is already a claim on the access token, so
createBingClient.getConnectedEmail() decodes it with no extra network call.

setSite and the grant listing now surface it, both best-effort: a missing or
undecodable claim yields null rather than failing the connection or hiding
the whole account list. Round 3's UI needs this for the "connected by" line.

Splits BingService.test.ts, which crossed the 400-line lint cap once these
cases were added; disconnect and grant-failure coverage now lives alongside
it in BingService.disconnect.test.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 2b of specs/0009 — the layer that makes the client reachable.

Server functions mirror gsc.ts: grant status, connection status, site
listing, select, and disconnect, all project-scoped through
requireProjectContext. Site selection gates on Bing's isVerified boolean
rather than GSC's permissionLevel string.

The MCP tool derives its table columns from the keys actually present on
the returned rows instead of hard-coding them, because Bing's
GetRankAndTrafficStats field names are still unverified — inventing
Clicks/Impressions would produce a table that silently renders nothing.
A missing connection or a revoked grant returns an actionable connect or
reconnect message rather than surfacing as a fault, matching how the
Search Console tools behave. Reads stay free — no credit metering.

Registration in mcp/server.ts is wired here rather than by the worker, so
no lane could collide on the shared tool registry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Live call against a verified site pins what GetRankAndTrafficStats actually
returns: one row per day carrying Date, Clicks, and Impressions, plus the
__type marker every WCF payload has. The date arrives as
/Date(1781852400000-0700)/ — the offset is informational, the milliseconds
are already UTC, which the existing parser handles.

Rows are now typed and mapped to { date, clicks, impressions } instead of
passed through as Record<string, unknown>. The MCP tool needs no change: it
derives columns from the keys present, which are now meaningful names rather
than Bing's PascalCase.

Adds a --step=call probe to the spike script, which is how the shape was
captured and how the next endpoint's shape should be.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The refresh behaviour now rests on two independent grants rather than one
ten-minute window: no refresh_token returned on refresh, original reusable,
observed before and after regenerating the OAuth client secret. The
multi-day expiry complaint in the public reports remains untested and is
called out as such.

webmasteruid also survived the credential regeneration, confirming it keys
the Bing account rather than the grant or the OAuth client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 3 of specs/0009 — the user-facing surface.

Bing gets its own page rather than a source toggle on Search Performance.
GetRankAndTrafficStats accepts no date range, no device or country filter,
and no paging, so sharing that page's chrome would advertise controls Bing
cannot honour. The page shows clicks/impressions totals and the daily rows,
and falls back to the connection card when the project isn't connected.

Adds getBingPerformance, which round 2b deliberately left out: not-connected
and dead-grant both resolve to { connected: false } so the page renders the
connect card instead of an error boundary.

The connect card mirrors the Search Console one, with two differences that
follow from Bing's API: sites are gated on isVerified rather than a
permission-level string, and an unconfigured deployment gets an explanatory
setup notice instead of a button, because Bing rejects localhost redirect
URIs and allows one redirect URI per client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both of these passed while proving less than they claimed.

schema-parity.test.ts never imported the bing schemas, so the D1/Postgres
mirror guard silently skipped the new table — the 120 green cases covered
only pre-existing schemas. Verified the fix bites by deleting a column from
the Postgres mirror and confirming a precise failure.

The getConnectedEmail suite sat outside the describe that owns the fetch
stub, so "makes no network call" could not detect a network call: an added
fetch would hit the real global and leave the mock uncalled. Moved it into
the stubbed scope and verified by sneaking a fetch into the client, which
now fails the test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…secret

Typing the rank/traffic rows against the live API left stale claims behind
in four places. The MCP tool still derived table columns dynamically to
absorb variability that no longer exists, its model-facing description said
the row shape "varies by site", and its empty state told callers to try a
different date range — for an endpoint that accepts no date range at all.
Columns are now fixed like every other MCP tool, and two tests that asserted
the dynamic behaviour are replaced by ones asserting the real shape,
including that an unparseable date renders as unknown rather than invented.

Also stops logging grant.accountId on an unexpected fault. That value is the
webmasteruid, which doubles as Bing's site verification code, so the inherited
GSC logging line put a secret into Worker logs and Sentry — a lower-trust
store than the database. Logs the Better Auth row id instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…code

Three reviewers flagged the API-key lane as scaffolding. Verification agreed
in part: the two crypto helpers had no caller and no column to write to, and
were two one-line wrappers over better-auth's symmetricEncrypt that will be
rewritten when the real storage lands — so they go.

The auth_mode column stays. Removing it means regenerating the head migration
and snapshot in both dialects, then adding a third migration to restore it
when the lane lands; a NOT NULL discriminator is the right shape for a
two-mode connection. The getPerformance guard stays too — authMode is read
back from the database as a union, and refusing an unhandled member at a
trust boundary beats silently building an OAuth client for a non-OAuth row.

The real defect was the spec claiming API-key mode "is also supported" with
the key "stored encrypted at rest in a dedicated column" — no such column
exists. It now says deferred, and three other overclaims are corrected: the
surface described top queries, top pages and crawl issues that v1 does not
build; the MCP section promised a crawl-issues tool that does not exist; and
the Context still called the refresh finding ten minutes of observation after
Consequences recorded the replication.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviewers flagged two duplications between the Bing and Search Console
cards. Verification rejected the larger one and confirmed the smaller.

REJECTED — merging BingSitePicker into SitePicker. The claimed nine-line
diff is really 19 hunks, and "(no access)" vs "(not verified)" mean opposite
things to a user: one is a permission problem, the other an action to take in
Bing Webmaster Tools. A merged component needs a glyph plus ten label strings,
which reads worse than two clear files. SitePicker is also consumed by shipped
GSC onboarding, so refactoring it to land a Bing feature risks an existing
activation flow for no user-visible benefit.

APPLIED — the card chrome. StatusPill was byte-identical, IntegrationCard
differed by its title and ConnectedState by a glyph and one label: three props,
no configuration bag. Both cards lose ~107 lines. The card bodies stay
separate, since their connect flows, setup warnings, and invalidation sets
genuinely differ — Bing has no dashboard cache to invalidate because the card
only appears in project settings.

Also removes getBingGrantStatus and the GRANT_STATUS_KEY invalidations. No
query ever registered that key, so every invalidation of it was a no-op; the
GSC equivalent exists because onboarding and the re-engagement modal read it,
and Bing has neither.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clicking Connect on a cloudflare_access deployment failed with "Could not
start Bing sign-in". Better Auth's oauth2.link needs a Better Auth session,
which cloudflare_access and local_noauth do not have — Search Console solves
this with a hand-rolled flow and Bing had no equivalent, so the card offered
a button that could not work in the mode this repo defaults to.

Adds the Bing counterpart of gsc/selfHostedOAuth.ts: HMAC-signed state, code
exchange, and an encrypted account row written exactly the way Better Auth's
setTokenUtil would, so getAccessToken reads it back unchanged. The one
Bing-specific difference is identity — no id_token and no userinfo endpoint,
so the account id comes from the access token's webmasteruid claim.

The redirect URI is {origin}/api/bing/oauth/callback. Bing permits one per
registered client and rejects localhost, so each deployment needs its own
client and a localhost dev server still cannot complete the flow.

Five review axes and two verification passes missed this because all of them
read code; it only surfaced when the button was actually clicked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ationale

Verified end to end on a cloudflare_access deployment: connect, site
selection, and daily rows all work. The spec justified API-key mode with
"a self-hoster cannot complete an OAuth flow" — that is now only true of
localhost, since Bing refuses localhost redirect URIs but is perfectly happy
with a self-hosted deployment on a real domain. The remaining use for the
API-key lane is local development without a public tunnel.

The setup notice now names BETTER_AUTH_SECRET, which gates the flow and whose
absence shows up as "Setup required" with no explanation, and gives the exact
callback path to register.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This handles token exchange, at-rest encryption, signed state, and an
open-redirect guard, and had no coverage — the Search Console original it
mirrors has none either, so this is new ground rather than parity.

Eleven cases, built around driving the real authorize step to obtain a
genuinely signed state so the callback tests exercise verification rather
than a hand-made string. Covers: tampered state rejected, state issued for
another user refused, an off-origin callbackURL collapsing to "/" instead of
becoming an open redirect, denied consent returning without exchanging
anything, the grant being keyed by webmasteruid, a stored refresh token
surviving a re-link that returns none (Bing never returns one), and both
failure paths — a rejected code exchange and an unreadable access token —
storing nothing.

The two security assertions were mutation-tested: removing the HMAC check
and the same-origin guard each fails exactly the test that covers it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Live MCP output showed the offset moving between 08:00Z and 07:00Z across
March — these are midnight in Bing's own reporting timezone (US Pacific,
shifting with daylight saving), not instants. Formatting them in the viewer's
timezone labels every row a day early for anyone at UTC-9 or further west;
Honolulu and Anchorage render a 9 Feb bucket as 8 Feb.

The helper moves to its own module so it can be tested without dragging the
page's server-function imports into the test's module graph, and reads the
day in UTC. Mutation-tested: dropping the UTC pin fails the tests under
TZ=America/Anchorage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Probed live so the next person does not have to. Both endpoints share one row
shape carrying AvgImpressionPosition, so average position and a
striking-distance view are reachable — but the rows are sampled at roughly 16
dates across five months, which makes query-level trends lumpy and a 28-day
slice potentially empty. Also records that the traffic window varies by site
and that its Date is a Pacific day bucket rather than an instant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Issues are disabled on the fork, so the follow-up lands where specs/0008
already puts this kind of note. Captures what the probed query/page endpoints
make possible — striking distance first — and what still cannot be built
honestly: no device or country dimension exists, and sampled query rows make a
date-range control promise precision the data lacks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mlava
mlava merged commit c29d42d into main Jul 25, 2026
@mlava
mlava deleted the feat/bing-webmaster branch July 27, 2026 10:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant