Skip to content

Feat/bing ai citations import - #191

Open
mlava wants to merge 133 commits into
every-app:mainfrom
mlava:feat/bing-ai-citations-import
Open

Feat/bing ai citations import#191
mlava wants to merge 133 commits into
every-app:mainfrom
mlava:feat/bing-ai-citations-import

Conversation

@mlava

@mlava mlava commented Aug 5, 2026

Copy link
Copy Markdown

No description provided.

mlava and others added 30 commits July 25, 2026 11:26
cf-selfhost-deploy.sh writes cf-resources.json as a single line of JSON
containing the operator's own Cloudflare resource IDs. It was committed in
f3d6374, so `prettier --check .` failed on a clean tree — and since ci:check
chains with &&, that stopped knip, tsc, and oxlint from running at all.

Formatting the file would only hold until the next deploy rewrote it, and
each contributor's copy wants their own account's IDs, so it is ignored
rather than reformatted — the same treatment .env already gets. Local
copies are untouched; deploys keep working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix: stop tracking cf-resources.json so ci:check passes
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>
Bing Webmaster Tools integration
…position tiles, get_bing_queries MCP tool

Builds the spec's Future directions (specs/0009) on the sampled
GetQueryStats/GetPageStats endpoints probed live on 2026-07-25:

- bingClient: getQueryStats/getPageStats sharing one probed row schema
- bingQueryReport: whole-window aggregation (impression-weighted position,
  AvgClickPosition ignored) and the 5-20 striking-distance band
- BingService.getQueryReport + getBingQueryReport server fn
- UI: CTR and avg-position tiles, half-split period deltas from the dense
  daily series, tabs for striking distance / queries / pages / daily,
  copy/save-as-keywords on the striking table
- MCP: get_bing_queries (dimension, strikingDistanceOnly, limit)

No date picker or device/country filters — Bing's API has none, and the
query/page rows are sampled (~16 dates over ~5 months), so only
whole-window aggregates are honest. No DB changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(bing): v2 panels — striking distance, queries/pages tables, tiles, get_bing_queries
Bing pads its daily window back before a site had any presence; those
leading all-zero rows gutted the previous half and printed +6950%-style
deltas on the tiles. Trim leading zero days before splitting, and
suppress the delta entirely when the previous half carries under 1% of
the current half's impressions — a ratio against noise is not
information.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(bing): honest delta baseline for the half-split comparison
GetPageQueryStats probed live 2026-07-25: takes siteUrl plus a `page`
query parameter (that exact name — the docs disagree with themselves)
and returns the same sampled QueryStats row shape filtered to one page;
an unknown page returns 0 rows. Recorded in specs/0009.

- bingClient.getPageQueryStats + `--page`/`--pageParam` passthrough on
  the spike script's call step
- BingService.getPageQueries + getBingPageQueries server fn, aggregated
  with the existing whole-window math
- Pages tab: per-row "Queries →" action opens the queries driving that
  page, with a back button; drill-down resets on tab switch
- get_bing_queries MCP tool: optional pageUrl input for the same
  drill-down (composes with strikingDistanceOnly)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GetCrawlStats (dense daily series), GetCrawlIssues (0 rows on a healthy
site — shape unpinned), GetLinkCounts and GetUrlLinks (param is `link`;
single-object responses, inner shapes unpinned on a site with no inbound
links). Probed 2026-07-25 against scholar-sidekick.com.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… in the legacy API

Re-probed GetLinkCounts/GetUrlLinks with the integer page pager against a
site whose portal shows 27 referring domains / 85 referring pages: still
200 + empty. Matches an unresolved Microsoft Q&A report. Recorded so
nobody builds on them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mlava and others added 30 commits August 3, 2026 18:01
feat(revenue): Revenue page with RapidAPI subscriptions and Stripe metrics
Verified live 2026-08-03 against a real hub:
- Subscription status vocabulary is ACTIVE/DELETED (not CANCELED), and
  DELETED can appear with canceledAt null (plan switches) — only an
  explicit ACTIVE now counts as an active subscriber.
- The subscriptions query silently ignores an unrecognized where.apiId
  and returns the caller's own subscriptions instead of erroring. Nodes
  now carry api.id and are scoped to the requested API, and setApi
  rejects an id that matches none of the returned nodes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(revenue): harden RapidAPI client against live Platform API behavior
Newer Stripe accounts have no account-default API version and reject
requests without a Stripe-Version header ('You did not provide an API
version', verified live 2026-08-03). Pin the current stable release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(revenue): pin Stripe-Version header on API requests
Org-level keys reject requests that don't name a target account
('Please include the Stripe-Context header', verified live 2026-08-03).
Optional STRIPE_CONTEXT env passes the acct_ id through; account-level
keys keep working with it unset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two projects can use Stripe on different accounts under one org key: the
connection row stores the target account id (an identifier, not a
secret), the client sends it as Stripe-Context, and the connection card
gains an account field. Replaces the instance-level STRIPE_CONTEXT env
added earlier on this branch — wrong granularity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(revenue): per-project Stripe account for organization keys
A retired one-off product's purchase history is still worth tracking
(verified live: the real one-off product is archived). Archived products
sort last and are labeled in the dropdown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(revenue): include archived Stripe products in the picker
…ucts

Refunds live on PaymentIntents, not Checkout Sessions, so refunds from
/v1/refunds are attributed to the one-off product via the session's
payment_intent — with a capped per-refund session lookup for refunds
whose purchase predates the 60-day sessions window. The one-off row
gains Refunds and Net revenue tiles; a key without the Refunds read
grant degrades to the old gross-only tiles with a hint instead of
failing the panel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(revenue): Stripe refunds and net revenue tiles
Pin the dashboard grid to fixed positions - Search performance | Bing
performance on the first row, Revenue | Site audit on the second, with
Backlink pulse below - replacing the data-first sort that reshuffled
cards as data arrived. The MCP pitch still leads during onboarding.

The new Bing card reuses getBingPerformance (shared query key with the
Bing page) and shows clicks, impressions and CTR with half-vs-half
deltas; avg position is omitted since it needs the slow sampled-stats
call. The new Revenue card reuses getStripeRevenue and shows
subscribers, est. MRR, purchases and net revenue (falling back to gross
when the key lacks Refunds read access).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(dashboard): Bing and Revenue cards in a fixed 2x2 layout
…lookups

The dashboard card and the Backlinks page hit the same DataForSEO
summary endpoint but cached independently (24h visit-triggered snapshot
vs 6h overview cache), so the card could lag the page by a day whenever
the live index moved.

Backlinks overview lookups - web page and get_backlinks_overview MCP
tool - now record the summary as a dashboard snapshot when the target
is the project's own domain. Zero extra DataForSEO spend: the overview
response is reused, and a snapshot recorded here also satisfies the
dashboard's daily freshness check, saving that path's metered call.
Cache-served overviews keep their original fetchedAt, and a
newer-than-latest guard drops writes that would move the card backwards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(dashboard): sync backlink snapshot from Backlinks-page overview lookups
DataForSEO's backlinks summary endpoint never returns new/lost counts,
so the card's New links / Lost links tiles have shown em-dashes since
they shipped. Deltas now come from diffing the latest snapshot against
a previous-day baseline (the newest snapshot at least 20h older, scanned
over recent rows since overview-synced snapshots can add several per
day) - free, no extra DataForSEO calls.

The card shows the deltas as percent subs under Ref. domains and
Backlinks (matching the GSC and Bing cards) and fills the freed row
with Rank and Broken links, which every snapshot already stores. The
stamp names the baseline day. The always-null new/lost insert fields
are dropped; the DB columns stay (nullable, no migration needed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(dashboard): replace dead New/Lost tiles with snapshot-diff deltas
The Bing tiles totalled Bing's entire reported window (~6 months) and
compared its newer half against its older half, so a growing site showed
four-digit deltas against its own near-zero past. The GSC surfaces frame
everything as last 28 days vs the prior 28.

last28DayReport replaces splitDailySeries: it cuts fixed 28-day windows
from the daily rows Bing sent, anchored on the newest reported day (the
endpoint accepts no date range, so the windows are client-side). Rows
are day buckets stamped at midnight US Pacific; window membership uses
rounded day-distance so the DST hour shift can't move a day across a
window boundary. The delta is omitted when Bing's window doesn't span
the full prior 28 days rather than comparing against a partial one.

Applied to both the Bing performance page tiles (the daily tab still
shows the full window) and the dashboard card, whose stamp now reads
"last 28 days" like the GSC card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(bing): last 28 days vs prior 28 tiles, matching the GSC report
get_backlinks_overview started calling waitUntil for its snapshot sync
(7857308) but the tool-text-output mock only exported env, failing the
suite on main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RapidAPI support confirmed (2026-08-04) there are no platform APIs for
public-marketplace Hub data — the subscriptions query is Enterprise Hub
only, and the accidentally-public test endpoint we had verified against
was retired to private. Subscriber numbers exist solely in Studio
Analytics dashboards.

The panel becomes manual snapshots: a rapidapi_snapshots table (one row
per project per day, re-logging replaces), a log form + latest-vs-
previous tiles + history on the Revenue page, and a get_rapidapi_snapshots
MCP tool. The GraphQL client, connection card/settings section, and
RAPIDAPI_* env vars are removed; rapidapi_connections is dropped.
RevenuePage's shared tiles move to revenueParts.tsx to stay under the
file-size lint budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(revenue): replace RapidAPI live queries with manual snapshots
Revenue moves out of the two-column grid to span the page width at the
top, with its four tiles sharing one row on large screens. The grid
below keeps fixed pairs: GSC | Bing, then Backlinks | Site audit. The
MCP pitch still leads the grid during onboarding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RapidAPI marketplace subscriptions are recurring revenue, but the
snapshots only stored subscriber counts, so nothing could be turned
into dollars. Snapshots now take an optional monthly plan price (USD
cents - RapidAPI bills in USD only), and the report derives est. gross
MRR (paying x price) and est. net MRR after RapidAPI's flat 25%
marketplace fee, both from the latest snapshot only.

The panel gains a "Plan price $/mo" form field (prefilled from the
latest snapshot since the price rarely changes, kept after logging),
two MRR tiles with the working shown as hints, and a Plan price history
column. formatMoney takes optional fraction digits so $5.99-style plan
prices don't round to whole dollars.

Additive nullable column on both SQLite and Postgres; migrations
generated and applied locally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(dashboard): full-width revenue card above the paired cards
feat(revenue): plan price on RapidAPI snapshots with est. MRR tiles
The dashboard Est. MRR tile showed Stripe only. It now also queries the
RapidAPI snapshots (shared query key with the Revenue page) and combines
the sources: Stripe MRR + RapidAPI net MRR summed when Stripe bills in
USD (RapidAPI is USD-only), with the breakdown as a sub note; a non-USD
Stripe MRR keeps its own value and notes the RapidAPI figure alongside
rather than summing across currencies.

A project with RapidAPI MRR but no Stripe connection now shows the tile
instead of the Stripe pitch, and a RapidAPI fetch failure only costs
its contribution. The stamp reads "Stripe + RapidAPI" when both count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(dashboard): fold RapidAPI net MRR into the revenue card's Est. MRR
Bing Webmaster Tools' AI performance report (Copilot/AI-answer citations)
has no API, only CSV exports. Adds a manual-import path mirroring the
RapidAPI snapshot pattern: a daily Overview upserted per (project, date),
and dated Pages/Queries snapshots (no per-row date, so history is kept
across uploads) with a new "AI performance" tab on the Bing page and a
get_bing_ai_citations MCP tool. See specs/0015.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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