refactor(connect): validate the Peppol connector with the shared contract schemas (#2193)

The hosted Peppol route now parses requests with the schemas published in
@accounted/connect-contract instead of its own copies, and the instance
transport validates every hosted answer against the contract's response
schemas (a shape mismatch is a non-retryable protocol error) and reads the
error envelope through connectorErrorSchema. Paths come from the operation
table. No behaviour change for a conforming peer; the two sides can no longer
drift apart silently.

Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-02 21:08:49 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent f31eeaa603
commit 3f9a21c64d
3 changed files with 69 additions and 75 deletions
+13 -46
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import type { z } from 'zod'
import { CONNECTOR_HEADERS, PEPPOL_OPERATIONS, peppolParticipantSchema } from '@accounted/connect-contract'
import { withConnectorAuth, type ConnectorContext } from '@/lib/connect/hosted/with-connector-auth'
import { reserveUpstream } from '@/lib/connect/hosted/upstream-budget'
import {
@@ -63,53 +64,19 @@ import { QVALIA_PROVIDER, createQvaliaTransport, readQvaliaConfigFromEnv } from
* operation answers 403.
*/
const MAX_DOCUMENT_CHARS = 5_000_000
const COMPANY_HEADER = 'x-connector-company'
const COMPANY_HEADER = CONNECTOR_HEADERS.company.toLowerCase()
const PENDING_STATE_PREFIX = 'peppol:'
const participantSchema = z.object({
// Peppol participant scheme (ISO 6523 ICD), four digits; not a BAS account.
scheme: z.string().length(4).regex(/^\d+$/),
identifier: z.string().trim().min(1).max(64),
})
const documentTypeSchema = z.enum(['Invoice', 'CreditNote'])
const lookupSchema = z.object({ participant: participantSchema })
const submissionSchema = z.object({
idempotencyKey: z.string().trim().min(1).max(128),
tenantReference: z.string().trim().min(1).max(128),
sender: participantSchema,
recipient: participantSchema,
documentTypeId: z.string().trim().min(1).max(512),
processId: z.string().trim().min(1).max(512),
filename: z.string().trim().min(1).max(255),
contentType: z.literal('application/xml'),
document: z.string().min(1).max(MAX_DOCUMENT_CHARS),
documentSha256: z.string().regex(/^[0-9a-f]{64}$/),
})
const submissionRefSchema = z.object({ providerSubmissionId: z.string().trim().min(1).max(128) })
const registrationSchema = z.object({
participant: participantSchema,
businessCard: z.object({
companyName: z.string().trim().min(1).max(200),
countryCode: z.string().trim().length(2),
geographicalInformation: z.string().max(500).nullish(),
vatNumber: z.string().max(64).nullish(),
orgNumber: z.string().max(64).nullish(),
}),
documentTypes: z.array(z.object({ processId: z.string().min(1).max(512), documentTypeId: z.string().min(1).max(512) })).min(1).max(20),
description: z.string().max(200).nullish(),
tenantReference: z.string().max(128).nullish(),
})
const inboundListSchema = z.object({
documentType: documentTypeSchema,
limit: z.number().int().min(1).max(100).optional(),
includeRead: z.boolean().optional(),
})
const inboundXmlSchema = z.object({
providerDocumentId: z.string().trim().min(1).max(128),
documentType: documentTypeSchema,
})
// Request shapes come from the published contract so this route and the
// instance transport (and any third-party implementation of either side)
// validate with the same schemas.
const participantSchema = peppolParticipantSchema
const lookupSchema = PEPPOL_OPERATIONS.lookup.request
const submissionSchema = PEPPOL_OPERATIONS.submit.request
const submissionRefSchema = PEPPOL_OPERATIONS.status.request
const registrationSchema = PEPPOL_OPERATIONS.register.request
const inboundListSchema = PEPPOL_OPERATIONS.inboundList.request
const inboundXmlSchema = PEPPOL_OPERATIONS.inboundXml.request
function hostedTransport(): PeppolTransport | null {
const config = readQvaliaConfigFromEnv()
@@ -62,15 +62,24 @@ describe('connector Peppol transport', () => {
})
it('polls status and evidence with the connector provider stamped on and the owning company resolved', async () => {
const event = {
provider: 'qvalia', providerTenantId: '5560000000', providerSubmissionId: 'int-1', providerEventId: 'e1', idempotencyKey: null,
eventCode: 'status_poll', normalizedStatus: 'transport_succeeded', isTerminal: false, detail: null, occurredAt: 't',
rawPayload: {}, eventSha256: 'a'.repeat(64), verificationMethod: 'provider_poll',
}
const evidence = {
provider: 'qvalia', evidenceType: 'qvalia_message_record', payload: {}, exactDocument: null, exactDocumentSha256: null,
evidenceSha256: 'b'.repeat(64), retrievedAt: 't',
}
const fetchMock = vi.fn()
.mockResolvedValueOnce(jsonResponse([{ provider: 'qvalia', eventCode: 'status_poll' }]))
.mockResolvedValueOnce(jsonResponse([{ provider: 'qvalia', evidenceType: 'qvalia_message_record' }]))
.mockResolvedValueOnce(jsonResponse([event]))
.mockResolvedValueOnce(jsonResponse([evidence]))
const transport = createConnectorPeppolTransport(upstream, {
fetch: fetchMock as unknown as typeof fetch,
companyFor: async (id) => (id === 'int-1' ? 'company-7' : null),
})
expect(await transport.pollDeliveryStatus!('int-1')).toEqual([{ provider: 'connector', eventCode: 'status_poll' }])
expect(await transport.retrieveEvidence('int-1')).toEqual([{ provider: 'connector', evidenceType: 'qvalia_message_record' }])
expect(await transport.pollDeliveryStatus!('int-1')).toEqual([{ ...event, provider: 'connector' }])
expect(await transport.retrieveEvidence('int-1')).toEqual([{ ...evidence, provider: 'connector' }])
for (const call of fetchMock.mock.calls as Array<[string, RequestInit]>) {
expect((call[1].headers as Record<string, string>)['X-Connector-Company']).toBe('company-7')
}
@@ -109,3 +118,13 @@ describe('transport security', () => {
await expect(transport.lookupRecipient(participant)).rejects.toSatisfy((e: unknown) => isPeppolTransportError(e) && e.retryable === true)
})
})
describe('contract validation', () => {
it('rejects a hosted answer that does not match the contract as a non-retryable protocol error', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ reachable: 'maybe' }))
const transport = build(fetchMock as unknown as typeof fetch)
await expect(transport.lookupRecipient(participant)).rejects.toSatisfy(
(e: unknown) => isPeppolTransportError(e) && e.retryable === false && /unexpected response shape/.test(e.message),
)
})
})
+33 -25
View File
@@ -1,18 +1,20 @@
import { PEPPOL_OPERATIONS, connectorErrorSchema } from '@accounted/connect-contract'
import type { z } from 'zod'
import { CONNECTOR_COMPANY_HEADER, type ConnectorUpstream } from '@/lib/connect/instance/upstreams'
import {
CONNECTOR_PEPPOL_PROVIDER,
PeppolTransportError,
type PeppolDeliveryEvidence,
type PeppolInboundListOptions,
type PeppolInboundMessage,
type PeppolParticipant,
type PeppolRecipientLookup,
type PeppolRecipientRegistration,
type PeppolSubmissionReceipt,
type PeppolVerifiedEvent,
type PeppolInboundListOptions,
type PeppolParticipant,
type PeppolRecipientRegistrationInput,
type PeppolSubmission,
type PeppolSubmissionReceipt,
type PeppolTransport,
type PeppolVerifiedEvent,
type PeppolWebhookRequest,
} from '@/lib/invoices/peppol-transport'
@@ -49,13 +51,6 @@ export interface ConnectorTransportDeps {
companyForParticipant?: (participant: PeppolParticipant) => Promise<string | null>
}
interface ConnectorErrorBody {
error?: string
code?: string
retryable?: boolean
detail?: string | null
}
async function readJson(response: Response): Promise<unknown> {
const text = await response.text()
if (!text) return null
@@ -67,14 +62,25 @@ async function readJson(response: Response): Promise<unknown> {
}
function failureFromResponse(status: number, body: unknown): PeppolTransportError {
const parsed = (body && typeof body === 'object' ? body : {}) as ConnectorErrorBody
const code = typeof parsed.code === 'string' ? parsed.code : `HTTP_${status}`
const message = typeof parsed.error === 'string' && parsed.error ? parsed.error : `Connector answered ${status}`
const retryable = typeof parsed.retryable === 'boolean' ? parsed.retryable : status === 429 || status >= 500
const detail = [code, typeof parsed.detail === 'string' ? parsed.detail : null].filter(Boolean).join(': ')
const parsed = connectorErrorSchema.safeParse(body)
const envelope = parsed.success ? parsed.data : null
const code = envelope?.code ?? `HTTP_${status}`
const message = envelope?.error || `Connector answered ${status}`
const retryable = typeof envelope?.retryable === 'boolean' ? envelope.retryable : status === 429 || status >= 500
const detail = [code, envelope?.detail ?? null].filter(Boolean).join(': ')
return new PeppolTransportError(`Connector: ${message}`, { retryable, detail: detail || null })
}
/** Validate a hosted answer against the contract; a shape mismatch is a protocol error, never retried. */
function parseResponse<T>(schema: z.ZodType<T>, json: unknown, operation: string): T {
const parsed = schema.safeParse(json)
if (parsed.success) return parsed.data
throw new PeppolTransportError(`Connector: unexpected response shape from ${operation}`, {
retryable: false,
detail: parsed.error.issues.slice(0, 3).map((i) => `${i.path.join('.')}: ${i.message}`).join('; '),
})
}
/**
* The connector key travels as a bearer token, so the hosted origin must be
* https. Plain http is tolerated for loopback only (local development against
@@ -104,6 +110,8 @@ export function createConnectorPeppolTransport(
assertTransportSecurity(baseUrl)
async function call<T>(
operation: string,
schema: z.ZodType<T>,
method: 'POST' | 'PUT' | 'DELETE',
path: string,
body: unknown,
@@ -130,7 +138,7 @@ export function createConnectorPeppolTransport(
})
const json = await readJson(response)
if (!response.ok) throw failureFromResponse(response.status, json)
return json as T
return parseResponse(schema, json, operation)
} catch (error) {
if (error instanceof PeppolTransportError) throw error
throw new PeppolTransportError('Connector: could not reach the hosted service', { retryable: true, cause: error })
@@ -144,11 +152,11 @@ export function createConnectorPeppolTransport(
}
async function lookupRecipient(participant: PeppolParticipant): Promise<PeppolRecipientLookup> {
return call<PeppolRecipientLookup>('POST', '/lookup', { participant })
return call('lookup', PEPPOL_OPERATIONS.lookup.response, 'POST', PEPPOL_OPERATIONS.lookup.path, { participant })
}
async function submit(submission: PeppolSubmission): Promise<PeppolSubmissionReceipt> {
const receipt = await call<PeppolSubmissionReceipt>('POST', '/submit', submission, {
const receipt = await call('submit', PEPPOL_OPERATIONS.submit.response, 'POST', PEPPOL_OPERATIONS.submit.path, submission, {
companyRef: submission.tenantReference,
})
return withProvider(receipt)
@@ -168,14 +176,14 @@ export function createConnectorPeppolTransport(
}
async function retrieveEvidence(providerSubmissionId: string): Promise<PeppolDeliveryEvidence[]> {
const items = await call<PeppolDeliveryEvidence[]>('POST', '/evidence', { providerSubmissionId }, {
const items = await call('evidence', PEPPOL_OPERATIONS.evidence.response, 'POST', PEPPOL_OPERATIONS.evidence.path, { providerSubmissionId }, {
companyRef: await companyFor(providerSubmissionId),
})
return (items ?? []).map(withProvider)
}
async function pollDeliveryStatus(providerSubmissionId: string): Promise<PeppolVerifiedEvent[]> {
const events = await call<PeppolVerifiedEvent[]>('POST', '/status', { providerSubmissionId }, {
const events = await call('status', PEPPOL_OPERATIONS.status.response, 'POST', PEPPOL_OPERATIONS.status.path, { providerSubmissionId }, {
companyRef: await companyFor(providerSubmissionId),
})
return (events ?? []).map(withProvider)
@@ -187,7 +195,7 @@ export function createConnectorPeppolTransport(
retryable: false,
})
}
const result = await call<PeppolRecipientRegistration>('PUT', '/recipient', input, {
const result = await call('register', PEPPOL_OPERATIONS.register.response, 'PUT', PEPPOL_OPERATIONS.register.path, input, {
companyRef: input.tenantReference,
})
return { ...result, participant: input.participant }
@@ -195,13 +203,13 @@ export function createConnectorPeppolTransport(
async function unregisterRecipient(participant: PeppolParticipant): Promise<void> {
const query = new URLSearchParams({ scheme: participant.scheme, identifier: participant.identifier })
await call<unknown>('DELETE', `/recipient?${query.toString()}`, undefined, {
await call('unregister', PEPPOL_OPERATIONS.unregister.response, 'DELETE', `${PEPPOL_OPERATIONS.unregister.path}?${query.toString()}`, undefined, {
companyRef: deps.companyForParticipant ? await deps.companyForParticipant(participant) : null,
})
}
async function listInboundDocuments(options: PeppolInboundListOptions): Promise<PeppolInboundMessage[]> {
const items = await call<PeppolInboundMessage[]>('POST', '/inbound/list', options)
const items = await call('inboundList', PEPPOL_OPERATIONS.inboundList.response, 'POST', PEPPOL_OPERATIONS.inboundList.path, options)
return (items ?? []).map(withProvider)
}
@@ -209,7 +217,7 @@ export function createConnectorPeppolTransport(
providerDocumentId: string,
documentType: PeppolInboundListOptions['documentType'],
): Promise<string | null> {
const result = await call<{ xml: string | null }>('POST', '/inbound/xml', { providerDocumentId, documentType })
const result = await call('inboundXml', PEPPOL_OPERATIONS.inboundXml.response, 'POST', PEPPOL_OPERATIONS.inboundXml.path, { providerDocumentId, documentType })
return typeof result?.xml === 'string' && result.xml.trim().startsWith('<') ? result.xml : null
}