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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
629069e281
commit
bf5ca2c615
@@ -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 })
|
||||
})
|
||||
Reference in New Issue
Block a user