fix(skatteverket): treat AGI kvittens gateway refusals as errors and name the connector operator (#2234)

* fix(skatteverket): treat AGI kvittens gateway refusals as errors and name the connector operator

The AGI kvittens cron bucketed ACCESS_DENIED as apigw_config with a
warn-once suppression because the APIGW client was known to lack the
AGI hantera subscription in Utvecklarportalen (#963). With that
subscription being put in place (#2226), a gateway refusal is a
regression and belongs in the ordinary error path, so the bucket, its
"known configuration gap" comment and the apigwConfig response field
are gone.

The connector-mode gateway-refusal message said "kontakta supporten";
hosted is now itself a Connect installation for the canary companies,
so the message names the connector operator by host instead.

Refs #2226

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159Mi1sTAHfUDZkSysntYz2

* fix(skatteverket): keep the ACCESS_DENIED code in the kvittens cron body

Skeptic finding on PR #2234: the generic error path ran the gateway
refusal through getErrorMessage, whose Swedish keyword heuristic misses
the gateway wording and collapsed it into "Något gick fel". Echo the
machine-readable code instead, as the expired_token and grant_revoked
rows already do; the full guidance stays in the error log.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159Mi1sTAHfUDZkSysntYz2

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-03 17:47:02 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent cb39cded81
commit 80b87c55fc
5 changed files with 70 additions and 111 deletions
@@ -185,6 +185,10 @@ describe('skvRequestWithAuth: connector mode', () => {
expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED')
const { message } = e as SkatteverketAuthError
expect(message).toMatch(/connectorn/)
// Points at the connector's operator by host, not at an ambiguous
// "support": hosted is itself a Connect installation (#2226).
expect(message).toMatch(/operatör \(app\.hosted\.example\)/)
expect(message).not.toMatch(/supporten/)
expect(message).not.toMatch(/SKATTEVERKET_APIGW_CLIENT_ID|Utvecklarportalen/)
}
})
@@ -198,6 +202,8 @@ describe('skvRequestWithAuth: connector mode', () => {
expect((e as SkatteverketAuthError).code).toBe('ACCESS_DENIED')
const { message } = e as SkatteverketAuthError
expect(message).toMatch(/connectorn/)
expect(message).toMatch(/operatör \(app\.hosted\.example\)/)
expect(message).not.toMatch(/supporten/)
expect(message).not.toMatch(/Utvecklarportalen/)
}
})
@@ -317,20 +317,33 @@ function apigwOrScopeMessage(url: string): string {
/**
* Connector-mode variant of the gateway-refusal guidance: the APIGW client
* and its subscriptions belong to the HOSTED broker (Arcim), so telling a
* self-host operator to check SKATTEVERKET_APIGW_CLIENT_ID or visit
* Utvecklarportalen points at knobs their instance does not have. The token
* they hold was also minted by the broker, so the only local actions are
* checking the connector status and contacting support.
* and its subscriptions belong to the connector's OPERATOR (the broker the
* instance routes through), so telling a self-host operator to check
* SKATTEVERKET_APIGW_CLIENT_ID or visit Utvecklarportalen points at knobs
* their instance does not have. The token they hold was also minted by the
* broker, so the only local actions are checking the connector status and
* contacting the operator. The message names the operator by the connector
* host rather than saying "support": hosted is itself a Connect
* installation for the canary companies (#2209), so "support" no longer
* says whose (#2226).
*/
function connectorGatewayMessage(url: string): string {
function connectorGatewayMessage(url: string, connectBaseUrl: string): string {
return (
`Skatteverkets API-gateway nekade anropet till tjänsten "${apiHintFromUrl(url)}" via connectorn. ` +
'Detta är ett konfigurationsproblem på värdtjänstens sida (gateway-prenumeration eller scope), ' +
'inte på din instans: kontakta supporten. Anslutningsläget syns på /api/connector/status.'
`Felet ligger hos connectorns operatör (${connectorHost(connectBaseUrl)}), i gateway-prenumerationen ` +
'eller scope-listan, inte på din instans: kontakta operatören. Anslutningsläget syns på /api/connector/status.'
)
}
/** The connector operator's host, for messages that have to say WHOM to contact. */
function connectorHost(connectBaseUrl: string): string {
try {
return new URL(connectBaseUrl).host
} catch {
return connectBaseUrl
}
}
/**
* A genuine token-scope rejection: the stored access token predates a scope
* the service now requires, and only a fresh consent can widen it.
@@ -567,7 +580,7 @@ export async function skvRequestWithAuth(
// so either verdict re-arms the banner the user just tried to clear.
if (isApigwScopeContractError(text)) {
throw new SkatteverketAuthError(
connector ? connectorGatewayMessage(url) : apigwOrScopeMessage(url),
connector ? connectorGatewayMessage(url, connector.baseUrl) : apigwOrScopeMessage(url),
'ACCESS_DENIED'
)
}
@@ -631,7 +644,7 @@ export async function skvRequestWithAuth(
// Connector mode: the gateway client is the broker's, not the instance's.
throw new SkatteverketAuthError(
connector
? connectorGatewayMessage(url)
? connectorGatewayMessage(url, connector.baseUrl)
: `Skatteverkets API-gateway nekade anropet till "${apiHintFromUrl(url)}". ` +
'Kontrollera att din APIGW-klient (SKATTEVERKET_APIGW_CLIENT_ID) har ' +
'prenumeration på denna tjänst i Utvecklarportalen.',
@@ -649,7 +662,7 @@ export async function skvRequestWithAuth(
// likely fix instead.
if (!text) {
if (connector) {
throw new SkatteverketAuthError(connectorGatewayMessage(url), 'ACCESS_DENIED')
throw new SkatteverketAuthError(connectorGatewayMessage(url, connector.baseUrl), 'ACCESS_DENIED')
}
const apiHint = apiHintFromUrl(url)
throw new SkatteverketAuthError(
@@ -717,7 +730,7 @@ export async function skvRequestWithAuth(
// scope actually exists, so it can never be automatic.
if (isApigwScopeContractError(text)) {
throw new SkatteverketAuthError(
connector ? connectorGatewayMessage(url) : apigwOrScopeMessage(url),
connector ? connectorGatewayMessage(url, connector.baseUrl) : apigwOrScopeMessage(url),
'ACCESS_DENIED'
)
}