fix(providers): name the real Björn Lundén connect failure (integration not activated, not bad credentials) (#2322)

* fix(providers): name the real Björn Lundén connect failure: integration not activated, not bad credentials

Every Björn Lundén connect in prod has failed with "Leverantören avvisade
autentiseringen" (10 consents since June; only BL's own sandbox company ever
received tokens). Live-verified against a real customer User-Key today: BL
answers 403 "<service>:READ is out of allowed scope for service provider
Arcim" on every read endpoint. The key is right and binds the company; the
company has simply never activated our integration, and it cannot until BL
moves the listing out of sandbox. The generic 403 mapping told the user to
re-check what they pasted, which can never help.

- BjornLundenClient: isBjornLundenScopeError / isBjornLundenUnknownKeyError,
  matching the verbatim live 403 and 500 bodies.
- submitProviderToken: 403-with-scope-body -> ProviderTokenInvalidError kind
  'integration-not-activated'; 500/404 -> 'company-key-not-found'; 401 (our
  own client_credentials token refused) rethrows as a generic submit failure
  instead of blaming the pasted key.
- New 422 structured errors BL_INTEGRATION_NOT_ACTIVATED and
  BL_COMPANY_KEY_NOT_FOUND with Swedish/English copy that names the fix
  (activate under Integrationer in Lundify, else SIE) and where the GUID is.
- Wizard copy for BL moved to i18n keys and reordered: activate first, then
  paste the key; the key only works once the integration is activated.
- Tests: route mapping for both kinds, probe classification incl. the
  captured live bodies, registry entries pinned to 422.

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

* fix(providers): drop the unknown-key body matcher, the live BL 500 body is not stable

Verifying through BjornLundenClient against apigateway.blinfo.se, a made-up
User-Key answered 500 with a Spring BeanCreationException for
databaseConnector, not the null getCurrentUser() message captured earlier.
The unknown-key verdict already keys on the status alone in
submitProviderToken; keep only the 403 scope matcher, whose body IS stable.

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

---------

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-05 17:20:08 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 41a5728ca7
commit 473b1fd2eb
12 changed files with 264 additions and 12 deletions
@@ -39,6 +39,18 @@ describe('structured-errors registry', () => {
}
})
it('has 422 entries for the Björn Lundén connect verdicts (valid key, missing activation; unknown key)', () => {
for (const code of ['BL_INTEGRATION_NOT_ACTIVATED', 'BL_COMPANY_KEY_NOT_FOUND']) {
const entry = getErrorEntry(code)
expect(entry, `missing entry for ${code}`).toBeDefined()
// 422, never 401: the caller's own session is fine and a 401 can trip
// client-side auth interceptors into logging the user out.
expect(entry?.httpStatus).toBe(422)
expect(entry?.message_sv).toBeTruthy()
expect(entry?.message_en).toBeTruthy()
}
})
it('has an entry for every code the link-transaction service can emit', () => {
for (const code of [
'LINK_TX_JE_NOT_FOUND',
+21
View File
@@ -2492,6 +2492,27 @@ const PROVIDER_MIGRATION: Record<string, StructuredErrorEntry> = {
message_en:
'Bokio could not find the company. Check the company ID and that the integration token was created for the same company.',
},
BL_INTEGRATION_NOT_ACTIVATED: {
// 422, same reasoning as PROVIDER_TOKEN_INVALID. The User-Key opened a
// real company, but that company has granted our service provider no
// scopes (BL: "out of allowed scope for service provider"). Nothing the
// user re-pastes can fix this: the integration must be activated on the
// BL side, and until BL has released it for the company it cannot be.
httpStatus: 422,
message_sv:
'Företagsnyckeln stämmer, men företaget har inte aktiverat Accounted som integration i Björn Lundén. Aktivera integrationen under Integrationer i Lundify eller BL Administration och försök igen. Saknas Accounted i listan är integrationen inte släppt för ditt företag ännu: importera via SIE-fil så länge.',
message_en:
'The company key is valid, but the company has not activated Accounted as an integration in Björn Lundén. Activate the integration under Integrations in Lundify or BL Administration and try again. If Accounted is missing from the list, the integration has not been released for your company yet: import via a SIE file for now.',
},
BL_COMPANY_KEY_NOT_FOUND: {
// 422: BL could not bind any company to the pasted User-Key (typo,
// truncated GUID, key from a different BL environment).
httpStatus: 422,
message_sv:
'Björn Lundén hittade inget företag för den här företagsnyckeln. Kontrollera att hela nyckeln (GUID) är kopierad från Integrationer → kugghjulet i Lundify och försök igen.',
message_en:
'Björn Lundén found no company for this company key. Check that the whole key (GUID) was copied from Integrations → the gear icon in Lundify and try again.',
},
PROVIDER_COMPANY_MISMATCH: {
// 422, same reasoning as PROVIDER_TOKEN_INVALID: the credentials are valid,
// but they open a DIFFERENT legal entity than the one being imported into.
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { BjornLundenApiError, isBjornLundenScopeError } from '@/lib/providers/bjornlunden/client'
// Verbatim body captured from apigateway.blinfo.se on 2026-09-05 for a real
// customer User-Key whose company never activated the integration. The helper
// must keep matching this exact shape.
const SCOPE_BODY =
'{"headers":{},"body":{"status":"FORBIDDEN","timestamp":"2026-09-05 03:48:38","message":"Calls to details:READ is out of allowed scope for service provider Arcim ","debugMessage":"Calls to details:READ is out of allowed scope for service provider Arcim ","causeChain":[{"name":"ChainBreakingAuthException","message":"Calls to details:READ is out of allowed scope for service provider Arcim "}]},"statusCode":"FORBIDDEN","statusCodeValue":403}'
describe('isBjornLundenScopeError', () => {
it('recognises the live 403 "out of allowed scope" body', () => {
const err = new BjornLundenApiError('Björn Lunden API error: 403 Forbidden', 403, SCOPE_BODY)
expect(isBjornLundenScopeError(err)).toBe(true)
})
it('is false for a 403 without the scope wording, for other statuses, and for foreign errors', () => {
expect(isBjornLundenScopeError(new BjornLundenApiError('403', 403, '{"message":"Forbidden"}'))).toBe(false)
expect(isBjornLundenScopeError(new BjornLundenApiError('403', 403))).toBe(false)
expect(isBjornLundenScopeError(new BjornLundenApiError('500', 500, SCOPE_BODY))).toBe(false)
expect(isBjornLundenScopeError(new Error('out of allowed scope'))).toBe(false)
})
})
+25
View File
@@ -19,6 +19,31 @@ export class BjornLundenApiError extends Error {
}
}
/**
* True when BL answered 403 because the company behind the User-Key has not
* activated our integration: the service provider holds no scopes for that
* company. Live-verified 2026-09-05 against a real customer key:
*
* {"body":{"status":"FORBIDDEN","message":"Calls to details:READ is out of
* allowed scope for service provider Arcim "}, "statusCodeValue":403}
*
* The key itself is right, so this must never be reported as "check what
* you pasted": the fix is on the BL side (activate the integration).
*
* An UNKNOWN key is a different signal: BL fails to bind the company database
* and answers 500. That body is not stable (observed both a null
* ServiceInfo.getCurrentUser() message and a Spring BeanCreationException for
* databaseConnector), so callers key the unknown-key verdict on the status
* alone; only the 403 case has a body worth matching.
*/
export function isBjornLundenScopeError(error: unknown): boolean {
return (
error instanceof BjornLundenApiError &&
error.statusCode === 403 &&
/out of allowed scope/i.test(error.body ?? '')
)
}
function isRetryableError(error: unknown): boolean {
if (isTimeoutError(error)) return true;
if (error instanceof BjornLundenApiError) {