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
metaandtiktokend-to-end. Tokens land envelope-encrypted;tokenExpiresAtreflects 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_platformcleanly. - When a refresh fails because the user revoked access upstream, the response is a typed
token_revokederror and the audit log records it; same for missing scopes (scope_missing). -
GET /tenant/connectionsreturns connection state for the calling tenant. -
DELETE /admin/tenants/:idcascades throughplatform_credentials,metric_cache,sync_log, destroys the DEK, anonymisesaudit_logrows, and deletes thetenantsrow — 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 everythingA 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_statesrow still carries acodeVerifier, but the Meta callback ignores it. Thestateparam 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
exchangeAuthCodewalks short→long flow, populatesplatform_credentialswith a 60-day expiry, and verifies scopes (missing →ScopeMissingError).ensureValidTokenre-exchanges when <7 days remain; older-than-60d tokens forceTokenRevokedError.- A 400 with
error.code = 190from any insights call surfaces asTokenRevokedError.
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;
tokenExpiresAtmatchesaccess_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/starthandler 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 usesconfig.GOOGLE_AUTH_ENDPOINT+ PKCE, Meta usesconfig.META_AUTH_ENDPOINT(no PKCE — see §A2), TikTok usesconfig.TIKTOK_AUTH_ENDPOINT. A smallbuildAuthUrl(platform, state, codeChallenge)helper keeps the route handler clean. Thestateparam remains the CSRF defence on every platform;code_challengeis 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_selecteduntil/auth/:platform/accounts/selectis POSTed. - Selecting an
accountIdnot 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
accountIdto theCacheKeyinterface (Phase 2 §A2) and run the migration below — Phase 3 owns the fix because Phase 2 didn’t yet have the data-driven path. TheADD COLUMNmust 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.
| Tool | Phase 2 | Phase 3 adds |
|---|---|---|
get_account_health | meta, tiktok | |
get_pmax_breakdown | — | |
get_quality_score | — | |
get_search_term_waste | meta | |
get_budget_optimizer | meta, tiktok | |
get_auction_insights | meta | |
get_weekly_anomaly | meta, tiktok |
Two options for the per-tool dispatcher:
- Each tool’s
fetcherswitches on platform and calls the right adapter method. - 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
tokeninfolookup →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 anmcp.tool_failedaudit 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
tokenExpiresAtandaccountSelected: 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:
tenantsrow gone;metric_cache,platform_credentials,sync_log,api_keysrows for that tenant gone;tenant_deksgone;audit_logrows for that tenant havetenant_id IS NULLandmetadatano 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
| File | Covers |
|---|---|
tests/integration/meta-adapter.test.ts | A1–A6 |
tests/integration/tiktok-adapter.test.ts | B1–B6 |
tests/integration/account-selection.test.ts | C |
tests/integration/tools-meta-tiktok.test.ts | D — every cell in the matrix |
tests/integration/adapter-errors.test.ts | E — typed error mapping |
tests/integration/connections.test.ts | F |
tests/integration/erasure.test.ts | G |
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)andmockTikTokReport(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_SECRETCI workflow already runs migrations; the new index migrations land automatically.
§11 — Definition of Done (full checklist)
A. Meta adapter
-
META_GRAPH_VERSIONpinned in config;META_APP_SECRETinREQUIRED_SECRETS. - OAuth callback exchanges short→long-lived; scopes verified via
debug_token. -
ensureValidTokenre-exchanges <7d before expiry. - Revoked tokens surface as
TokenRevokedError.
B. TikTok adapter
-
TIKTOK_API_VERSIONpinned;TIKTOK_APP_SECRETinREQUIRED_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/accountsreturns the tenant-accessible list. -
POST /auth/:platform/accounts/selectvalidates against the listed set. - Cache key gains
accountId; index migration applied. - Tools return
account_not_selecteduntil 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
-
AdapterErrortaxonomy in place; all three adapters throw typed errors. -
makeToolboundary converts to{ error, platform }envelopes and writesmcp.tool_failedaudit rows. - Google adapter retrofitted to the same taxonomy.
F. Connection-status endpoint
-
GET /tenant/connectionsreturns per-platform state for the calling tenant. - Auth-protected; never returns another tenant’s data.
G. GDPR erasure
-
DELETE /admin/tenants/:idcascades 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 auditstill green; newnode-fetch/mswdeps reviewed.
§12 — Out of scope (deferred)
| Item | Phase |
|---|---|
| Inngest sync functions, signed-webhook verification, eager refresh cron | 4 |
| KEK rotation procedure | 5 |
| Real Prometheus / OTel exporter | 5 |
| nginx OAuth callback IP allow-list | 5 |
Penetration test of /auth/*, /admin/*, /tenant/connections | 5 |
| Multi-region data residency for tenants | 5 |
§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":{}}}'
# → 401If every step produces the expected outcome, Phase 3 is shipped. Move on to Phase 4 (Inngest background sync).