feat(connect): Peppol through the connector (hosted proxy, instance transport, ownership ledger) (#2177)
* feat(connect): peppol connector foundation: capability, ledger/budget service, quota Adds the storage + package shape for brokering Peppol through the connector with the same one-address + rate-budget model as bank/skatteverket: a peppol capability (connector-gated, free on hosted), peppol as a ledger + upstream service, a conservative rate budget, and a migration extending the ledger service CHECK and the per-key limits (peppol_connections_per_company). Proxy route + instance-side Qvalia reroute follow. Switch-on gated on the Qvalia brokering-terms check. (cherry picked from commit 3cc0da6a3, migration renumbered 20260902190000) Signed-off-by: Jakob Wennberg <jakob.wennberg@arcim.io> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat * feat(connect): Peppol through the connector: hosted proxy, instance transport, ownership ledger Completes the Peppol upstream for self-hosted instances on the connector (WS3): an instance with a connector key carrying the peppol scope and no Qvalia keys of its own sends and receives e-invoices through Arcim's contracted access point, the same way bank and Skatteverket already route. Hosted: app/api/connect/peppol/[...path] speaks the PeppolTransport operations (lookup, submit, status, evidence, recipient PUT/DELETE, inbound list/xml) rather than proxying Qvalia paths, because the Qvalia account is shared by every hosted company and every instance: reads must be scoped to what the caller owns, and the inbound read endpoint is destructive for the whole account. Ownership: a receiving registration is a ledger row (service peppol, participant id in account_uids, sha256 in handle_hash so one key holds a participant at a time); outbound submissions land in the new connector_peppol_submissions table and gate status/evidence; inbound documents are served from the hosted archive filtered by the participants the key holds. Per-company quota (peppol_connections_per_company), the shared PEPPOL_RECEIVING_MAX_REGISTRATIONS cap, and the global peppol rate budget apply. Provider failures cross as CONNECTOR_UPSTREAM_ERROR with the adapter's retryable flag (422 or 502). Instance: lib/invoices/transports/connector.ts implements PeppolTransport over that API and registers itself in connector mode (key present, no QVALIA_* keys); getPeppolTransportAvailability() defaults to it when no provider is selected, so an instance needs no PEPPOL_TRANSPORT_PROVIDER. Webhooks are not brokered; the existing outbound status poll covers it. Hosted is byte-identical: it has its own keys, so connector mode is never on. Docs: SELF-HOSTING.md, SOVEREIGN.md, .env.example. Switch-on for third-party instances stays gated on the Qvalia brokering-terms check; without the scope every operation answers 403. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> * fix(connect): authorize Peppol participants per key, harden the proxy after review Review follow-ups on #2177. Authorization: a key may only register (and send as) participant identifiers Arcim recorded on the key at issuance (connector_keys.peppol_participants, migration 20260902191000) or the licensee's own org number, and a document may only be submitted as a sender the key has registered; X-Connector-Company stays an opaque per-company ref. Cap: the shared access-point cap now counts fresh pending reservations and is re-checked after this request's own reservation, so concurrent registrations cannot both pass. Inbound: both halves of the participant id are filtered in the archive query (over-fetched, then exact-pair checked), so foreign rows sharing an identifier cannot consume the limit. Delete: deregistration is a required transport capability, checked before the ledger row is revoked, and registration refuses an access point that cannot deregister. Instance transport: the hosted URL must be https (loopback http only, same rule as getConnectorConfig), and the response body is read inside the timeout window with body-read failures mapped to retryable transport errors. issue-connector-key.ts gains --peppol-participants and --peppol-connections-per-company. Declined: NOT VALID on the ledger CHECK (the table is empty until keys are issued; the validated scan is instant). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> * fix(connect): bind Peppol ownership to the instance company, query exact participant pairs Second review round on #2177. Ownership is now (key, company_ref), not key alone: a sender must be registered under the same company header, status and evidence reads look the submission up under the header company, DELETE and re-registration refuse a participant the key holds for another company, so one company on a multi-company instance cannot act on another company's registration through the shared key. The instance transport resolves the owning company from its own peppol_deliveries / peppol_registrations rows before status, evidence and deregistration calls (deps.companyFor, deps.companyForParticipant, wired in transports/index.ts). Inbound listing stays key-wide (the instance routes documents to its own companies by its own registrations). The archive query now runs one exact-pair query per scheme (scheme fixed, that scheme's identifiers), so neither foreign nor cross-pair rows can consume the limit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> --------- Signed-off-by: Jakob Wennberg <jakob.wennberg@arcim.io> 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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
fcfa1ba974
commit
f31eeaa603
@@ -15,7 +15,7 @@ const ROW = {
|
||||
status: 'active',
|
||||
current_period_end: '2027-01-01T00:00:00.000Z',
|
||||
rate_limited: false,
|
||||
limits: { bank_connections_per_company: 2, skv_connections_per_company: 1, sync_min_interval_s: 3600 },
|
||||
limits: { bank_connections_per_company: 2, skv_connections_per_company: 1, peppol_connections_per_company: 1, sync_min_interval_s: 3600 },
|
||||
}
|
||||
|
||||
describe('connector key primitives', () => {
|
||||
@@ -57,7 +57,7 @@ describe('validateConnectorKey', () => {
|
||||
scopes: ['bank_sync', 'skatteverket'],
|
||||
status: 'active',
|
||||
currentPeriodEnd: '2027-01-01T00:00:00.000Z',
|
||||
limits: { bank_connections_per_company: 2, skv_connections_per_company: 1, sync_min_interval_s: 3600 },
|
||||
limits: { bank_connections_per_company: 2, skv_connections_per_company: 1, peppol_connections_per_company: 1, sync_min_interval_s: 3600 },
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -65,7 +65,7 @@ describe('validateConnectorKey', () => {
|
||||
it('fills default limits when the RPC returns null limits', async () => {
|
||||
const { key } = generateConnectorKey()
|
||||
const result = await validateConnectorKey(key, supabaseWithRpc({ data: [{ ...ROW, limits: null }] }).supabase)
|
||||
expect(result.ok && result.key.limits).toEqual({ bank_connections_per_company: 1, skv_connections_per_company: 1, sync_min_interval_s: 0 })
|
||||
expect(result.ok && result.key.limits).toEqual({ bank_connections_per_company: 1, skv_connections_per_company: 1, peppol_connections_per_company: 1, sync_min_interval_s: 0 })
|
||||
})
|
||||
|
||||
it('maps no row (unknown/revoked) to 401, but an RPC error to 503', async () => {
|
||||
|
||||
@@ -28,12 +28,15 @@ export function isConnectorKeyFormat(key: string): boolean {
|
||||
export interface ConnectorKeyLimits {
|
||||
bank_connections_per_company: number
|
||||
skv_connections_per_company: number
|
||||
/** Active Peppol receiving registrations per company ("one address"). */
|
||||
peppol_connections_per_company: number
|
||||
sync_min_interval_s: number
|
||||
}
|
||||
|
||||
export const DEFAULT_CONNECTOR_LIMITS: ConnectorKeyLimits = {
|
||||
bank_connections_per_company: 1,
|
||||
skv_connections_per_company: 1,
|
||||
peppol_connections_per_company: 1,
|
||||
sync_min_interval_s: 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
* session id, SKV access token) is hashed; the value never rests here.
|
||||
*/
|
||||
|
||||
export type ConnectorService = 'bank' | 'skatteverket'
|
||||
export type ConnectorService = 'bank' | 'skatteverket' | 'peppol'
|
||||
|
||||
export function hashHandle(handle: string): string {
|
||||
return crypto.createHash('sha256').update(handle).digest('hex')
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { isPeppolTransportError, type PeppolParticipant } from '@/lib/invoices/peppol-transport'
|
||||
import { hashHandle } from './ledger'
|
||||
|
||||
/**
|
||||
* Peppol-specific reads over the connector ledger (hosted side).
|
||||
*
|
||||
* A Peppol "connection" is a receiving registration: one participant id
|
||||
* (scheme:identifier) published under Arcim's access point on behalf of one
|
||||
* company on one instance. The ledger row stores the participant id in
|
||||
* `account_uids` (it is public directory data, not a secret) so inbound
|
||||
* documents can be routed to the key that owns the recipient, and its sha256
|
||||
* in `handle_hash` so the partial unique index makes a participant claimable
|
||||
* by exactly one key at a time.
|
||||
*
|
||||
* Outbound submissions are tracked in `connector_peppol_submissions`: the
|
||||
* hosted Qvalia account is shared, so status polls and evidence reads must
|
||||
* prove the caller submitted the document.
|
||||
*/
|
||||
|
||||
export function peppolHandle(participant: PeppolParticipant): string {
|
||||
return `${participant.scheme}:${participant.identifier.replace(/\s/g, '')}`
|
||||
}
|
||||
|
||||
export function parsePeppolHandle(handle: string): PeppolParticipant | null {
|
||||
const colon = handle.indexOf(':')
|
||||
if (colon === -1) return null
|
||||
const scheme = handle.slice(0, colon)
|
||||
const identifier = handle.slice(colon + 1)
|
||||
// ISO 6523 ICD scheme: four digits (not a BAS account, hence no shared schema).
|
||||
if (scheme.length !== 4 || !/^\d+$/.test(scheme) || !identifier) return null
|
||||
return { scheme, identifier }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every participant this key currently holds an active registration for,
|
||||
* optionally narrowed to one company on the instance. Inbound routing is
|
||||
* key-wide (the instance's inbound sync lists for all its companies and
|
||||
* routes by its own registrations); everything else is company-bound.
|
||||
*/
|
||||
export async function listActivePeppolParticipants(
|
||||
supabase: SupabaseClient,
|
||||
keyId: string,
|
||||
companyRef?: string,
|
||||
): Promise<PeppolParticipant[]> {
|
||||
let query = supabase
|
||||
.from('connector_connections')
|
||||
.select('account_uids')
|
||||
.eq('connector_key_id', keyId)
|
||||
.eq('service', 'peppol')
|
||||
.eq('status', 'active')
|
||||
if (companyRef) query = query.eq('company_ref', companyRef)
|
||||
const { data, error } = await query
|
||||
if (error) throw new Error(`ledger read failed: ${error.message}`)
|
||||
const participants: PeppolParticipant[] = []
|
||||
for (const row of (data ?? []) as Array<{ account_uids: string[] | null }>) {
|
||||
for (const uid of row.account_uids ?? []) {
|
||||
const parsed = parsePeppolHandle(uid)
|
||||
if (parsed) participants.push(parsed)
|
||||
}
|
||||
}
|
||||
return participants
|
||||
}
|
||||
|
||||
/** Whether ANY key holds an active registration for this participant. */
|
||||
export async function isPeppolParticipantHeld(supabase: SupabaseClient, handle: string): Promise<boolean> {
|
||||
const { data, error } = await supabase
|
||||
.from('connector_connections')
|
||||
.select('id')
|
||||
.eq('service', 'peppol')
|
||||
.eq('status', 'active')
|
||||
.eq('handle_hash', hashHandle(handle))
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`ledger read failed: ${error.message}`)
|
||||
return !!data
|
||||
}
|
||||
|
||||
/** Whether a HOSTED company holds a live registration for this participant at the provider. */
|
||||
export async function isHostedPeppolParticipantLive(
|
||||
supabase: SupabaseClient,
|
||||
params: { provider: string; participant: PeppolParticipant },
|
||||
): Promise<boolean> {
|
||||
const { data, error } = await supabase
|
||||
.from('peppol_registrations')
|
||||
.select('id')
|
||||
.eq('provider', params.provider)
|
||||
.eq('participant_scheme', params.participant.scheme)
|
||||
.eq('participant_identifier', params.participant.identifier.replace(/\s/g, ''))
|
||||
.in('status', ['pending', 'registered'])
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`peppol registration read failed: ${error.message}`)
|
||||
return !!data
|
||||
}
|
||||
|
||||
/** Same reservation window as countHeldConnections in ./ledger.ts. */
|
||||
const PENDING_CAP_WINDOW_MS = 15 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Connector-held registrations across every key, for the provider-account
|
||||
* cap: active rows plus fresh pending reservations, so two concurrent
|
||||
* registrations cannot both pass the cap check and both land.
|
||||
*/
|
||||
export async function countConnectorPeppolRegistrations(supabase: SupabaseClient, now: Date = new Date()): Promise<number> {
|
||||
const freshPendingSince = new Date(now.getTime() - PENDING_CAP_WINDOW_MS).toISOString()
|
||||
const [active, pending] = await Promise.all([
|
||||
supabase
|
||||
.from('connector_connections')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('service', 'peppol')
|
||||
.eq('status', 'active'),
|
||||
supabase
|
||||
.from('connector_connections')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('service', 'peppol')
|
||||
.eq('status', 'pending')
|
||||
.gte('created_at', freshPendingSince),
|
||||
])
|
||||
if (active.error) throw new Error(`ledger count failed: ${active.error.message}`)
|
||||
if (pending.error) throw new Error(`ledger count failed: ${pending.error.message}`)
|
||||
return (active.count ?? 0) + (pending.count ?? 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Participant identifiers a key may publish under Arcim's access point: the
|
||||
* allowlist Arcim recorded at issuance plus the licensee's own org number.
|
||||
* Whitespace is stripped the same way peppolHandle() does.
|
||||
*/
|
||||
export async function getPeppolAllowedIdentifiers(supabase: SupabaseClient, keyId: string): Promise<Set<string>> {
|
||||
const { data, error } = await supabase
|
||||
.from('connector_keys')
|
||||
.select('org_number, peppol_participants')
|
||||
.eq('id', keyId)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`connector key read failed: ${error.message}`)
|
||||
const row = data as { org_number: string | null; peppol_participants: string[] | null } | null
|
||||
const allowed = new Set<string>()
|
||||
for (const value of [row?.org_number ?? '', ...(row?.peppol_participants ?? [])]) {
|
||||
const cleaned = value.replace(/\s/g, '')
|
||||
if (cleaned) allowed.add(cleaned)
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
export async function recordPeppolSubmission(
|
||||
supabase: SupabaseClient,
|
||||
params: { keyId: string; companyRef: string; provider: string; providerSubmissionId: string; idempotencyKey: string },
|
||||
): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('connector_peppol_submissions')
|
||||
.upsert(
|
||||
{
|
||||
connector_key_id: params.keyId,
|
||||
company_ref: params.companyRef,
|
||||
provider: params.provider,
|
||||
provider_submission_id: params.providerSubmissionId,
|
||||
idempotency_key: params.idempotencyKey,
|
||||
},
|
||||
{ onConflict: 'provider,provider_submission_id', ignoreDuplicates: true },
|
||||
)
|
||||
if (error) throw new Error(`submission record failed: ${error.message}`)
|
||||
}
|
||||
|
||||
/** The submission row for (key, company, provider submission id): both the key and the company must match. */
|
||||
export async function findOwnedPeppolSubmission(
|
||||
supabase: SupabaseClient,
|
||||
params: { keyId: string; companyRef: string; provider: string; providerSubmissionId: string },
|
||||
): Promise<{ id: string; company_ref: string } | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('connector_peppol_submissions')
|
||||
.select('id, company_ref')
|
||||
.eq('connector_key_id', params.keyId)
|
||||
.eq('company_ref', params.companyRef)
|
||||
.eq('provider', params.provider)
|
||||
.eq('provider_submission_id', params.providerSubmissionId)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`submission read failed: ${error.message}`)
|
||||
return (data as { id: string; company_ref: string } | null) ?? null
|
||||
}
|
||||
|
||||
export interface PeppolUpstreamFailure {
|
||||
/** Short, adapter-classified text (never a raw provider body). */
|
||||
text: string
|
||||
retryable: boolean
|
||||
/** Adapter detail, capped; the instance surfaces it as PeppolTransportError.detail. */
|
||||
hint: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize a transport failure for the connector response. The Qvalia
|
||||
* adapter already classifies failures (network, auth, protocol, rejected,
|
||||
* duplicate) into its own message text; only that classified text and its
|
||||
* capped detail cross to the instance. Anything that is not a transport
|
||||
* error is a hosted bug and is not summarized here (the caller rethrows).
|
||||
*/
|
||||
export function describePeppolUpstreamFailure(err: unknown): PeppolUpstreamFailure | null {
|
||||
if (!isPeppolTransportError(err)) return null
|
||||
return {
|
||||
text: err.message.slice(0, 200),
|
||||
retryable: err.retryable,
|
||||
hint: err.detail ? err.detail.slice(0, 300) : null,
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,14 @@ export function budgetFor(service: UpstreamService): Budget {
|
||||
hourMax: intFromEnv('CONNECT_BANK_RPH_BUDGET', 3000), // ~30% of EB's 10 000/h
|
||||
}
|
||||
}
|
||||
if (service === 'peppol') {
|
||||
// Peppol is low-volume (invoices, not polling), so a modest ceiling well
|
||||
// under Qvalia's limits is plenty; tune via env if a busy byrå needs more.
|
||||
return {
|
||||
minuteMax: intFromEnv('CONNECT_PEPPOL_RPM_BUDGET', 60),
|
||||
hourMax: intFromEnv('CONNECT_PEPPOL_RPH_BUDGET', 1000),
|
||||
}
|
||||
}
|
||||
return {
|
||||
minuteMax: intFromEnv('CONNECT_SKV_RPM_BUDGET', 120),
|
||||
hourMax: intFromEnv('CONNECT_SKV_RPH_BUDGET', 4000),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest'
|
||||
import {
|
||||
bankConnectorMode,
|
||||
peppolConnectorMode,
|
||||
skatteverketConnectorMode,
|
||||
hasOwnEnableBankingCredentials,
|
||||
hasOwnPeppolCredentials,
|
||||
hasOwnSkatteverketCredentials,
|
||||
} from '../upstreams'
|
||||
|
||||
const ENV = ['GNUBOK_CONNECTOR_KEY', 'GNUBOK_CONNECT_URL', 'ENABLE_BANKING_PRIVATE_KEY', 'ENABLE_BANKING_APP_ID', 'ENABLE_BANKING_PRIVATE_KEY_PRODUCTION', 'ENABLE_BANKING_APP_ID_PRODUCTION', 'SKATTEVERKET_OAUTH2_CLIENT_ID', 'SKATTEVERKET_APIGW_CLIENT_ID'] as const
|
||||
const ENV = ['GNUBOK_CONNECTOR_KEY', 'GNUBOK_CONNECT_URL', 'ENABLE_BANKING_PRIVATE_KEY', 'ENABLE_BANKING_APP_ID', 'ENABLE_BANKING_PRIVATE_KEY_PRODUCTION', 'ENABLE_BANKING_APP_ID_PRODUCTION', 'SKATTEVERKET_OAUTH2_CLIENT_ID', 'SKATTEVERKET_APIGW_CLIENT_ID', 'QVALIA_API_KEY', 'QVALIA_PARTNER_REG_NO'] as const
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
function clear() {
|
||||
@@ -56,3 +58,15 @@ describe('connector-mode detection', () => {
|
||||
expect(skatteverketConnectorMode()).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('peppol connector mode', () => {
|
||||
it('is off without a key and off with own Qvalia keys, on otherwise', () => {
|
||||
clear()
|
||||
expect(peppolConnectorMode()).toBeNull()
|
||||
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_x')
|
||||
expect(peppolConnectorMode()).toEqual({ baseUrl: 'https://app.gnubok.se/api/connect/peppol', key: 'gnubok_ck_x' })
|
||||
vi.stubEnv('QVALIA_PARTNER_REG_NO', '5560000000')
|
||||
expect(hasOwnPeppolCredentials()).toBe(true)
|
||||
expect(peppolConnectorMode()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getConnectorConfig } from './config'
|
||||
import {
|
||||
hasOwnEnableBankingCredentials,
|
||||
hasOwnPeppolCredentials,
|
||||
hasOwnSkatteverketCredentials,
|
||||
} from '@/lib/entitlements/own-credentials'
|
||||
|
||||
@@ -24,7 +25,7 @@ export const CONNECTOR_COMPANY_HEADER = 'X-Connector-Company'
|
||||
export const CONNECTOR_UPSTREAM_AUTH_HEADER = 'X-Connector-Upstream-Authorization'
|
||||
export const CONNECTOR_UPSTREAM_CONTENT_TYPE_HEADER = 'X-Connector-Upstream-Content-Type'
|
||||
|
||||
export { hasOwnEnableBankingCredentials, hasOwnSkatteverketCredentials }
|
||||
export { hasOwnEnableBankingCredentials, hasOwnPeppolCredentials, hasOwnSkatteverketCredentials }
|
||||
|
||||
export interface ConnectorUpstream {
|
||||
/** Base URL to send upstream requests to (the hosted proxy). */
|
||||
@@ -46,3 +47,15 @@ export function skatteverketConnectorMode(): ConnectorUpstream | null {
|
||||
if (!cfg) return null
|
||||
return { baseUrl: `${cfg.baseUrl}/api/connect/skv`, key: cfg.key }
|
||||
}
|
||||
|
||||
/**
|
||||
* Peppol through Arcim's contracted access point. Same rule as the other
|
||||
* upstreams: an instance with its own Qvalia partner keys runs Peppol itself
|
||||
* and is never routed here.
|
||||
*/
|
||||
export function peppolConnectorMode(): ConnectorUpstream | null {
|
||||
if (hasOwnPeppolCredentials()) return null
|
||||
const cfg = getConnectorConfig()
|
||||
if (!cfg) return null
|
||||
return { baseUrl: `${cfg.baseUrl}/api/connect/peppol`, key: cfg.key }
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('CONNECTOR_CAPABILITIES', () => {
|
||||
const { CAPABILITY, CONNECTOR_CAPABILITIES, PAID_CAPABILITIES, isConnectorCapability } = await import('../keys')
|
||||
const all = new Set(Object.values(CAPABILITY))
|
||||
for (const key of CONNECTOR_CAPABILITIES) expect(all.has(key), key).toBe(true)
|
||||
expect(CONNECTOR_CAPABILITIES).toEqual(['bank_sync', 'skatteverket', 'org_lookup', 'migration'])
|
||||
expect(CONNECTOR_CAPABILITIES).toEqual(['bank_sync', 'skatteverket', 'org_lookup', 'migration', 'peppol'])
|
||||
// org_lookup and migration stay free on hosted (not PAID) but still need
|
||||
// Accounted's services, hence connector-gated on a self-host.
|
||||
expect(PAID_CAPABILITIES).not.toContain('org_lookup')
|
||||
|
||||
@@ -51,6 +51,8 @@ export const CAPABILITY = {
|
||||
* mail keeps leaving from the platform sender.
|
||||
*/
|
||||
custom_sender_domain: 'custom_sender_domain',
|
||||
/** Peppol e-invoicing (send/receive via a Peppol Access Point). Free on hosted (Arcim's own AP); on self-host brokered through the connector: Arcim's Qvalia AP with a per-key one-address + volume quota. */
|
||||
peppol: 'peppol',
|
||||
} as const
|
||||
|
||||
export type CapabilityKey = (typeof CAPABILITY)[keyof typeof CAPABILITY]
|
||||
@@ -105,6 +107,7 @@ export const CONNECTOR_CAPABILITIES: readonly CapabilityKey[] = [
|
||||
CAPABILITY.skatteverket,
|
||||
CAPABILITY.org_lookup,
|
||||
CAPABILITY.migration,
|
||||
CAPABILITY.peppol,
|
||||
] as const
|
||||
|
||||
export function isConnectorCapability(key: CapabilityKey): boolean {
|
||||
|
||||
@@ -33,6 +33,11 @@ export function hasOwnSkatteverketCredentials(): boolean {
|
||||
return !!(process.env.SKATTEVERKET_OAUTH2_CLIENT_ID || process.env.SKATTEVERKET_APIGW_CLIENT_ID)
|
||||
}
|
||||
|
||||
/** True when the instance would use its own Peppol access point (Qvalia partner keys). */
|
||||
export function hasOwnPeppolCredentials(): boolean {
|
||||
return !!(process.env.QVALIA_API_KEY || process.env.QVALIA_PARTNER_REG_NO)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this instance provides the given connector capability from its own
|
||||
* credentials. org_lookup and migration have no own-credentials form: they
|
||||
@@ -41,5 +46,6 @@ export function hasOwnSkatteverketCredentials(): boolean {
|
||||
export function hasOwnCredentialsFor(key: CapabilityKey): boolean {
|
||||
if (key === CAPABILITY.bank_sync) return hasOwnEnableBankingCredentials()
|
||||
if (key === CAPABILITY.skatteverket) return hasOwnSkatteverketCredentials()
|
||||
if (key === CAPABILITY.peppol) return hasOwnPeppolCredentials()
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ describe('registerCompanyForPeppolReceiving', () => {
|
||||
participant: { scheme: '0007', identifier: '5595386219' },
|
||||
businessCard: expect.objectContaining({ companyName: 'Arcim Technology AB', orgNumber: '5595386219' }),
|
||||
documentTypes: PEPPOL_RECEIVING_DOCUMENT_TYPES,
|
||||
tenantReference: 'company-1',
|
||||
})
|
||||
const inserted = calls.find((c) => c.method === 'insert')
|
||||
expect(inserted?.args[0]).toMatchObject({ status: 'pending', participant_identifier: '5595386219', company_id: 'company-1' })
|
||||
|
||||
@@ -36,6 +36,18 @@ describe('Peppol transport registry', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('defaults to the connector transport when that is the only registered adapter and no provider is selected', () => {
|
||||
cleanups.push(registerPeppolTransport(makeTransport('connector')))
|
||||
expect(getPeppolTransportAvailability()).toEqual({ available: true, provider: 'connector' })
|
||||
// An explicit selection still wins, and still refuses an absent adapter.
|
||||
process.env.PEPPOL_TRANSPORT_PROVIDER = 'qvalia'
|
||||
expect(getPeppolTransportAvailability()).toEqual({
|
||||
available: false,
|
||||
provider: null,
|
||||
reason: 'provider_adapter_unavailable',
|
||||
})
|
||||
})
|
||||
|
||||
it('stays truthfully unavailable until a provider is selected', () => {
|
||||
expect(getPeppolTransportAvailability()).toEqual({
|
||||
available: false,
|
||||
|
||||
@@ -205,6 +205,7 @@ export async function registerCompanyForPeppolReceiving(args: {
|
||||
participant: prepared.participant,
|
||||
businessCard: prepared.businessCard,
|
||||
documentTypes: PEPPOL_RECEIVING_DOCUMENT_TYPES,
|
||||
tenantReference: companyId,
|
||||
})
|
||||
const { data, error } = await service
|
||||
.from('peppol_registrations')
|
||||
|
||||
@@ -118,6 +118,12 @@ export interface PeppolRecipientRegistrationInput {
|
||||
businessCard: PeppolBusinessCard
|
||||
documentTypes: PeppolDocumentTypeRegistration[]
|
||||
description?: string | null
|
||||
/**
|
||||
* The registering tenant (company id). Providers that hold one account per
|
||||
* installation ignore it; the connector transport needs it because the
|
||||
* hosted access point enforces a per-company registration quota.
|
||||
*/
|
||||
tenantReference?: string | null
|
||||
}
|
||||
|
||||
export interface PeppolRecipientRegistration {
|
||||
@@ -196,6 +202,13 @@ export interface PeppolTransport {
|
||||
pollDeliveryStatus?(providerSubmissionId: string): Promise<PeppolVerifiedEvent[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider id of the instance-side transport that reaches Arcim's access
|
||||
* point through the hosted connector (lib/invoices/transports/connector.ts).
|
||||
* Declared here so availability resolution needs no import of that module.
|
||||
*/
|
||||
export const CONNECTOR_PEPPOL_PROVIDER = 'connector'
|
||||
|
||||
const transports = new Map<string, PeppolTransport>()
|
||||
|
||||
export function registerPeppolTransport(transport: PeppolTransport): () => void {
|
||||
@@ -226,6 +239,12 @@ export type PeppolTransportAvailability =
|
||||
export function getPeppolTransportAvailability(): PeppolTransportAvailability {
|
||||
const configuredProvider = process.env.PEPPOL_TRANSPORT_PROVIDER?.trim().toLowerCase()
|
||||
if (!configuredProvider) {
|
||||
// A self-hosted instance in connector mode has exactly one possible
|
||||
// provider, so it needs no PEPPOL_TRANSPORT_PROVIDER. Hosted never
|
||||
// registers the connector transport, so this branch is inert there.
|
||||
if (transports.has(CONNECTOR_PEPPOL_PROVIDER)) {
|
||||
return { available: true, provider: CONNECTOR_PEPPOL_PROVIDER }
|
||||
}
|
||||
return { available: false, provider: null, reason: 'provider_selection_required' }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { createConnectorPeppolTransport, CONNECTOR_PROVIDER } from '../connector'
|
||||
import { isPeppolTransportError } from '@/lib/invoices/peppol-transport'
|
||||
|
||||
const upstream = { baseUrl: 'https://app.gnubok.se/api/connect/peppol', key: 'gnubok_ck_test' }
|
||||
const participant = { scheme: '0007', identifier: '5561234567' }
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
|
||||
}
|
||||
|
||||
function build(fetchImpl: typeof fetch) {
|
||||
return createConnectorPeppolTransport(upstream, { fetch: fetchImpl })
|
||||
}
|
||||
|
||||
describe('connector Peppol transport', () => {
|
||||
it('identifies as the connector provider and rewrites provider on everything it returns', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ provider: 'qvalia', providerSubmissionId: 'int-1', idempotencyKey: 'k', tenantReference: 'c1', acceptedAt: 't' }))
|
||||
const transport = build(fetchMock as unknown as typeof fetch)
|
||||
expect(transport.provider).toBe(CONNECTOR_PROVIDER)
|
||||
const receipt = await transport.submit({
|
||||
idempotencyKey: 'k', tenantReference: 'c1', sender: participant, recipient: participant,
|
||||
documentTypeId: 'd', processId: 'p', filename: 'f.xml', contentType: 'application/xml', document: '<x/>', documentSha256: 'a'.repeat(64),
|
||||
})
|
||||
expect(receipt.provider).toBe('connector')
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('https://app.gnubok.se/api/connect/peppol/submit')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.redirect).toBe('error')
|
||||
const headers = init.headers as Record<string, string>
|
||||
expect(headers.Authorization).toBe('Bearer gnubok_ck_test')
|
||||
expect(headers['X-Connector-Company']).toBe('c1')
|
||||
})
|
||||
|
||||
it('sends the tenant as the company header on registration and refuses without one', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ status: 'registered', participant, providerAccountReference: 'accounted-connector', raw: {} }))
|
||||
const transport = build(fetchMock as unknown as typeof fetch)
|
||||
const input = { participant, businessCard: { companyName: 'AB', countryCode: 'SE' }, documentTypes: [{ processId: 'p', documentTypeId: 'd' }] }
|
||||
await expect(transport.registerRecipient!(input)).rejects.toSatisfy((e: unknown) => isPeppolTransportError(e) && !e.retryable)
|
||||
const result = await transport.registerRecipient!({ ...input, tenantReference: 'company-1' })
|
||||
expect(result.participant).toEqual(participant)
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('https://app.gnubok.se/api/connect/peppol/recipient')
|
||||
expect(init.method).toBe('PUT')
|
||||
expect((init.headers as Record<string, string>)['X-Connector-Company']).toBe('company-1')
|
||||
})
|
||||
|
||||
it('unregisters through query parameters and lists inbound via the archive operation', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
.mockResolvedValueOnce(jsonResponse([{ provider: 'qvalia', providerDocumentId: 'doc-1', documentType: 'Invoice', payload: {}, receivedAt: null }]))
|
||||
.mockResolvedValueOnce(jsonResponse({ xml: '<Invoice/>' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ xml: null }))
|
||||
const transport = build(fetchMock as unknown as typeof fetch)
|
||||
await transport.unregisterRecipient!(participant)
|
||||
expect((fetchMock.mock.calls[0] as [string])[0]).toBe('https://app.gnubok.se/api/connect/peppol/recipient?scheme=0007&identifier=5561234567')
|
||||
expect((fetchMock.mock.calls[0] as [string, RequestInit])[1].method).toBe('DELETE')
|
||||
const inbound = await transport.listInboundDocuments!({ documentType: 'Invoice', limit: 5 })
|
||||
expect(inbound).toEqual([{ provider: 'connector', providerDocumentId: 'doc-1', documentType: 'Invoice', payload: {}, receivedAt: null }])
|
||||
expect(await transport.fetchInboundDocumentXml!('doc-1', 'Invoice')).toBe('<Invoice/>')
|
||||
expect(await transport.fetchInboundDocumentXml!('doc-1', 'Invoice')).toBeNull()
|
||||
})
|
||||
|
||||
it('polls status and evidence with the connector provider stamped on and the owning company resolved', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([{ provider: 'qvalia', eventCode: 'status_poll' }]))
|
||||
.mockResolvedValueOnce(jsonResponse([{ provider: 'qvalia', evidenceType: 'qvalia_message_record' }]))
|
||||
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' }])
|
||||
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')
|
||||
}
|
||||
})
|
||||
|
||||
it('turns hosted refusals into PeppolTransportErrors carrying the retryable flag and code', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ error: 'quota', code: 'CONNECTOR_QUOTA_EXCEEDED', retryable: false }, 403))
|
||||
.mockResolvedValueOnce(jsonResponse({ error: 'busy', code: 'CONNECTOR_RATE_LIMITED' }, 429))
|
||||
.mockRejectedValueOnce(new TypeError('fetch failed'))
|
||||
const transport = build(fetchMock as unknown as typeof fetch)
|
||||
await expect(transport.lookupRecipient(participant)).rejects.toSatisfy(
|
||||
(e: unknown) => isPeppolTransportError(e) && e.retryable === false && /CONNECTOR_QUOTA_EXCEEDED/.test(e.detail ?? ''),
|
||||
)
|
||||
await expect(transport.lookupRecipient(participant)).rejects.toSatisfy((e: unknown) => isPeppolTransportError(e) && e.retryable === true)
|
||||
await expect(transport.lookupRecipient(participant)).rejects.toSatisfy((e: unknown) => isPeppolTransportError(e) && e.retryable === true)
|
||||
})
|
||||
|
||||
it('does not verify webhooks: the hosted service owns them', async () => {
|
||||
const transport = build(vi.fn() as unknown as typeof fetch)
|
||||
await expect(transport.verifyWebhook({ headers: new Headers(), rawBody: new Uint8Array() })).rejects.toSatisfy(
|
||||
(e: unknown) => isPeppolTransportError(e) && e.retryable === false,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('transport security', () => {
|
||||
it('refuses a plain-http hosted URL except for loopback', () => {
|
||||
expect(() => createConnectorPeppolTransport({ baseUrl: 'http://connect.example.se/api/connect/peppol', key: 'k' })).toThrow(/https/)
|
||||
expect(() => createConnectorPeppolTransport({ baseUrl: 'http://localhost:3000/api/connect/peppol', key: 'k' })).not.toThrow()
|
||||
})
|
||||
|
||||
it('maps a stalled or failing body read to a retryable transport error', async () => {
|
||||
const stalled = { ok: true, status: 200, text: () => Promise.reject(new Error('body stalled')) } as unknown as Response
|
||||
const transport = build(vi.fn().mockResolvedValue(stalled) as unknown as typeof fetch)
|
||||
await expect(transport.lookupRecipient(participant)).rejects.toSatisfy((e: unknown) => isPeppolTransportError(e) && e.retryable === true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* registerConfiguredPeppolTransports() wires the connector transport only on
|
||||
* a self-hosted instance in connector mode (connector key, no own Qvalia
|
||||
* keys). Hosted keeps its own keys, so nothing changes there. The registry is
|
||||
* module state, so every case gets a fresh module graph.
|
||||
*/
|
||||
|
||||
const ENV = ['GNUBOK_CONNECTOR_KEY', 'GNUBOK_CONNECT_URL', 'QVALIA_API_KEY', 'QVALIA_PARTNER_REG_NO', 'QVALIA_BASE_URL', 'PEPPOL_TRANSPORT_PROVIDER'] as const
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
for (const key of ENV) vi.stubEnv(key, '')
|
||||
})
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
async function load() {
|
||||
const registry = await import('@/lib/invoices/peppol-transport')
|
||||
const transports = await import('@/lib/invoices/transports')
|
||||
return { ...registry, ...transports }
|
||||
}
|
||||
|
||||
describe('registerConfiguredPeppolTransports in connector mode', () => {
|
||||
it('registers nothing without keys of any kind', async () => {
|
||||
const m = await load()
|
||||
expect(m.registerConfiguredPeppolTransports({}).map((t) => t.provider)).toEqual([])
|
||||
expect(m.getPeppolTransportAvailability().available).toBe(false)
|
||||
})
|
||||
|
||||
it('registers the connector transport when a connector key is set and no Qvalia keys are', async () => {
|
||||
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_test')
|
||||
const m = await load()
|
||||
expect(m.registerConfiguredPeppolTransports({}).map((t) => t.provider)).toEqual(['connector'])
|
||||
expect(m.getPeppolTransportAvailability()).toEqual({ available: true, provider: 'connector' })
|
||||
// Idempotent: a second call registers nothing new.
|
||||
expect(m.registerConfiguredPeppolTransports({})).toEqual([])
|
||||
})
|
||||
|
||||
it('prefers own Qvalia keys over the connector (hosted, or a self-host with its own access point)', async () => {
|
||||
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_test')
|
||||
const env = { QVALIA_API_KEY: 'k', QVALIA_PARTNER_REG_NO: '5560000000', QVALIA_BASE_URL: 'https://api-test.qvalia.com' }
|
||||
vi.stubEnv('QVALIA_API_KEY', env.QVALIA_API_KEY)
|
||||
vi.stubEnv('QVALIA_PARTNER_REG_NO', env.QVALIA_PARTNER_REG_NO)
|
||||
const m = await load()
|
||||
expect(m.registerConfiguredPeppolTransports(env).map((t) => t.provider)).toEqual(['qvalia'])
|
||||
expect(m.getPeppolTransport('connector')).toBeNull()
|
||||
// Own keys still need the explicit provider selection, exactly as before.
|
||||
expect(m.getPeppolTransportAvailability()).toEqual({ available: false, provider: null, reason: 'provider_selection_required' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
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 PeppolRecipientRegistrationInput,
|
||||
type PeppolSubmission,
|
||||
type PeppolSubmissionReceipt,
|
||||
type PeppolTransport,
|
||||
type PeppolVerifiedEvent,
|
||||
type PeppolWebhookRequest,
|
||||
} from '@/lib/invoices/peppol-transport'
|
||||
|
||||
/**
|
||||
* Instance-side Peppol transport for connector mode (WS3).
|
||||
*
|
||||
* A self-hosted instance with a connector key and no Qvalia keys of its own
|
||||
* reaches Arcim's contracted access point through the hosted proxy
|
||||
* (`app/api/connect/peppol/*`). The proxy speaks the PeppolTransport
|
||||
* operations, not Qvalia paths, so the instance never learns Arcim's partner
|
||||
* or account numbers and the hosted side can enforce ownership: a key can
|
||||
* only poll, fetch evidence for, or receive documents belonging to
|
||||
* participants and submissions it registered itself.
|
||||
*
|
||||
* Webhooks are not brokered: Qvalia posts to the hosted webhook, which only
|
||||
* knows hosted deliveries. The instance learns outbound status by polling
|
||||
* (`pollDeliveryStatus`, already driven by /api/peppol/outbound/status/cron).
|
||||
*/
|
||||
|
||||
export const CONNECTOR_PROVIDER = CONNECTOR_PEPPOL_PROVIDER
|
||||
|
||||
const FETCH_TIMEOUT_MS = 60_000
|
||||
|
||||
export interface ConnectorTransportDeps {
|
||||
fetch?: typeof fetch
|
||||
/**
|
||||
* Resolve the instance company that made a submission (from
|
||||
* peppol_deliveries) so status and evidence reads carry the company the
|
||||
* hosted side bound the submission to. Optional for tests; the production
|
||||
* factory in transports/index.ts wires it to the instance database.
|
||||
*/
|
||||
companyFor?: (providerSubmissionId: string) => Promise<string | null>
|
||||
/** Same for a receiving registration (from peppol_registrations). */
|
||||
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
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return { error: text.slice(0, 500) }
|
||||
}
|
||||
}
|
||||
|
||||
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(': ')
|
||||
return new PeppolTransportError(`Connector: ${message}`, { retryable, detail: detail || null })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* a hosted dev server), the same rule getConnectorConfig() applies when it
|
||||
* reads GNUBOK_CONNECT_URL.
|
||||
*/
|
||||
function assertTransportSecurity(baseUrl: string): void {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(baseUrl)
|
||||
} catch {
|
||||
throw new PeppolTransportError('Connector: invalid hosted URL', { retryable: false })
|
||||
}
|
||||
const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'
|
||||
if (url.protocol === 'https:' || (url.protocol === 'http:' && loopback)) return
|
||||
throw new PeppolTransportError('Connector: the hosted URL must be https (the connector key is a bearer token)', {
|
||||
retryable: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function createConnectorPeppolTransport(
|
||||
upstream: ConnectorUpstream,
|
||||
deps: ConnectorTransportDeps = {},
|
||||
): PeppolTransport {
|
||||
const fetchImpl = deps.fetch ?? globalThis.fetch
|
||||
const baseUrl = upstream.baseUrl.replace(/\/+$/, '')
|
||||
assertTransportSecurity(baseUrl)
|
||||
|
||||
async function call<T>(
|
||||
method: 'POST' | 'PUT' | 'DELETE',
|
||||
path: string,
|
||||
body: unknown,
|
||||
options: { companyRef?: string | null } = {},
|
||||
): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
||||
// The body is read INSIDE the timeout window: a response whose headers
|
||||
// arrive and whose body then stalls must not hold the caller forever,
|
||||
// and a body-read failure is a transport failure like any other.
|
||||
try {
|
||||
const response = await fetchImpl(`${baseUrl}${path}`, {
|
||||
method,
|
||||
signal: controller.signal,
|
||||
redirect: 'error',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Authorization: `Bearer ${upstream.key}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...(options.companyRef ? { [CONNECTOR_COMPANY_HEADER]: options.companyRef } : {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const json = await readJson(response)
|
||||
if (!response.ok) throw failureFromResponse(response.status, json)
|
||||
return json as T
|
||||
} catch (error) {
|
||||
if (error instanceof PeppolTransportError) throw error
|
||||
throw new PeppolTransportError('Connector: could not reach the hosted service', { retryable: true, cause: error })
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
function withProvider<T extends { provider: string }>(value: T): T {
|
||||
return { ...value, provider: CONNECTOR_PROVIDER }
|
||||
}
|
||||
|
||||
async function lookupRecipient(participant: PeppolParticipant): Promise<PeppolRecipientLookup> {
|
||||
return call<PeppolRecipientLookup>('POST', '/lookup', { participant })
|
||||
}
|
||||
|
||||
async function submit(submission: PeppolSubmission): Promise<PeppolSubmissionReceipt> {
|
||||
const receipt = await call<PeppolSubmissionReceipt>('POST', '/submit', submission, {
|
||||
companyRef: submission.tenantReference,
|
||||
})
|
||||
return withProvider(receipt)
|
||||
}
|
||||
|
||||
async function verifyWebhook(_webhook: PeppolWebhookRequest): Promise<PeppolVerifiedEvent[]> {
|
||||
throw new PeppolTransportError('Connector: delivery webhooks are handled by the hosted service; poll instead', {
|
||||
retryable: false,
|
||||
})
|
||||
}
|
||||
|
||||
// The PeppolTransport read methods carry no tenant, but the hosted side
|
||||
// binds submissions to (key, company). The instance resolves the company
|
||||
// from its own peppol_deliveries row before polling, via deps.companyFor.
|
||||
async function companyFor(providerSubmissionId: string): Promise<string | null> {
|
||||
return deps.companyFor ? deps.companyFor(providerSubmissionId) : null
|
||||
}
|
||||
|
||||
async function retrieveEvidence(providerSubmissionId: string): Promise<PeppolDeliveryEvidence[]> {
|
||||
const items = await call<PeppolDeliveryEvidence[]>('POST', '/evidence', { providerSubmissionId }, {
|
||||
companyRef: await companyFor(providerSubmissionId),
|
||||
})
|
||||
return (items ?? []).map(withProvider)
|
||||
}
|
||||
|
||||
async function pollDeliveryStatus(providerSubmissionId: string): Promise<PeppolVerifiedEvent[]> {
|
||||
const events = await call<PeppolVerifiedEvent[]>('POST', '/status', { providerSubmissionId }, {
|
||||
companyRef: await companyFor(providerSubmissionId),
|
||||
})
|
||||
return (events ?? []).map(withProvider)
|
||||
}
|
||||
|
||||
async function registerRecipient(input: PeppolRecipientRegistrationInput): Promise<PeppolRecipientRegistration> {
|
||||
if (!input.tenantReference) {
|
||||
throw new PeppolTransportError('Connector: a tenant reference is required to register a recipient', {
|
||||
retryable: false,
|
||||
})
|
||||
}
|
||||
const result = await call<PeppolRecipientRegistration>('PUT', '/recipient', input, {
|
||||
companyRef: input.tenantReference,
|
||||
})
|
||||
return { ...result, participant: input.participant }
|
||||
}
|
||||
|
||||
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, {
|
||||
companyRef: deps.companyForParticipant ? await deps.companyForParticipant(participant) : null,
|
||||
})
|
||||
}
|
||||
|
||||
async function listInboundDocuments(options: PeppolInboundListOptions): Promise<PeppolInboundMessage[]> {
|
||||
const items = await call<PeppolInboundMessage[]>('POST', '/inbound/list', options)
|
||||
return (items ?? []).map(withProvider)
|
||||
}
|
||||
|
||||
async function fetchInboundDocumentXml(
|
||||
providerDocumentId: string,
|
||||
documentType: PeppolInboundListOptions['documentType'],
|
||||
): Promise<string | null> {
|
||||
const result = await call<{ xml: string | null }>('POST', '/inbound/xml', { providerDocumentId, documentType })
|
||||
return typeof result?.xml === 'string' && result.xml.trim().startsWith('<') ? result.xml : null
|
||||
}
|
||||
|
||||
return {
|
||||
provider: CONNECTOR_PROVIDER,
|
||||
lookupRecipient,
|
||||
submit,
|
||||
verifyWebhook,
|
||||
retrieveEvidence,
|
||||
pollDeliveryStatus,
|
||||
registerRecipient,
|
||||
unregisterRecipient,
|
||||
listInboundDocuments,
|
||||
fetchInboundDocumentXml,
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,45 @@
|
||||
* configured (for the probe script, for a preview) without being switched on.
|
||||
*/
|
||||
|
||||
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { peppolConnectorMode } from '@/lib/connect/instance/upstreams'
|
||||
import {
|
||||
CONNECTOR_PEPPOL_PROVIDER,
|
||||
getPeppolTransport,
|
||||
registerPeppolTransport,
|
||||
type PeppolParticipant,
|
||||
type PeppolTransport,
|
||||
} from '@/lib/invoices/peppol-transport'
|
||||
import { createConnectorPeppolTransport } from '@/lib/invoices/transports/connector'
|
||||
|
||||
/**
|
||||
* The hosted connector binds submissions and registrations to the instance
|
||||
* company that made them. PeppolTransport's read methods carry no tenant, so
|
||||
* the instance looks the company up in its own tables before each call.
|
||||
*/
|
||||
async function connectorCompanyForSubmission(providerSubmissionId: string): Promise<string | null> {
|
||||
const { data } = await createServiceClientNoCookies()
|
||||
.from('peppol_deliveries')
|
||||
.select('company_id')
|
||||
.eq('provider', CONNECTOR_PEPPOL_PROVIDER)
|
||||
.eq('provider_submission_id', providerSubmissionId)
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
return (data as { company_id: string } | null)?.company_id ?? null
|
||||
}
|
||||
|
||||
async function connectorCompanyForParticipant(participant: PeppolParticipant): Promise<string | null> {
|
||||
const { data } = await createServiceClientNoCookies()
|
||||
.from('peppol_registrations')
|
||||
.select('company_id')
|
||||
.eq('provider', CONNECTOR_PEPPOL_PROVIDER)
|
||||
.eq('participant_scheme', participant.scheme)
|
||||
.eq('participant_identifier', participant.identifier.replace(/\s/g, ''))
|
||||
.in('status', ['pending', 'registered'])
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
return (data as { company_id: string } | null)?.company_id ?? null
|
||||
}
|
||||
import {
|
||||
QVALIA_PROVIDER,
|
||||
createQvaliaTransport,
|
||||
@@ -30,5 +64,20 @@ export function registerConfiguredPeppolTransports(
|
||||
}
|
||||
}
|
||||
|
||||
// Self-hosted instance in connector mode (connector key, no own Qvalia
|
||||
// keys): reach Arcim's access point through the hosted proxy. Hosted has
|
||||
// its own keys, so peppolConnectorMode() is null there and nothing changes.
|
||||
if (!getPeppolTransport(CONNECTOR_PEPPOL_PROVIDER)) {
|
||||
const connector = peppolConnectorMode()
|
||||
if (connector) {
|
||||
const transport = createConnectorPeppolTransport(connector, {
|
||||
companyFor: connectorCompanyForSubmission,
|
||||
companyForParticipant: connectorCompanyForParticipant,
|
||||
})
|
||||
registerPeppolTransport(transport)
|
||||
registered.push(transport)
|
||||
}
|
||||
}
|
||||
|
||||
return registered
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user