Skip to Content
Deneva MCPPlan3 — Meta Tiktok

Phase 3 — Meta + TikTok Adapters

Detailed execution doc for Phase 3 of the MCP Marketing Tool Architecture Plan. Builds on Phase 1 (foundation) and Phase 2 (Google adapter, OAuth, envelope encryption, cache).

Estimated effort: 1–2 weeks for one engineer. Phase 4 follow-up: (not yet written — will be docs/phase-4-inngest-sync.md).


Goal

Plug Meta and TikTok into the adapter contract Phase 2 established. By the end of Phase 3, every tool’s supportedPlatforms array reflects the architecture matrix, tenants can pick which ad account to bind during OAuth, refresh failures surface as typed errors instead of opaque 5xx, and a single DELETE /admin/tenants/:id call satisfies GDPR right-to-erasure for everything Phase 1–2 stored.

If Phase 3 is done correctly: the same MCP tool calls a customer made against Google in Phase 2 work against Meta and TikTok with no client-side changes other than the platform parameter.

Definition of Done (high-level — full checklist in §11)

  • A tenant can complete OAuth for meta and tiktok end-to-end. Tokens land envelope-encrypted; tokenExpiresAt reflects the long-lived expiry for Meta and the standard expiry for TikTok.
  • During OAuth callback, a tenant is prompted to choose an account from the list of accessible accounts; the choice persists on platform_credentials.account_id.
  • All 14 (platform, tool) combinations in the architecture matrix work; combinations outside the matrix still return unsupported_platform cleanly.
  • When a refresh fails because the user revoked access upstream, the response is a typed token_revoked error and the audit log records it; same for missing scopes (scope_missing).
  • GET /tenant/connections returns connection state for the calling tenant.
  • DELETE /admin/tenants/:id cascades through platform_credentials, metric_cache, sync_log, destroys the DEK, anonymises audit_log rows, and deletes the tenants row — all in one transaction.

Workstream order & dependency graph

A. Meta adapter ───────┐ ├──▶ C. Multi-account selection ──▶ D. Tool matrix widening B. TikTok adapter ─────┘ ├──▶ E. Refresh edge-case typing (refactors Google adapter too) └──▶ F. Connection-status endpoint G. GDPR erasure (depends on revoke + DEK destroy from Phase 2) H. Tests run alongside everything

A and B are independent. C/D depend on both. E refactors the existing Google adapter to share an error taxonomy.


Workstream A — Meta adapter

A1. Config additions

Add to src/config.ts:

META_APP_ID: z.string().min(1), META_OAUTH_REDIRECT_URI: z.string().url(), META_AUTH_ENDPOINT: z.string().url().default('https://www.facebook.com/v21.0/dialog/oauth'), META_TOKEN_ENDPOINT: z.string().url().default('https://graph.facebook.com/v21.0/oauth/access_token'), META_GRAPH_VERSION: z.string().default('v21.0'), META_SCOPES: z.string().default('ads_read,business_management'),

META_APP_SECRET is loaded from secrets — never process.env. Add 'META_APP_SECRET' to REQUIRED_SECRETS.

A2. OAuth — short→long-lived exchange in callback

Meta has no traditional refresh-token grant. Instead, a short-lived user token (~1h) is exchanged for a long-lived token (~60d) via fb_exchange_token, and long-lived tokens can themselves be re-exchanged before expiry. We treat the long-lived token as both access and refresh.

File: src/adapters/meta/auth.ts

import { and, eq } from 'drizzle-orm'; import { db } from '../../db/index.js'; import { platformCredentials } from '../../db/schema.js'; import { encryptToken, decryptToken } from '../../security/credentials.service.js'; import { loadSecret } from '../../security/secrets.loader.js'; import { config } from '../../config.js'; import { writeAuditEvent } from '../../security/audit-log.service.js'; import { TokenRevokedError, ScopeMissingError } from '../errors.js'; // §E const REFRESH_WHEN_REMAINING_SECONDS = 7 * 24 * 3600; // re-exchange when <7 days left export async function exchangeAuthCode(tenantId: string, code: string, _codeVerifier: string): Promise<void> { const appSecret = (await loadSecret('META_APP_SECRET' as never)).toString('utf8'); // Step 1: code → short-lived user token const shortUrl = new URL(config.META_TOKEN_ENDPOINT); shortUrl.searchParams.set('client_id', config.META_APP_ID); shortUrl.searchParams.set('client_secret', appSecret); shortUrl.searchParams.set('redirect_uri', config.META_OAUTH_REDIRECT_URI); shortUrl.searchParams.set('code', code); const shortRes = await fetch(shortUrl.toString()); if (!shortRes.ok) throw new Error(`meta_short_token_failed: ${shortRes.status}`); const shortJson = (await shortRes.json()) as { access_token: string }; // Step 2: short-lived → long-lived (~60d) const longUrl = new URL(config.META_TOKEN_ENDPOINT); longUrl.searchParams.set('grant_type', 'fb_exchange_token'); longUrl.searchParams.set('client_id', config.META_APP_ID); longUrl.searchParams.set('client_secret', appSecret); longUrl.searchParams.set('fb_exchange_token', shortJson.access_token); const longRes = await fetch(longUrl.toString()); if (!longRes.ok) throw new Error(`meta_long_token_failed: ${longRes.status}`); const longJson = (await longRes.json()) as { access_token: string; expires_in: number }; // Step 3: verify granted scopes const debugRes = await fetch(`https://graph.facebook.com/${config.META_GRAPH_VERSION}/debug_token?input_token=${longJson.access_token}&access_token=${config.META_APP_ID}|${appSecret}`); const debugJson = (await debugRes.json()) as { data: { scopes: string[]; expires_at: number } }; const required = config.META_SCOPES.split(','); const missing = required.filter(s => !debugJson.data.scopes.includes(s)); if (missing.length > 0) throw new ScopeMissingError('meta', missing); const accessEnc = await encryptToken(tenantId, longJson.access_token); // Meta has no separate refresh_token — we re-exchange the long-lived token itself. await db.insert(platformCredentials).values({ tenantId, platform: 'meta', accountId: '', accessTokenEnc: accessEnc, refreshTokenEnc: accessEnc, // same blob; semantics: source for re-exchange tokenExpiresAt: new Date(debugJson.data.expires_at * 1000), scopes: debugJson.data.scopes, }).onConflictDoUpdate({ target: [platformCredentials.tenantId, platformCredentials.platform], set: { accessTokenEnc: accessEnc, refreshTokenEnc: accessEnc, tokenExpiresAt: new Date(debugJson.data.expires_at * 1000), scopes: debugJson.data.scopes, updatedAt: new Date(), }, }); } export async function ensureValidToken(tenantId: string): Promise<string> { const [row] = await db.select().from(platformCredentials) .where(and(eq(platformCredentials.tenantId, tenantId), eq(platformCredentials.platform, 'meta'))); if (!row) throw new Error('no_meta_credentials'); const remaining = (row.tokenExpiresAt!.getTime() - Date.now()) / 1000; if (remaining > REFRESH_WHEN_REMAINING_SECONDS) { return decryptToken(tenantId, row.accessTokenEnc); } // Re-exchange the long-lived token. If Meta says the token is invalid, surface as TokenRevokedError. const appSecret = (await loadSecret('META_APP_SECRET' as never)).toString('utf8'); const current = await decryptToken(tenantId, row.accessTokenEnc); const url = new URL(config.META_TOKEN_ENDPOINT); url.searchParams.set('grant_type', 'fb_exchange_token'); url.searchParams.set('client_id', config.META_APP_ID); url.searchParams.set('client_secret', appSecret); url.searchParams.set('fb_exchange_token', current); const res = await fetch(url.toString()); if (res.status === 400 || res.status === 401) { await writeAuditEvent('oauth.token_refreshed', 'failure', { tenantId, platform: 'meta', status: res.status }); throw new TokenRevokedError('meta'); } if (!res.ok) throw new Error(`meta_token_refresh_failed: ${res.status}`); const json = (await res.json()) as { access_token: string; expires_in: number }; const enc = await encryptToken(tenantId, json.access_token); await db.update(platformCredentials) .set({ accessTokenEnc: enc, refreshTokenEnc: enc, tokenExpiresAt: new Date(Date.now() + json.expires_in * 1000), updatedAt: new Date() }) .where(and(eq(platformCredentials.tenantId, tenantId), eq(platformCredentials.platform, 'meta'))); await writeAuditEvent('oauth.token_refreshed', 'success', { tenantId, platform: 'meta' }); return json.access_token; }

No PKCE support: Meta’s OAuth doesn’t accept PKCE on the token endpoint. Phase 1’s oauth_states row still carries a codeVerifier, but the Meta callback ignores it. The state param remains the CSRF defence.

A3. Account listing

File: src/adapters/meta/customers.ts

export async function listAdAccounts(tenantId: string): Promise<{ id: string; name: string }[]> { const token = await ensureValidToken(tenantId); const res = await fetch( `https://graph.facebook.com/${config.META_GRAPH_VERSION}/me/adaccounts?fields=account_id,name&access_token=${token}` ); if (!res.ok) throw new Error(`meta_list_accounts_failed: ${res.status}`); const json = (await res.json()) as { data: { account_id: string; name: string }[] }; return json.data.map(a => ({ id: `act_${a.account_id}`, name: a.name })); }

A4. Queries

File: src/adapters/meta/queries.ts

Use fetch against graph.facebook.com/${version}/{ad_account_id}/insights. Parameter mapping:

const RANGE_PRESET: Record<DateRange, string> = { last_7_days: 'last_7d', last_30_days: 'last_30d', last_90_days: 'last_90d', }; async function queryInsights(tenantId: string, accountId: string, params: { range: DateRange; fields: string[]; level?: 'account' | 'campaign' | 'adset' | 'ad'; breakdowns?: string[]; }) { const token = await ensureValidToken(tenantId); const url = new URL(`https://graph.facebook.com/${config.META_GRAPH_VERSION}/${accountId}/insights`); url.searchParams.set('access_token', token); url.searchParams.set('date_preset', RANGE_PRESET[params.range]); url.searchParams.set('fields', params.fields.join(',')); url.searchParams.set('level', params.level ?? 'campaign'); if (params.breakdowns) url.searchParams.set('breakdowns', params.breakdowns.join(',')); url.searchParams.set('limit', '500'); const all: unknown[] = []; let next: string | null = url.toString(); while (next) { const res = await fetch(next); if (res.status === 400 || res.status === 401) { const body = await res.json().catch(() => ({})); if ((body as any)?.error?.code === 190) throw new TokenRevokedError('meta'); // OAuthException throw new Error(`meta_insights_failed: ${res.status}`); } const json = (await res.json()) as { data: unknown[]; paging?: { next?: string } }; all.push(...json.data); next = json.paging?.next ?? null; } return all; } export const queryAccountHealth = (t: string, a: string, r: DateRange) => queryInsights(t, a, { range: r, fields: ['spend', 'purchase_roas', 'cost_per_action_type', 'ctr', 'clicks', 'impressions'], level: 'campaign' }); export const querySearchTermWaste = (t: string, a: string, r: DateRange) => queryInsights(t, a, { range: r, fields: ['spend', 'actions'], level: 'ad', breakdowns: ['publisher_platform', 'placement'] }); // queryAuctionInsights, queryBudgetSplit, queryWeeklyAnomaly — same shape, different fields/level. // queryQualityScore + queryPmaxBreakdown stay Google-only — no Meta equivalent.

A5. Adapter top-level

File: src/adapters/meta/index.ts — same shape as src/adapters/google/index.ts. revoke() calls https://graph.facebook.com/${version}/me/permissions with DELETE and a token; if the network call fails, still wipe the local row.

A6. Acceptance

  • exchangeAuthCode walks short→long flow, populates platform_credentials with a 60-day expiry, and verifies scopes (missing → ScopeMissingError).
  • ensureValidToken re-exchanges when <7 days remain; older-than-60d tokens force TokenRevokedError.
  • A 400 with error.code = 190 from any insights call surfaces as TokenRevokedError.

Workstream B — TikTok adapter

B1. Config additions

TIKTOK_CLIENT_KEY: z.string().min(1), TIKTOK_OAUTH_REDIRECT_URI: z.string().url(), TIKTOK_AUTH_ENDPOINT: z.string().url().default('https://business-api.tiktok.com/portal/auth'), TIKTOK_TOKEN_ENDPOINT: z.string().url().default('https://business-api.tiktok.com/open_api/v1.3/oauth2/access_token/'), TIKTOK_REFRESH_ENDPOINT: z.string().url().default('https://business-api.tiktok.com/open_api/v1.3/oauth2/refresh_token/'), TIKTOK_API_VERSION: z.string().default('v1.3'),

TIKTOK_APP_SECRET lands in REQUIRED_SECRETS.

B2. OAuth — standard refresh-token flow

TikTok’s Marketing API uses a normal OAuth 2.0 flow with access_token + refresh_token and JSON response bodies (not query strings). No PKCE.

File: src/adapters/tiktok/auth.ts

const REFRESH_SKEW_SECONDS = 600; // 10 min export async function exchangeAuthCode(tenantId: string, code: string, _codeVerifier: string): Promise<void> { const appSecret = (await loadSecret('TIKTOK_APP_SECRET' as never)).toString('utf8'); const res = await fetch(config.TIKTOK_TOKEN_ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ app_id: config.TIKTOK_CLIENT_KEY, secret: appSecret, auth_code: code, grant_type: 'authorization_code', }), }); if (!res.ok) throw new Error(`tiktok_token_failed: ${res.status}`); const env = (await res.json()) as { code: number; message: string; data: { access_token: string; refresh_token: string; access_token_expire_in: number; refresh_token_expire_in: number; scope: string[]; } }; if (env.code !== 0) throw new Error(`tiktok_token_failed: ${env.message}`); const accessEnc = await encryptToken(tenantId, env.data.access_token); const refreshEnc = await encryptToken(tenantId, env.data.refresh_token); await db.insert(platformCredentials).values({ tenantId, platform: 'tiktok', accountId: '', accessTokenEnc: accessEnc, refreshTokenEnc: refreshEnc, tokenExpiresAt: new Date(Date.now() + env.data.access_token_expire_in * 1000), scopes: env.data.scope, }).onConflictDoUpdate({ target: [platformCredentials.tenantId, platformCredentials.platform], set: { accessTokenEnc: accessEnc, refreshTokenEnc: refreshEnc, tokenExpiresAt: new Date(Date.now() + env.data.access_token_expire_in * 1000), scopes: env.data.scope, updatedAt: new Date() }, }); } export async function ensureValidToken(tenantId: string): Promise<string> { const [row] = await db.select().from(platformCredentials) .where(and(eq(platformCredentials.tenantId, tenantId), eq(platformCredentials.platform, 'tiktok'))); if (!row) throw new Error('no_tiktok_credentials'); if ((row.tokenExpiresAt!.getTime() - Date.now()) / 1000 > REFRESH_SKEW_SECONDS) { return decryptToken(tenantId, row.accessTokenEnc); } const refresh = await decryptToken(tenantId, row.refreshTokenEnc!); const appSecret = (await loadSecret('TIKTOK_APP_SECRET' as never)).toString('utf8'); const res = await fetch(config.TIKTOK_REFRESH_ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ app_id: config.TIKTOK_CLIENT_KEY, secret: appSecret, refresh_token: refresh, grant_type: 'refresh_token', }), }); const env = (await res.json()) as { code: number; message: string; data?: { access_token: string; refresh_token: string; access_token_expire_in: number } }; // TikTok returns code 40105 for revoked / invalid refresh tokens. if (env.code === 40105 || env.code === 40104) { await writeAuditEvent('oauth.token_refreshed', 'failure', { tenantId, platform: 'tiktok', code: env.code }); throw new TokenRevokedError('tiktok'); } if (env.code !== 0 || !env.data) throw new Error(`tiktok_refresh_failed: ${env.message}`); const accessEnc = await encryptToken(tenantId, env.data.access_token); const refreshEnc = await encryptToken(tenantId, env.data.refresh_token); await db.update(platformCredentials) .set({ accessTokenEnc: accessEnc, refreshTokenEnc: refreshEnc, tokenExpiresAt: new Date(Date.now() + env.data.access_token_expire_in * 1000), updatedAt: new Date() }) .where(and(eq(platformCredentials.tenantId, tenantId), eq(platformCredentials.platform, 'tiktok'))); await writeAuditEvent('oauth.token_refreshed', 'success', { tenantId, platform: 'tiktok' }); return env.data.access_token; }

B3. Account listing

// src/adapters/tiktok/customers.ts export async function listAdvertisers(tenantId: string): Promise<{ id: string; name: string }[]> { const token = await ensureValidToken(tenantId); const appSecret = (await loadSecret('TIKTOK_APP_SECRET' as never)).toString('utf8'); const url = `https://business-api.tiktok.com/open_api/${config.TIKTOK_API_VERSION}/oauth2/advertiser/get/?app_id=${config.TIKTOK_CLIENT_KEY}&secret=${appSecret}`; const res = await fetch(url, { headers: { 'Access-Token': token } }); const env = (await res.json()) as { code: number; data: { list: { advertiser_id: string; advertiser_name: string }[] } }; if (env.code !== 0) throw new Error('tiktok_list_advertisers_failed'); return env.data.list.map(a => ({ id: a.advertiser_id, name: a.advertiser_name })); }

B4. Queries

POST to /open_api/v1.3/report/integrated/get/ with JSON body. Same TokenRevokedError mapping when env.code === 40105. Implement: queryAccountHealth, queryBudgetSplit, queryWeeklyAnomaly — TikTok’s matrix from architecture only includes those three.

B5. Adapter top-level + revoke

src/adapters/tiktok/index.ts mirrors the Google/Meta shape. TikTok doesn’t expose a public revoke endpoint, so revoke() only deletes the local row and logs an audit event noting the upstream is not contacted.

B6. Acceptance

  • Tokens persist envelope-encrypted; tokenExpiresAt matches access_token_expire_in.
  • Refresh near expiry rotates both tokens; refresh on revoked refresh token throws TokenRevokedError.
  • Pagination across report/integrated/get/ is handled.

Workstream C — Multi-account selection

C1. Schema change

platform_credentials already has an accountId column. Add a new “pending” state: when the OAuth callback finishes but the tenant hasn’t chosen an account, accountId is empty and the tools refuse to fetch.

Add a small migration adding a unique partial index so each (tenant, platform) pair has at most one credential row:

CREATE UNIQUE INDEX platform_credentials_one_per_tenant_platform ON platform_credentials (tenant_id, platform);

C2. List route

Lift the Phase 2 guard. Phase 2 §C2’s /auth/:platform/start handler hard-codes:

if (platform !== 'google') return reply.code(501).send({ error: 'unsupported_platform' });

Phase 3 removes this line outright. The handler now builds the redirect URL based on platform — Google uses config.GOOGLE_AUTH_ENDPOINT + PKCE, Meta uses config.META_AUTH_ENDPOINT (no PKCE — see §A2), TikTok uses config.TIKTOK_AUTH_ENDPOINT. A small buildAuthUrl(platform, state, codeChallenge) helper keeps the route handler clean. The state param remains the CSRF defence on every platform; code_challenge is only included when the platform supports it.

File: src/auth/oauth-routes.ts (extend Phase 2 file)

fastify.get('/auth/:platform/accounts', async (req, reply) => { const P = z.object({ platform: z.enum(['google', 'meta', 'tiktok']) }).parse(req.params); if (!req.tenantId) return reply.code(401).send(); const adapter = getAdapter(P.platform); const accounts = await adapter.listAccounts(req.tenantId); // new method on the interface return { platform: P.platform, accounts }; }); fastify.post('/auth/:platform/accounts/select', async (req, reply) => { const P = z.object({ platform: z.enum(['google', 'meta', 'tiktok']) }).parse(req.params); const B = z.object({ accountId: z.string().min(1) }).parse(req.body); if (!req.tenantId) return reply.code(401).send(); // Validate the choice belongs to the tenant. const adapter = getAdapter(P.platform); const accounts = await adapter.listAccounts(req.tenantId); if (!accounts.some(a => a.id === B.accountId)) return reply.code(400).send({ error: 'account_not_accessible' }); await db.update(platformCredentials) .set({ accountId: B.accountId, updatedAt: new Date() }) .where(and(eq(platformCredentials.tenantId, req.tenantId), eq(platformCredentials.platform, P.platform))); return { status: 'account_selected', accountId: B.accountId }; });

C3. Adapter interface change

Add to PlatformAdapter:

listAccounts(tenantId: string): Promise<{ id: string; name: string }[]>;

Each adapter wires this to the customers.ts / listAdAccounts / listAdvertisers function it already has.

C4. Tool guard

makeTool from Phase 2 §E1 must refuse to fetch when accountId === '':

// inside the helper, before readOrFetch: if (!await ensureAccountSelected(ctx.tenantId, input.platform)) { return { error: 'account_not_selected', platform: input.platform } as const; }

ensureAccountSelected reads platform_credentials.accountId — empty string → false. The tool returns a typed error instead of attempting a query.

C5. Acceptance

  • Brand-new OAuth: tools return account_not_selected until /auth/:platform/accounts/select is POSTed.
  • Selecting an accountId not in the listed set returns 400; not silent.
  • Switching account causes the next tool call to be a cache MISS for that tenant/platform/report (cache key still pivots on tenant + platform + report + dateRange — which means switching accounts shows stale data unless we add accountId to the key).

Cache-key correction: add accountId to the CacheKey interface (Phase 2 §A2) and run the migration below — Phase 3 owns the fix because Phase 2 didn’t yet have the data-driven path. The ADD COLUMN must run before the index recreate; otherwise the unique index references a column that doesn’t exist yet.

ALTER TABLE metric_cache ADD COLUMN account_id text NOT NULL DEFAULT ''; DROP INDEX metric_cache_key_idx; CREATE UNIQUE INDEX metric_cache_key_idx ON metric_cache (tenant_id, platform, account_id, report_type, date_range_key);

Workstream D — Tool platform-matrix widening

Each tool’s supportedPlatforms array changes per the architecture matrix. The actual fetcher logic dispatches on input.platform to the right adapter call.

ToolPhase 2Phase 3 adds
get_account_healthgooglemeta, tiktok
get_pmax_breakdowngoogle
get_quality_scoregoogle
get_search_term_wastegooglemeta
get_budget_optimizergooglemeta, tiktok
get_auction_insightsgooglemeta
get_weekly_anomalygooglemeta, tiktok

Two options for the per-tool dispatcher:

  1. Each tool’s fetcher switches on platform and calls the right adapter method.
  2. Adapter interface gains every tool’s method; tools just call adapter.fetchX.

Option 2 is what the Phase 2 PlatformAdapter interface already does. In Phase 3, only the supportedPlatforms array changes — the fetcher continues to call adapter.fetchAccountHealth(...) etc, and each adapter implements only the methods its supported tools require. For Meta-unsupported tools (fetchPmaxBreakdown, fetchKeywordQualityScores), the Meta adapter’s method throws NotImplementedError, which makeTool catches and converts to unsupported_platform (defence in depth — the supportedPlatforms gate should already prevent this).

File: src/adapters/errors.ts

export class NotImplementedError extends Error { constructor(public platform: string, public method: string) { super(`${method} not implemented for ${platform}`); } }

Update each tool’s supportedPlatforms per the matrix — that’s the only Phase 3 source change in src/mcp/tools/.

D1. Acceptance

  • For every (tool, platform) cell in the matrix: the tool returns real data.
  • For every cell NOT in the matrix: unsupported_platform, with no adapter call attempted.

Workstream E — Refresh edge-case typing

E1. Error taxonomy

File: src/adapters/errors.ts

export type AdapterErrorCode = | 'token_revoked' | 'scope_missing' | 'rate_limited' | 'account_not_accessible' | 'account_not_selected' | 'platform_unavailable'; export class AdapterError extends Error { constructor(public code: AdapterErrorCode, public platform: string, message?: string, public details?: unknown) { super(message ?? code); } } export class TokenRevokedError extends AdapterError { constructor(platform: string) { super('token_revoked', platform); } } export class ScopeMissingError extends AdapterError { constructor(platform: string, public missing: string[]) { super('scope_missing', platform, undefined, { missing }); } }

E2. Boundary conversion in makeTool

try { const { data, cache } = await readOrFetch(/* ... */); await writeAuditEvent('mcp.tool_called', 'success', { /* ... */ }); return { data, cache }; } catch (err) { if (err instanceof AdapterError) { await writeAuditEvent('mcp.tool_failed', 'failure', { tenantId: ctx.tenantId, tool: opts.name, code: err.code, platform: err.platform }); return { error: err.code, platform: err.platform, ...(err.details ? { details: err.details } : {}) }; } await writeAuditEvent('mcp.tool_failed', 'failure', { tenantId: ctx.tenantId, tool: opts.name, code: 'internal_error' }); throw err; // propagate to Fastify error handler → 500 }

E3. Refactor Google adapter

The Phase 2 Google code throws plain Error('google_token_refresh_failed: ...'). Update auth.ts to map:

  • HTTP 400 with error: 'invalid_grant'TokenRevokedError('google').
  • HTTP 401 → TokenRevokedError('google').
  • Missing scopes detected by tokeninfo lookup → ScopeMissingError('google', missing).
  • HTTP 429 → AdapterError('rate_limited', 'google').

E4. Acceptance

For each adapter, integration tests using msw (or a fetch-mock library):

  • Refresh response simulating a revoked token → tool returns { error: 'token_revoked', platform: 'X' } and writes an mcp.tool_failed audit row.
  • OAuth callback with insufficient scopes → returns { error: 'scope_missing', platform: 'X', details: { missing: [...] } }.
  • 429 from upstream → { error: 'rate_limited', platform: 'X' }.

Workstream F — Connection-status endpoint

F1. Route

File: src/mcp/connections.routes.ts

import type { FastifyPluginAsync } from 'fastify'; import { eq } from 'drizzle-orm'; import { db } from '../db/index.js'; import { platformCredentials, syncLog } from '../db/schema.js'; export const connectionsRoutes: FastifyPluginAsync = async (fastify) => { fastify.get('/tenant/connections', async (req, reply) => { if (!req.tenantId) return reply.code(401).send(); const rows = await db.select({ platform: platformCredentials.platform, accountId: platformCredentials.accountId, tokenExpiresAt: platformCredentials.tokenExpiresAt, scopes: platformCredentials.scopes, updatedAt: platformCredentials.updatedAt, }).from(platformCredentials).where(eq(platformCredentials.tenantId, req.tenantId)); return { tenantId: req.tenantId, connections: rows.map(r => ({ platform: r.platform, accountId: r.accountId || null, accountSelected: r.accountId !== '', tokenExpiresAt: r.tokenExpiresAt, scopes: r.scopes, lastUpdatedAt: r.updatedAt, })), }; }); };

F2. Mount

In src/index.ts, after the auth plugin: await app.register(connectionsRoutes);

F3. Acceptance

  • A tenant with no connections → { connections: [] }.
  • A tenant connected to Google + Meta → two entries, each with tokenExpiresAt and accountSelected: true|false.
  • The endpoint requires a valid X-Api-Key (covered by the Phase 1 auth plugin once we register the route on the auth-protected scope — make sure it’s not under /admin).

Workstream G — GDPR erasure endpoint

G1. Service

File: src/security/erasure.service.ts

import { eq, sql } from 'drizzle-orm'; import { db } from '../db/index.js'; import { tenants, platformCredentials, metricCache, syncLog, auditLog, apiKeys } from '../db/schema.js'; import { destroyDek } from './credentials.service.js'; import { getAdapter } from '../adapters/registry.js'; import { writeAuditEvent } from './audit-log.service.js'; export async function deleteTenant(tenantId: string): Promise<void> { // Best-effort upstream revocation BEFORE wiping local state — we need the tokens to revoke. const creds = await db.select().from(platformCredentials).where(eq(platformCredentials.tenantId, tenantId)); for (const c of creds) { try { await getAdapter(c.platform).revoke(tenantId); } catch { /* upstream may already be revoked; we still wipe locally */ } } await db.transaction(async (tx) => { await tx.delete(metricCache).where(eq(metricCache.tenantId, tenantId)); await tx.delete(platformCredentials).where(eq(platformCredentials.tenantId, tenantId)); await tx.delete(syncLog).where(eq(syncLog.tenantId, tenantId)); await tx.delete(apiKeys).where(eq(apiKeys.tenantId, tenantId)); // DEK destroyed AFTER credentials wiped — order matters for crash safety. await destroyDek(tenantId); // Audit log: anonymise rather than delete (architecture §9, GDPR vs. SOC 2 trade-off). await tx.update(auditLog) .set({ tenantId: null, metadata: sql`COALESCE(${auditLog.metadata}, '{}'::jsonb) - 'account_id' - 'accountId'` }) .where(eq(auditLog.tenantId, tenantId)); await tx.delete(tenants).where(eq(tenants.id, tenantId)); }); await writeAuditEvent('tenant.deleted', 'success', { tenantId }); // tenantId now refers to a deleted tenant; row remains as anonymised audit }

G2. Admin route

Add to src/auth/admin-routes.ts:

fastify.delete('/admin/tenants/:tenantId', async (req, reply) => { if (req.headers['x-admin-token'] !== adminToken) return reply.code(401).send(); const P = z.object({ tenantId: z.string().uuid() }).parse(req.params); await deleteTenant(P.tenantId); return reply.code(204).send(); });

G3. Acceptance

File: tests/integration/erasure.test.ts

  • Seed a tenant with API key, two platform credentials, cache rows, sync rows, audit rows.
  • DELETE /admin/tenants/:id → 204.
  • Post-state: tenants row gone; metric_cache, platform_credentials, sync_log, api_keys rows for that tenant gone; tenant_deks gone; audit_log rows for that tenant have tenant_id IS NULL and metadata no longer contains the listed PII keys.
  • A subsequent attempt to authenticate with the deleted tenant’s API key returns 401.
  • Test that a partial failure (simulate a failure mid-transaction) leaves the tenant intact — no half-deleted state.

Workstream H — Tests

H1. New test files

FileCovers
tests/integration/meta-adapter.test.tsA1–A6
tests/integration/tiktok-adapter.test.tsB1–B6
tests/integration/account-selection.test.tsC
tests/integration/tools-meta-tiktok.test.tsD — every cell in the matrix
tests/integration/adapter-errors.test.tsE — typed error mapping
tests/integration/connections.test.tsF
tests/integration/erasure.test.tsG

H2. Mock boundary

Same as Phase 2: mock fetch at the adapter boundary; everything below runs against real Postgres + real envelope encryption. tests/_mocks/meta.ts and tests/_mocks/tiktok.ts provide:

  • mockShortLivedExchange() / mockLongLivedExchange() for Meta.
  • mockTokenExchange() / mockTokenRefresh() for TikTok.
  • mockListAccounts() / mockListAdvertisers().
  • mockInsights(reportType, rows) and mockTikTokReport(reportType, rows).
  • mockTokenRevoked() for both — produces the right error envelope.

H3. CI extension

Add to scripts/dev-secrets.sh:

[[ -f secrets/META_APP_SECRET ]] || echo -n 'meta_test_secret' > secrets/META_APP_SECRET [[ -f secrets/TIKTOK_APP_SECRET ]] || echo -n 'tiktok_test_secret' > secrets/TIKTOK_APP_SECRET

CI workflow already runs migrations; the new index migrations land automatically.


§11 — Definition of Done (full checklist)

A. Meta adapter

  • META_GRAPH_VERSION pinned in config; META_APP_SECRET in REQUIRED_SECRETS.
  • OAuth callback exchanges short→long-lived; scopes verified via debug_token.
  • ensureValidToken re-exchanges <7d before expiry.
  • Revoked tokens surface as TokenRevokedError.

B. TikTok adapter

  • TIKTOK_API_VERSION pinned; TIKTOK_APP_SECRET in REQUIRED_SECRETS.
  • Standard refresh-token flow with 10-minute skew.
  • code 40104/40105 → TokenRevokedError.
  • No upstream revoke (documented) — local wipe only.

C. Multi-account selection

  • GET /auth/:platform/accounts returns the tenant-accessible list.
  • POST /auth/:platform/accounts/select validates against the listed set.
  • Cache key gains accountId; index migration applied.
  • Tools return account_not_selected until a choice is made.

D. Tool matrix widening

  • All 14 supported (tool, platform) cells return data.
  • All 7 unsupported cells return unsupported_platform.

E. Refresh edge-case typing

  • AdapterError taxonomy in place; all three adapters throw typed errors.
  • makeTool boundary converts to { error, platform } envelopes and writes mcp.tool_failed audit rows.
  • Google adapter retrofitted to the same taxonomy.

F. Connection-status endpoint

  • GET /tenant/connections returns per-platform state for the calling tenant.
  • Auth-protected; never returns another tenant’s data.

G. GDPR erasure

  • DELETE /admin/tenants/:id cascades all tenant data, destroys DEK, anonymises audit rows.
  • Best-effort upstream revoke runs first; failure doesn’t block local wipe.
  • Mid-transaction failure leaves tenant intact.

H. Tests

  • All seven new test files pass in CI.
  • npm run audit still green; new node-fetch / msw deps reviewed.

§12 — Out of scope (deferred)

ItemPhase
Inngest sync functions, signed-webhook verification, eager refresh cron4
KEK rotation procedure5
Real Prometheus / OTel exporter5
nginx OAuth callback IP allow-list5
Penetration test of /auth/*, /admin/*, /tenant/connections5
Multi-region data residency for tenants5

§13 — Manual smoke test

# 0. Phase 2 already up: tenants seeded, Google connected, server running. # 1. Apply Phase 3 migrations (account_id on metric_cache, unique indexes, etc.) npm run db:migrate # 2. Connect Meta curl -i -H "X-Api-Key: $KEY" http://127.0.0.1:3001/auth/meta/start # → 302 to Facebook ... complete consent in browser ... # 3. List Meta accounts and pick one curl -H "X-Api-Key: $KEY" http://127.0.0.1:3001/auth/meta/accounts # → {"accounts":[{"id":"act_123...","name":"Brand A"}, ...]} curl -X POST -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \ -d '{"accountId":"act_123..."}' http://127.0.0.1:3001/auth/meta/accounts/select # → {"status":"account_selected","accountId":"act_123..."} # 4. Connect TikTok (same flow) curl -i -H "X-Api-Key: $KEY" http://127.0.0.1:3001/auth/tiktok/start # ... (browser) ... curl -H "X-Api-Key: $KEY" http://127.0.0.1:3001/auth/tiktok/accounts curl -X POST -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \ -d '{"accountId":"7000..."}' http://127.0.0.1:3001/auth/tiktok/accounts/select # 5. Check tenant connection state curl -H "X-Api-Key: $KEY" http://127.0.0.1:3001/tenant/connections # → {connections:[{platform:"google",...},{platform:"meta",...},{platform:"tiktok",...}]} # 6. Run get_account_health on all three platforms for P in google meta tiktok; do curl -X POST http://127.0.0.1:3001/mcp -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \ -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"get_account_health\",\"arguments\":{\"platform\":\"$P\",\"dateRange\":\"last_7_days\"}}}" done # → three responses, all "cache":"miss" first time, "cache":"hit" if you re-run # 7. Run a not-supported combination — get_quality_score on Meta curl -X POST http://127.0.0.1:3001/mcp -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_quality_score","arguments":{"platform":"meta","dateRange":"last_7_days"}}}' # → {"error":"unsupported_platform","platform":"meta"} # 8. Simulate a revoked token: in Meta's UI, revoke the app's access for the tenant's user. # Wait until the long-lived token would refresh, or force-clear tokenExpiresAt: psql "..." -c "UPDATE platform_credentials SET token_expires_at = now() - interval '1 day' WHERE platform='meta';" curl -X POST http://127.0.0.1:3001/mcp -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_account_health","arguments":{"platform":"meta","dateRange":"last_7_days"}}}' # → {"error":"token_revoked","platform":"meta"} + audit row mcp.tool_failed code:token_revoked # 9. GDPR erasure curl -i -X DELETE -H "x-admin-token: $ADMIN_TOKEN" http://127.0.0.1:3001/admin/tenants/$TENANT_ID # → HTTP/1.1 204 No Content psql "..." -c "SELECT count(*) FROM tenants WHERE id='$TENANT_ID';" # → 0 psql "..." -c "SELECT count(*) FROM platform_credentials WHERE tenant_id='$TENANT_ID';" # → 0 psql "..." -c "SELECT count(*) FROM tenant_deks WHERE tenant_id='$TENANT_ID';" # → 0 psql "..." -c "SELECT count(*) FROM audit_log WHERE tenant_id='$TENANT_ID';" # → 0 psql "..." -c "SELECT count(*) FROM audit_log WHERE tenant_id IS NULL;" # → many (anonymised rows) # 10. Confirm the deleted tenant's API key no longer authenticates curl -X POST http://127.0.0.1:3001/mcp -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ping","arguments":{}}}' # → 401

If every step produces the expected outcome, Phase 3 is shipped. Move on to Phase 4 (Inngest background sync).