Skip to content

Commit 12ca917

Browse files
feat(webhooks): expose delivery diagnostics
1 parent 996d96f commit 12ca917

22 files changed

Lines changed: 1029 additions & 6 deletions

TODO.md

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@
496496
- [x] Modelar endpoint, subscription, secret, filter e delivery attempt. Evidência F0-034: domínios canônicos, registro transacional, cinco tabelas, constraints e regressões de segurança.
497497
- [x] Implementar challenge, assinatura, timestamp e anti-replay. Evidência F0-035/F0-036: challenge durável one-shot, HMAC dos bytes exatos, janela de timestamp, receipt anti-replay e transporte HTTPS pinado com resolução DNS fail-closed.
498498
- [ ] Implementar at-least-once, backoff, dead-letter e replay controlado. Parcial F0-031/F0-041: render possui lease/fencing, backoff, checkpoint, dead-letter e retry manual; webhooks possuem fan-out, claim/lease, dispatch assinado, transporte DNS-pinado, heartbeat orquestrado, discovery paginada, sharding determinístico, backoff e dead-letter. Adapter concreto do secret provider, coordenação de rebalanceamento e replay administrativo continuam abertos.
499-
- [ ] Criar UI/API administrativa de status, attempts e rotação de secret.
499+
- [ ] Criar UI/API administrativa de status, attempts e rotação de secret. Parcial F0-042: API externa lista deliveries com cursor/filtros e lê histórico redigido de attempts; UI, endpoints/subscriptions e rotação de secret continuam abertos.
500500
- [ ] Criar integration tests de duplicação, timeout, assinatura inválida e replay. Parcial F0-035/F0-039: assinatura adulterada, replay durável, timeout absoluto, DNS misto, rebinding, claim concorrente, lease incorreto, reclaim, worker obsoleto, retry e dead-letter estão cobertos. Dispatcher cobre bytes/headers assinados, fingerprint, DNS privado e settlement integrado; falta replay administrativo ponta a ponta.
501501

502502
### F0.039 — Idempotência e concorrência externa [FR-245]
@@ -539,7 +539,7 @@
539539
### F0.043 — Governança da API [FR-249]
540540

541541
- [ ] Criar administração de clients, scopes, secrets, environments e status.
542-
- [ ] Criar administração de webhooks, subscriptions e delivery diagnostics.
542+
- [ ] Criar administração de webhooks, subscriptions e delivery diagnostics. Parcial F0-042: capabilities `apollo.webhooks.deliveries.list/read` entregam diagnóstico workspace-scoped; administração de endpoints/subscriptions, mutações e UI continuam abertas.
543543
- [ ] Implementar rate limits, quotas, concurrency e spend budgets por client/workspace.
544544
- [ ] Criar usage e audit queries paginadas com redaction.
545545
- [ ] Criar sandbox isolado com provider fakes e custos simulados.
@@ -3697,7 +3697,7 @@ Limites explícitos desta slice:
36973697

36983698
### Slice F0-041 — Discovery paginada e sharding do worker de webhooks
36993699

3700-
**Status:** concluído localmente em 14 de julho de 2026; ainda não commitado.
3700+
**Status:** publicado em 14 de julho de 2026 no commit `03b9d4b`, com correção de compatibilidade PostgreSQL no commit `996d96f`.
37013701

37023702
Entregas:
37033703

@@ -3728,4 +3728,41 @@ Limites explícitos desta slice:
37283728
- o adapter concreto do secret provider e o entrypoint de produção continuam pendentes;
37293729
- mudanças concorrentes de status não formam snapshot transacional; claim e fencing permanecem a autoridade contra execução duplicada;
37303730
- replay administrativo, rotação operacional, rate limit/circuit breaker e observabilidade continuam abertos;
3731-
- hosted CI será registrado após publicação no próximo ciclo.
3731+
- o primeiro hosted CI (`29380970328`) detectou que uma fixture alterava `createdAt` além de `nextAttemptAt`, violando corretamente o constraint PostgreSQL; a fixture inválida foi removida em `996d96f` sem alterar código de produção;
3732+
- hosted CI corrigido `29381478867` aprovou PostgreSQL, 91 testes, contratos, API, FFmpeg, Remotion real, build e auditorias.
3733+
3734+
### Slice F0-042 — API administrativa de webhook delivery diagnostics
3735+
3736+
**Status:** concluído localmente em 14 de julho de 2026; ainda não commitado.
3737+
3738+
Entregas:
3739+
3740+
- capability `apollo.webhooks.deliveries.list` expõe listagem workspace-scoped com scope `webhooks:admin`;
3741+
- filtros allowlisted cobrem status, endpoint e evento, além de limite e cursor opaco;
3742+
- cursor v1 vincula workspace e todos os filtros por SHA-256, impedindo reaproveitamento ambíguo;
3743+
- capability `apollo.webhooks.deliveries.read` expõe uma delivery e seus attempts em ordem crescente;
3744+
- leitura cross-workspace é indistinguível de inexistente;
3745+
- presenters excluem workspace, URL, payload, assinatura, headers, lease, heartbeat, secret e corpo de resposta;
3746+
- diagnóstico mantém apenas status HTTP, hash do body e error code redigido para correlação;
3747+
- JSON Schemas, exemplos, capability discovery, OpenAPI e duas rotas Next usam o mesmo contrato versionado;
3748+
- três índices compostos sustentam listagem geral, filtro por evento e percurso por subscription;
3749+
- ADR-031 formaliza escopo administrativo, paginação, redaction e fronteira com replay futuro.
3750+
3751+
Regressões e evidências locais:
3752+
3753+
- suíte global passa com 93 testes; 25 são contratos de webhook;
3754+
- contratos cobrem paginação, cursor vinculado a filtros, leitura workspace-scoped e attempts ordenados;
3755+
- integração Prisma cobre status+endpoint+evento, cross-workspace e histórico dead-letter;
3756+
- jornada HTTP autenticada cobre discovery/OpenAPI, list/read, resposta redigida, filtro inválido e 403 sem scope;
3757+
- contratos públicos passam com 29 capabilities, 35 schemas, 43 exemplos e 26 paths;
3758+
- build registra `/v1/webhooks/deliveries` e `/v1/webhooks/deliveries/{deliveryId}`.
3759+
- migration passa com 25 tabelas, 95 índices e 55 chaves estrangeiras;
3760+
- auditorias sem vulnerabilidades, FFmpeg, todas as integrações SQLite, bundle e render real do Remotion passam.
3761+
3762+
Limites explícitos desta slice:
3763+
3764+
- não há mutação administrativa, replay, rotação de secret ou administração de endpoint/subscription;
3765+
- UI administrativa ainda não foi iniciada;
3766+
- retenção e purge de attempts permanecem para política operacional posterior;
3767+
- rate limit, quotas, circuit breaker e métricas continuam abertos;
3768+
- hosted CI desta slice será registrado após publicação no próximo ciclo.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# ADR-031 — Diagnóstico público e redigido de webhook deliveries
2+
3+
> **Status:** Accepted
4+
>
5+
> **Data:** 14 de julho de 2026
6+
7+
## Contexto
8+
9+
O worker já mantém estado, tentativas e resultados técnicos de deliveries, mas operadores externos não conseguem localizar falhas ou distinguir retry agendado de dead-letter sem acesso direto ao banco. Expor a linha bruta seria inseguro: ela contém campos de lease e relações que podem levar a endpoint, payload e credenciais.
10+
11+
## Decisão
12+
13+
- A administração externa começa com duas capabilities read-only: listar deliveries e ler uma delivery com seu histórico de attempts.
14+
- Ambas exigem autenticação e o scope `webhooks:admin`; a exposição é `workspace-admin` e toda consulta recebe o workspace do actor autenticado.
15+
- `GET /v1/webhooks/deliveries` aceita somente `limit`, `after`, `status`, `endpointId` e `eventId`. Parâmetros desconhecidos ou repetidos falham fechado.
16+
- A lista é ordenada por `createdAt DESC, id DESC`, limitada a 100 itens e usa cursor opaco vinculado por SHA-256 ao workspace e a todos os filtros.
17+
- Reutilizar cursor com outro workspace, status, endpoint ou evento é inválido.
18+
- `GET /v1/webhooks/deliveries/{deliveryId}` devolve a delivery e até 20 attempts ordenados por número crescente.
19+
- O contrato público inclui IDs, status, contadores, agenda, timestamps, status HTTP, hash da resposta e código de erro redigido.
20+
- O contrato exclui `workspaceId`, URL, secret/key reference, payload/event data, assinatura, headers, lease owner/token/hash, heartbeat e corpo de resposta.
21+
- Uma delivery de outro workspace é indistinguível de inexistente e retorna 404.
22+
- As capabilities, schemas, exemplos e rotas OpenAPI são a fonte para API, agentes e futuro adapter MCP; não haverá boundary administrativo paralelo.
23+
- Índices específicos cobrem listagem geral, filtro por evento e percurso por subscription. O filtro por endpoint usa a relação endpoint→subscriptions sem duplicar endpoint na delivery.
24+
25+
## Consequências
26+
27+
- Operadores e agentes conseguem localizar e diagnosticar falhas sem acesso interno.
28+
- A API fornece a identidade necessária para o futuro replay controlado, mas esta decisão não autoriza replay, rotação ou alteração de subscription.
29+
- Status HTTP e hashes ajudam correlação sem armazenar ou revelar o corpo recebido.
30+
- A UI administrativa futura deverá consumir estas mesmas capabilities.
31+
32+
## Evidências exigidas
33+
34+
- cursor de uma combinação de filtros é rejeitado em outra;
35+
- filtro por status, endpoint e evento retorna somente deliveries do workspace autenticado;
36+
- leitura cross-workspace retorna 404;
37+
- attempts permanecem ordenados e não expõem IDs internos redundantes ou lease;
38+
- OpenAPI e capability discovery publicam as duas operações somente para `webhooks:admin`;
39+
- cliente sem scope recebe 403;
40+
- resposta HTTP não contém URL, payload, workspace, assinatura ou segredo;
41+
- integração Prisma e jornada HTTP autenticada executam os contratos reais.

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,4 @@ Estados usados:
3232
- [ADR-028 — Dispatch assinado de deliveries de webhook](./ADR-028-signed-webhook-delivery-dispatch.md)
3333
- [ADR-029 — Orquestração do worker de webhook deliveries](./ADR-029-webhook-delivery-worker-orchestration.md)
3434
- [ADR-030 — Descoberta e sharding de workspaces com webhook executável](./ADR-030-webhook-workspace-discovery-and-sharding.md)
35+
- [ADR-031 — Diagnóstico público e redigido de webhook deliveries](./ADR-031-public-webhook-delivery-diagnostics.md)

prisma/schema.prisma

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,9 @@ model V2WebhookDelivery {
659659
@@index([workspaceId, status, createdAt])
660660
@@index([workspaceId, status, nextAttemptAt, id])
661661
@@index([workspaceId, status, leaseExpiresAt, id])
662+
@@index([workspaceId, createdAt, id])
663+
@@index([workspaceId, eventId, createdAt, id])
664+
@@index([workspaceId, subscriptionId, createdAt, id])
662665
@@map("v2_webhook_deliveries")
663666
}
664667

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
CREATE INDEX "webhook_deliveries_workspaceId_createdAt_id_idx"
2+
ON "webhook_deliveries"("workspaceId", "createdAt" DESC, "id" DESC);
3+
4+
CREATE INDEX "webhook_deliveries_workspaceId_eventId_createdAt_id_idx"
5+
ON "webhook_deliveries"("workspaceId", "eventId", "createdAt" DESC, "id" DESC);
6+
7+
CREATE INDEX "webhook_deliveries_workspaceId_subscriptionId_createdAt_id_idx"
8+
ON "webhook_deliveries"("workspaceId", "subscriptionId", "createdAt" DESC, "id" DESC);

prisma/v2/schema.prisma

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,9 @@ model V2WebhookDelivery {
613613
@@index([workspaceId, status, createdAt(sort: Desc)])
614614
@@index([workspaceId, status, nextAttemptAt, id])
615615
@@index([workspaceId, status, leaseExpiresAt, id])
616+
@@index([workspaceId, createdAt(sort: Desc), id(sort: Desc)])
617+
@@index([workspaceId, eventId, createdAt(sort: Desc), id(sort: Desc)])
618+
@@index([workspaceId, subscriptionId, createdAt(sort: Desc), id(sort: Desc)])
616619
@@map("webhook_deliveries")
617620
}
618621

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
3+
import { requireScope } from '@/v2/application/authenticate-api-client'
4+
import { readWebhookDeliveryService } from '@/v2/application/read-webhook-delivery'
5+
import { createWebhookDeliveryQueryRepository } from '@/v2/infrastructure/repository-factory'
6+
import { authenticateExternalRequest } from '@/v2/public-api/authentication'
7+
import {
8+
publicApiHeaders,
9+
resolveRequestId,
10+
respondPublicError,
11+
} from '@/v2/public-api/errors'
12+
import { presentSuccess, presentWebhookDeliveryDiagnostic } from '@/v2/public-api/presenters'
13+
14+
export const dynamic = 'force-dynamic'
15+
16+
export async function GET(
17+
request: NextRequest,
18+
context: { params: Promise<{ deliveryId: string }> },
19+
) {
20+
const requestId = resolveRequestId(request)
21+
try {
22+
const actor = await authenticateExternalRequest(request)
23+
requireScope(actor, 'webhooks:admin')
24+
const { deliveryId } = await context.params
25+
const read = readWebhookDeliveryService({
26+
deliveries: createWebhookDeliveryQueryRepository(),
27+
})
28+
const diagnostic = await read({ workspaceId: actor.workspaceId, deliveryId })
29+
return NextResponse.json(
30+
presentSuccess({ delivery: presentWebhookDeliveryDiagnostic(diagnostic) }),
31+
{ status: 200, headers: publicApiHeaders(requestId) },
32+
)
33+
} catch (error) {
34+
return respondPublicError(error, requestId)
35+
}
36+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
3+
import { requireScope } from '@/v2/application/authenticate-api-client'
4+
import { listWebhookDeliveriesService } from '@/v2/application/list-webhook-deliveries'
5+
import { DomainError } from '@/v2/domain/errors'
6+
import { createWebhookDeliveryQueryRepository } from '@/v2/infrastructure/repository-factory'
7+
import { authenticateExternalRequest } from '@/v2/public-api/authentication'
8+
import {
9+
publicApiHeaders,
10+
resolveRequestId,
11+
respondPublicError,
12+
} from '@/v2/public-api/errors'
13+
import { presentSuccess, presentWebhookDeliverySummary } from '@/v2/public-api/presenters'
14+
15+
export const dynamic = 'force-dynamic'
16+
17+
export async function GET(request: NextRequest) {
18+
const requestId = resolveRequestId(request)
19+
try {
20+
const actor = await authenticateExternalRequest(request)
21+
requireScope(actor, 'webhooks:admin')
22+
const params = request.nextUrl.searchParams
23+
const allowedParameters = new Set(['limit', 'after', 'status', 'endpointId', 'eventId'])
24+
for (const name of params.keys()) {
25+
if (!allowedParameters.has(name)) {
26+
throw new DomainError('INVALID_ARGUMENT', `${name} is not a supported filter`)
27+
}
28+
}
29+
for (const name of allowedParameters) {
30+
if (params.getAll(name).length > 1) {
31+
throw new DomainError('INVALID_ARGUMENT', `${name} cannot be repeated`)
32+
}
33+
}
34+
const rawLimit = params.get('limit')
35+
const list = listWebhookDeliveriesService({
36+
deliveries: createWebhookDeliveryQueryRepository(),
37+
})
38+
const result = await list({
39+
workspaceId: actor.workspaceId,
40+
...(rawLimit !== null ? { limit: Number(rawLimit) } : {}),
41+
...(params.has('after') ? { after: params.get('after') ?? '' } : {}),
42+
...(params.has('status') ? { status: params.get('status') ?? '' } : {}),
43+
...(params.has('endpointId') ? { endpointId: params.get('endpointId') ?? '' } : {}),
44+
...(params.has('eventId') ? { eventId: params.get('eventId') ?? '' } : {}),
45+
})
46+
return NextResponse.json(
47+
presentSuccess({
48+
deliveries: result.deliveries.map(presentWebhookDeliverySummary),
49+
...(result.nextCursor ? { nextCursor: result.nextCursor } : {}),
50+
}),
51+
{ status: 200, headers: publicApiHeaders(requestId) },
52+
)
53+
} catch (error) {
54+
return respondPublicError(error, requestId)
55+
}
56+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { createHash } from 'node:crypto'
2+
3+
import type {
4+
WebhookDeliveryListQuery,
5+
WebhookDeliveryQueryRepository,
6+
} from './ports/webhook-delivery-query-repository.ts'
7+
import { DomainError, assertDomain } from '../domain/errors.ts'
8+
import {
9+
WEBHOOK_DELIVERY_STATUSES,
10+
type WebhookDeliveryStatus,
11+
} from '../domain/webhook.ts'
12+
13+
const SAFE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$/
14+
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
15+
const CURSOR_PATTERN = /^[A-Za-z0-9_-]{8,1024}$/
16+
const SHA256_PATTERN = /^[a-f0-9]{64}$/
17+
18+
interface WebhookDeliveryCursor {
19+
v: 1
20+
createdAt: string
21+
id: string
22+
filterHash: string
23+
}
24+
25+
function safeId(value: string, field: string): string {
26+
const normalized = value.trim()
27+
assertDomain(
28+
SAFE_ID_PATTERN.test(normalized),
29+
'INVALID_ARGUMENT',
30+
`${field} must contain 3 to 128 safe characters`,
31+
)
32+
return normalized
33+
}
34+
35+
function optionalUuid(value: string | undefined, field: string): string | undefined {
36+
const normalized = value?.trim().toLowerCase()
37+
if (!normalized) return undefined
38+
assertDomain(UUID_V4_PATTERN.test(normalized), 'INVALID_ARGUMENT', `${field} must be a UUID v4`)
39+
return normalized
40+
}
41+
42+
function createFilterHash(input: {
43+
workspaceId: string
44+
status?: WebhookDeliveryStatus
45+
endpointId?: string
46+
eventId?: string
47+
}): string {
48+
return createHash('sha256')
49+
.update(JSON.stringify({
50+
workspaceId: input.workspaceId,
51+
status: input.status ?? null,
52+
endpointId: input.endpointId ?? null,
53+
eventId: input.eventId ?? null,
54+
}))
55+
.digest('hex')
56+
}
57+
58+
function encodeCursor(cursor: WebhookDeliveryCursor): string {
59+
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url')
60+
}
61+
62+
function decodeCursor(value: string, expectedFilterHash: string): WebhookDeliveryCursor {
63+
assertDomain(CURSOR_PATTERN.test(value), 'INVALID_ARGUMENT', 'after is not a valid webhook delivery cursor')
64+
try {
65+
const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as unknown
66+
assertDomain(
67+
typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed),
68+
'INVALID_ARGUMENT',
69+
'after is not a valid webhook delivery cursor',
70+
)
71+
const cursor = parsed as Record<string, unknown>
72+
assertDomain(
73+
Object.keys(cursor).length === 4 &&
74+
cursor.v === 1 &&
75+
typeof cursor.createdAt === 'string' &&
76+
new Date(cursor.createdAt).toISOString() === cursor.createdAt &&
77+
typeof cursor.id === 'string' &&
78+
UUID_V4_PATTERN.test(cursor.id) &&
79+
typeof cursor.filterHash === 'string' &&
80+
SHA256_PATTERN.test(cursor.filterHash) &&
81+
cursor.filterHash === expectedFilterHash,
82+
'INVALID_ARGUMENT',
83+
'after does not match this webhook delivery query',
84+
)
85+
return cursor as unknown as WebhookDeliveryCursor
86+
} catch (error) {
87+
if (error instanceof DomainError) throw error
88+
throw new DomainError('INVALID_ARGUMENT', 'after is not a valid webhook delivery cursor')
89+
}
90+
}
91+
92+
export function listWebhookDeliveriesService(dependencies: {
93+
deliveries: WebhookDeliveryQueryRepository
94+
}) {
95+
return async function listWebhookDeliveries(request: {
96+
workspaceId: string
97+
limit?: number
98+
after?: string
99+
status?: string
100+
endpointId?: string
101+
eventId?: string
102+
}) {
103+
const workspaceId = safeId(request.workspaceId, 'workspaceId')
104+
const limit = request.limit ?? 20
105+
assertDomain(
106+
Number.isSafeInteger(limit) && limit >= 1 && limit <= 100,
107+
'INVALID_ARGUMENT',
108+
'limit must be an integer from 1 to 100',
109+
)
110+
const statusValue = request.status?.trim()
111+
assertDomain(
112+
!statusValue || WEBHOOK_DELIVERY_STATUSES.includes(statusValue as WebhookDeliveryStatus),
113+
'INVALID_ARGUMENT',
114+
'status is not supported',
115+
)
116+
const status = statusValue as WebhookDeliveryStatus | undefined
117+
const endpointId = optionalUuid(request.endpointId, 'endpointId')
118+
const eventId = optionalUuid(request.eventId, 'eventId')
119+
const queryFilterHash = createFilterHash({ workspaceId, status, endpointId, eventId })
120+
const cursorValue = request.after?.trim()
121+
const after = cursorValue ? decodeCursor(cursorValue, queryFilterHash) : undefined
122+
const query: WebhookDeliveryListQuery = {
123+
workspaceId,
124+
limit: limit + 1,
125+
...(status ? { status } : {}),
126+
...(endpointId ? { endpointId } : {}),
127+
...(eventId ? { eventId } : {}),
128+
...(after ? { after: { createdAt: after.createdAt, id: after.id } } : {}),
129+
}
130+
const records = await dependencies.deliveries.list(query)
131+
assertDomain(
132+
records.length <= limit + 1,
133+
'PERSISTENCE_CONFLICT',
134+
'Webhook delivery query returned too many records',
135+
)
136+
const hasNextPage = records.length > limit
137+
const page = records.slice(0, limit)
138+
const last = page.at(-1)?.delivery
139+
return Object.freeze({
140+
deliveries: Object.freeze(page),
141+
...(hasNextPage && last
142+
? {
143+
nextCursor: encodeCursor({
144+
v: 1,
145+
createdAt: last.createdAt,
146+
id: last.id,
147+
filterHash: queryFilterHash,
148+
}),
149+
}
150+
: {}),
151+
})
152+
}
153+
}

0 commit comments

Comments
 (0)