Google Ads adapter
Source:
- src/adapters/google/ —
auth.ts+customers.ts+queries.ts+revoke.ts+types.ts+index.ts(GoogleAdapterclass). - src/adapters/adapter.interface.ts — cross-platform contract (
Platform,DateRange,PlatformAdapter). - src/adapters/registry.ts — platform → adapter dispatch.
The Google-specific implementation of PlatformAdapter. The oauth-routes plugin and the MCP tool wrapper (PR-6) are platform-agnostic and dispatch through the registry.
Public API
The PlatformAdapter contract:
class GoogleAdapter implements PlatformAdapter {
readonly platform = 'google';
exchangeAuthCode(tenantId, code, codeVerifier): Promise<void>;
revoke(tenantId): Promise<void>;
fetchAccountHealth(tenantId, range): Promise<AccountHealthData>;
fetchSearchTermWaste(tenantId, range): Promise<SearchTermWasteData>;
fetchQualityScore(tenantId, range): Promise<QualityScoreData>;
fetchAuctionInsights(tenantId, range): Promise<AuctionInsightsData>;
fetchPmaxBreakdown(tenantId, range): Promise<PmaxBreakdownData>;
fetchBudgetOptimizer(tenantId, range): Promise<BudgetOptimizerData>;
fetchWeeklyAnomaly(tenantId, range): Promise<WeeklyAnomalyData>;
}Each fetch* method:
- Calls
resolveCustomerId(tenantId)— returns the cachedaccount_idfromplatform_credentials, or hitscustomers:listAccessibleCustomersand persists the first one. - Delegates to the matching
query<Report>inqueries.ts. - Returns Zod-parsed
*Data(the schemas live intypes.ts).
exchangeAuthCode POSTs to Google’s token endpoint with the authorization code + PKCE verifier, envelope-encrypts both tokens via credentials.service, and upserts a row into platform_credentials. The customer ID is recorded as empty string at this point — resolveCustomerId fills it on the first fetch* call.
ensureValidToken (in auth.ts) returns a live access token. If the cached token has more than 5 minutes left, the cached value is returned directly. Otherwise it decrypts the refresh token, calls Google’s token endpoint with grant_type=refresh_token, encrypts the new access token, updates the row, audits oauth.token_refreshed/success, and returns the fresh access token. A 401 from Google audits oauth.token_refreshed/failure and throws.
Refresh-skew window
5 minutes (REFRESH_SKEW_SECONDS = 300). Tokens are refreshed before they actually expire so a caller is never handed a token that is about to die mid-flight.
Why two transactions on the refresh path
The implementation:
- Opens tx 1, sets
app.current_tenant_id, reads the credentials row, exits. - (No tx held.) Decrypts the refresh token, POSTs to Google, encrypts the new access token.
- Opens tx 2, sets context, updates the row.
- (After commit.) Writes the success audit row.
Holding a single transaction across the network call to Google would idle a pool connection for hundreds of milliseconds per refresh — a real throughput problem if many tenants refresh concurrently. The two-tx form releases the connection during the network hop.
Race condition: two concurrent calls for the same tenant will both refresh and both update the row. Google does NOT invalidate prior access tokens on refresh, so the loser’s token remains valid until expiry; the DB ends up with whichever value the second UPDATE committed. Acceptable for Phase 2; PR-7 could add a tenant-scoped advisory lock if dedup becomes important.
RLS and the upsert constraint
platform_credentials is RLS-isolated; both exchangeAuthCode and ensureValidToken set app.current_tenant_id inside their transactions. The upsert in exchangeAuthCode uses onConflictDoUpdate against (tenant_id, platform) — Phase 2 migration 0002_new_synch.sql adds the matching platform_credentials_tenant_platform_idx unique index.
Configuration the adapter reads
config.googleClientId(public, from env)config.googleOauthRedirectUri(public, from env)config.googleTokenEndpoint(defaults tohttps://oauth2.googleapis.com/token)loadSecret('GOOGLE_CLIENT_SECRET')(sensitive, from secrets store)
config is parsed once at module load via loadConfig(); the client secret is loaded once at module load via loadSecret. Both fail the boot if absent — see secrets-loader.md and entry-point.md order of operations.
Registry
src/adapters/registry.ts is the platform → adapter dispatch. It maps 'google' → new GoogleAdapter(). Phase 3 will add meta and tiktok entries (separate adapter classes implementing the same interface).
getAdapter(platform) throws unsupported_platform:<name> on an unknown key — that’s what becomes the route’s 501 response.
Revocation
revoke.ts — revokeUpstream(tenantId):
- Read the
platform_credentialsrow inside an RLS-scoped tx; return early if no row (idempotent). - Decrypt the refresh token and POST it to
https://oauth2.googleapis.com/revoke?token=.... Best-effort: errors are swallowed so a Google outage doesn’t block the local wipe. DELETEtheplatform_credentialsrow inside a second RLS-scoped tx.
The tenant_deks row is intentionally NOT deleted — re-connecting the same platform reuses the same DEK. For full GDPR erasure, call destroyDek(tenantId) separately.
GAQL queries — design notes
- The seven query functions live in
queries.ts. Each callsapiClient.Customer({...}).report({...})with the entity / attributes / metrics / segments / constraints for that report, then maps the raw protobuf row shape (snake_case, mixed string/number/bigint primitives) to a camelCase row matching the matching Zod schema intypes.ts. - The library at v23 manages its own access-token cache keyed by the refresh token;
queries.tsdecrypts the stored refresh token viadecryptTokenand passes it in. Per-query background refreshes issued by the SDK are NOT individually written to ouraudit_log— they’re implicit in themcp.tool_calledrow written by the tool wrapper. The OAuth callback andresolveCustomerIdpaths still go through ourensureValidToken, so every flow-level refresh is audited. - All three Phase 2 date ranges (
last_7_days,last_30_days,last_90_days) are passed asfrom_date/to_datestrings rather than the SDK’sDateConstantenum — the enum stops atLAST_30_DAYSand we want a single code path for all three.
Customer ID resolution
customers.ts — resolveCustomerId(tenantId):
- Read
platform_credentials.account_idinside a short tx with RLS context set. - If non-empty, return it.
- Else:
ensureValidToken(cached or refreshed; audited), callGET /v17/customers:listAccessibleCustomerswith the access token + developer token, pick the firstcustomers/{id}resource name. - Persist the customer ID into
platform_credentials.account_idfor next time.
Phase 2 simplification: only the FIRST accessible customer is ever picked. If the OAuth identity has access to multiple customers under an MCC, only one is ever used. Phase 3 adds a customer-picker.
Tests
tests/integration/oauth.test.ts covers the auth half:
ensureValidTokenreturns the cached access token when expiry > 5min away (no Google fetch).ensureValidTokenrefreshes when expiry is within 5min, writes the new ciphertext toplatform_credentials, writes oneoauth.token_refreshed/successaudit row.- A 401 from Google writes
oauth.token_refreshed/failureand the function throws. - Full
/auth/google/start→/auth/google/callbackround-trip seeds a row whoseaccess_token_encandrefresh_token_encdecrypt back to the mocked Google response.
tests/integration/google-adapter.test.ts covers the queries / customer-resolution half:
- One test per of the seven query functions — asserts that the canned raw GAQL rows from the mocked
Customer.report()parse into the matching Zod schema with the expected camelCase shape, nullable fields included. resolveCustomerId: short-circuits whenaccount_idis already set, persists the first returned customer ID on cold start, throws on an empty resource list.GoogleAdapter.fetchAccountHealthdelegates toresolveCustomerId+queryAccountHealthend-to-end.
tests/integration/revocation.test.ts covers the revoke half:
revokeUpstreamPOSTs the decrypted refresh token to the right URL, then deletes the row.- Wipes the local row even when Google’s revoke endpoint is unreachable.
- Idempotent — no error when there’s nothing to disconnect.
Mocks: vi.mock('google-ads-api', …) at the module boundary; globalThis.fetch for upstream Google calls (revoke, listAccessibleCustomers). Everything below — Drizzle, crypto, audit log, RLS — runs against real Postgres.
Cross-references
oauth-routes— what callsexchangeAuthCode.credentials-service— what wraps tokens.database.md—platform_credentialsschema, RLS, unique index.secrets-loader.md—GOOGLE_CLIENT_SECRET/GOOGLE_DEVELOPER_TOKEN.