feat(whatsapp-inbox): GDPR retention cron and RoPA entry (#1341)

PR5b, the final code piece of the WhatsApp intake track. A daily cron
(04:15) enforces the channel's retention table; the receipt itself stays
7-year WORM under BFL and is never touched.

Retention actions (lib/retention.ts, each isolated and idempotent):
- whatsapp_messages transcripts past 90 days: body_text + raw_payload
  cleared in id batches under a wall-clock budget; the row skeleton
  (wamid, direction, timestamps, status, inbox_item_id) survives for
  audit. Only rows still carrying content match.
- Rows with phone_link_id IS NULL (unknown senders, orphans) past
  30 days: deleted.
- Link codes expired more than 24h ago: deleted, used or not.
- Sender rate counters idle 2+ days: deleted (minute/day window keys
  are dead weight after that).
- Links revoked 90+ days ago: phone_enc crypto-shredded to '' (column
  is NOT NULL), one-shot via neq guard; phone_hash and phone_masked
  kept for uniqueness history and audit display.

Route mirrors the sweep cron exactly: withCronContext + registry gate
(503 EXTENSION_DISABLED when the extension is off). vercel.json gets
the schedule and both Docker crontabs are regenerated.

Compliance: new whatsapp.receipt_intake activity in .compliance/ropa.yaml
covering purpose, Art 6(1)(b)/(c)/(f) bases with the Art 14(5)(b) note
for third-party attendee names, Meta Platforms Ireland as processor
(Cloud API, EU SCC addendum, Local Storage region DE), the differentiated
retention table, and security measures.

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-05 15:35:41 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 629069e281
commit bf5ca2c615
9 changed files with 619 additions and 0 deletions
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
vi.mock('@/lib/extensions/loader', () => ({
loadExtensions: vi.fn(),
}))
vi.mock('@/lib/extensions/registry', () => ({
extensionRegistry: {
get: vi.fn(),
},
}))
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: vi.fn().mockReturnValue({}),
}))
vi.mock('@/extensions/general/whatsapp-inbox/lib/retention', () => ({
runRetention: vi.fn(),
}))
vi.mock('@/lib/auth/cron', () => ({
verifyCronSecret: vi.fn().mockReturnValue(null),
}))
import { GET } from '../route'
import { extensionRegistry } from '@/lib/extensions/registry'
import { loadExtensions } from '@/lib/extensions/loader'
import { runRetention } from '@/extensions/general/whatsapp-inbox/lib/retention'
import { verifyCronSecret } from '@/lib/auth/cron'
const mockRegistryGet = vi.mocked(extensionRegistry.get)
const mockVerifyCronSecret = vi.mocked(verifyCronSecret)
const mockRunRetention = vi.mocked(runRetention)
function makeRequest() {
return new Request('http://localhost/api/extensions/whatsapp-inbox/retention/cron', {
headers: { authorization: 'Bearer synthetic-cron-secret' },
})
}
beforeEach(() => {
vi.clearAllMocks()
mockVerifyCronSecret.mockReturnValue(null)
})
describe('GET /api/extensions/whatsapp-inbox/retention/cron', () => {
it('returns 401 when the cron secret is rejected', async () => {
mockVerifyCronSecret.mockReturnValue(
NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
)
const response = await GET(makeRequest())
expect(response.status).toBe(401)
expect(mockRunRetention).not.toHaveBeenCalled()
})
it('returns 503 EXTENSION_DISABLED when the extension is not in the registry', async () => {
// Physical extension routes deploy in every build; the registry, generated
// from extensions.config.json, is what turns them on. Disabled must mean
// no purging AND a visible failure if the cron is scheduled anyway.
mockRegistryGet.mockReturnValue(undefined)
const response = await GET(makeRequest())
const body = await response.json()
expect(response.status).toBe(503)
expect(body.code).toBe('EXTENSION_DISABLED')
expect(mockRunRetention).not.toHaveBeenCalled()
})
it('runs the retention pass and returns its summary when enabled', async () => {
mockRegistryGet.mockReturnValue({ id: 'whatsapp-inbox' } as never)
mockRunRetention.mockResolvedValue({
purgedTranscripts: 12,
deletedUnknownSenderMessages: 4,
deletedLinkCodes: 2,
deletedRateCounters: 9,
shreddedRevokedLinks: 1,
})
const response = await GET(makeRequest())
const body = await response.json()
expect(loadExtensions).toHaveBeenCalled()
expect(mockRegistryGet).toHaveBeenCalledWith('whatsapp-inbox')
expect(response.status).toBe(200)
expect(body.data).toEqual({
purgedTranscripts: 12,
deletedUnknownSenderMessages: 4,
deletedLinkCodes: 2,
deletedRateCounters: 9,
shreddedRevokedLinks: 1,
})
})
})
@@ -0,0 +1,48 @@
import { NextResponse } from 'next/server'
import { loadExtensions } from '@/lib/extensions/loader'
import { extensionRegistry } from '@/lib/extensions/registry'
import { withCronContext } from '@/lib/api/with-cron-context'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { runRetention } from '@/extensions/general/whatsapp-inbox/lib/retention'
/**
* GET /api/extensions/whatsapp-inbox/retention/cron: daily GDPR purge for
* the WhatsApp channel. Enforces the retention table in .compliance/ropa.yaml
* (whatsapp.receipt_intake): 90-day chat transcripts, 30-day unknown-sender
* rows, expired link codes, dead rate counters, and the 90-day crypto-shred
* of revoked phone bindings. Receipts themselves are 7-year WORM under BFL
* and are never touched here. Scheduled in vercel.json (and the generated
* Docker crontabs).
*
* Overlap with a slow previous run is safe: every predicate only matches
* rows still carrying the data, so a second pass is a no-op.
*/
// The transcript purge loops over a possibly large first-run backlog.
export const maxDuration = 300
export const GET = withCronContext('cron.whatsapp_retention', async (_request, ctx) => {
// Load the registry so it reflects extensions.config.json.
loadExtensions()
// Physical routes under app/api/extensions/<id>/ compile into EVERY build,
// including the core-with-zero-extensions one: the registry (generated from
// extensions.config.json) is what actually switches an extension on. Mirror
// the ext/[...path] dispatcher: a disabled extension must not expose a live
// surface, and a scheduled-but-disabled cron must fail visibly (503)
// instead of quietly doing the work anyway.
if (!extensionRegistry.get('whatsapp-inbox')) {
ctx.log.warn('whatsapp-inbox extension is not enabled; cron refused')
return NextResponse.json(
{ error: 'WhatsApp inbox extension is not enabled', code: 'EXTENSION_DISABLED' },
{ status: 503 },
)
}
const supabase = createServiceClientNoCookies()
const summary = await runRetention(supabase)
ctx.log.info('whatsapp retention complete', { ...summary })
return NextResponse.json({ data: summary })
})