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()