Skip to Content
Deneva MCPComponentsGoogle Adapter

Google Ads adapter

Source:

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:

  1. Calls resolveCustomerId(tenantId) — returns the cached account_id from platform_credentials, or hits customers:listAccessibleCustomers and persists the first one.
  2. Delegates to the matching query<Report> in queries.ts.
  3. Returns Zod-parsed *Data (the schemas live in types.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:

  1. Opens tx 1, sets app.current_tenant_id, reads the credentials row, exits.
  2. (No tx held.) Decrypts the refresh token, POSTs to Google, encrypts the new access token.
  3. Opens tx 2, sets context, updates the row.
  4. (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 to https://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.tsrevokeUpstream(tenantId):

  1. Read the platform_credentials row inside an RLS-scoped tx; return early if no row (idempotent).
  2. 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.
  3. DELETE the platform_credentials row 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 calls apiClient.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 in types.ts.
  • The library at v23 manages its own access-token cache keyed by the refresh token; queries.ts decrypts the stored refresh token via decryptToken and passes it in. Per-query background refreshes issued by the SDK are NOT individually written to our audit_log — they’re implicit in the mcp.tool_called row written by the tool wrapper. The OAuth callback and resolveCustomerId paths still go through our ensureValidToken, so every flow-level refresh is audited.
  • All three Phase 2 date ranges (last_7_days, last_30_days, last_90_days) are passed as from_date / to_date strings rather than the SDK’s DateConstant enum — the enum stops at LAST_30_DAYS and we want a single code path for all three.

Customer ID resolution

customers.tsresolveCustomerId(tenantId):

  1. Read platform_credentials.account_id inside a short tx with RLS context set.
  2. If non-empty, return it.
  3. Else: ensureValidToken (cached or refreshed; audited), call GET /v17/customers:listAccessibleCustomers with the access token + developer token, pick the first customers/{id} resource name.
  4. Persist the customer ID into platform_credentials.account_id for 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:

  • ensureValidToken returns the cached access token when expiry > 5min away (no Google fetch).
  • ensureValidToken refreshes when expiry is within 5min, writes the new ciphertext to platform_credentials, writes one oauth.token_refreshed/success audit row.
  • A 401 from Google writes oauth.token_refreshed/failure and the function throws.
  • Full /auth/google/start/auth/google/callback round-trip seeds a row whose access_token_enc and refresh_token_enc decrypt 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 when account_id is already set, persists the first returned customer ID on cold start, throws on an empty resource list.
  • GoogleAdapter.fetchAccountHealth delegates to resolveCustomerId + queryAccountHealth end-to-end.

tests/integration/revocation.test.ts covers the revoke half:

  • revokeUpstream POSTs 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