feat(connect): Enable Banking client routes through the hosted proxy in connector mode (PR6b-1) (#2094)
* feat(connect): route the Enable Banking client through the hosted proxy in connector mode PR6b-1 of the instance-side client wiring. Until now bankConnectorMode() had no consumer but the status label; this makes a self-host with a connector key and no own EB credentials actually reach Enable Banking through the hosted bank proxy. - api-client authenticatedFetch: in connector mode swap the base URL to the proxy and send the connector key as a Bearer token. The EB JWT signer (getAuthorizationHeader) is never called: the instance holds no private key. On hosted and on own-credentials self-hosts the direct path is byte-identical. - startAuthorization forwards X-Connector-Company so the proxy can meter the per-company connection quota; index.ts passes companyId at both connect sites. - createSession forwards the signed connector_state so the proxy binds the /sessions exchange to the pending ledger row (single-use, race-safe). - callback route reads connector_state from the query (echoed by the hosted callback) and threads it through finalizeConnection. Tests: connector-mode base/auth/company-header/connector_state assertions in api-client, direct-path and own-credentials byte-identity, and the callback threading both connector and direct paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(connect): gate X-Connector-Company on connector mode, not on companyId Skeptic regression finding: index.ts passes companyId to startAuthorization unconditionally, and the header was attached whenever companyId was truthy. On hosted and on own-credentials self-hosts companyId is always set, so every direct POST /auth to the real Enable Banking API carried the tenant's internal company UUID: a needless behavior change on the production path and an identifier leak to a third-party PSD2 processor (the "byte-identical direct path" claim was false). Gate the header on bankConnectorMode() so it is sent only when the request actually goes to the hosted proxy. Adds a direct-path test asserting the header is absent even when companyId is passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e113e9c099
commit
05dce83a2b
@@ -19,6 +19,8 @@ import {
|
||||
getAllTransactionsWithRaw,
|
||||
convertTransaction,
|
||||
probeSessionHealth,
|
||||
startAuthorization,
|
||||
createSession,
|
||||
type Transaction,
|
||||
} from '../api-client'
|
||||
|
||||
@@ -571,3 +573,100 @@ describe('probeSessionHealth', () => {
|
||||
expect(await probeSessionHealth('s1')).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connector mode (self-host routes upstream through the hosted bank proxy)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('connector mode', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
const okJson = (body: unknown) =>
|
||||
new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// A self-host with a connector key and no own EB credentials. The
|
||||
// own-credentials env vars must stay unset for bankConnectorMode() to
|
||||
// engage (key present AND no own credentials).
|
||||
vi.stubEnv('GNUBOK_CONNECTOR_KEY', 'gnubok_ck_testsecret')
|
||||
vi.stubEnv('GNUBOK_CONNECT_URL', 'https://app.test.example')
|
||||
vi.stubEnv('ENABLE_BANKING_PRIVATE_KEY', '')
|
||||
vi.stubEnv('ENABLE_BANKING_PRIVATE_KEY_PRODUCTION', '')
|
||||
vi.stubEnv('ENABLE_BANKING_APP_ID', '')
|
||||
vi.stubEnv('ENABLE_BANKING_APP_ID_PRODUCTION', '')
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
const lastCall = () => {
|
||||
const call = fetchSpy.mock.calls[fetchSpy.mock.calls.length - 1]
|
||||
const url = String(call[0])
|
||||
const init = (call[1] ?? {}) as RequestInit
|
||||
const headers = (init.headers ?? {}) as Record<string, string>
|
||||
return { url, init, headers }
|
||||
}
|
||||
|
||||
it('routes reads through the proxy with the connector key, never the EB JWT', async () => {
|
||||
fetchSpy.mockResolvedValue(okJson({ aspsps: [] }))
|
||||
await getASPSPs('SE')
|
||||
const { url, headers } = lastCall()
|
||||
expect(url).toContain('https://app.test.example/api/connect/bank/aspsps')
|
||||
expect(headers['Authorization']).toBe('Bearer gnubok_ck_testsecret')
|
||||
expect(headers['Authorization']).not.toContain('jwt')
|
||||
// The JWT signer must not run: the instance holds no EB private key.
|
||||
expect(mockGenerateJWT).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends X-Connector-Company on /auth so the proxy can meter the company quota', async () => {
|
||||
fetchSpy.mockResolvedValue(okJson({ url: 'https://bank/auth', authorization_id: 'a1' }))
|
||||
await startAuthorization('Bank', 'SE', 'https://instance.test/callback', 'oauth-state-1', 'business', undefined, 'company-42')
|
||||
const { url, headers, init } = lastCall()
|
||||
expect(url).toBe('https://app.test.example/api/connect/bank/auth')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(headers['X-Connector-Company']).toBe('company-42')
|
||||
expect(headers['Authorization']).toBe('Bearer gnubok_ck_testsecret')
|
||||
})
|
||||
|
||||
it('binds /sessions to the signed connector_state when one is passed', async () => {
|
||||
fetchSpy.mockResolvedValue(okJson({ session_id: 's1', accounts: [], access: { valid_until: '2027-01-01' } }))
|
||||
await createSession('auth-code', 'signed-connector-state')
|
||||
const { url, init } = lastCall()
|
||||
expect(url).toBe('https://app.test.example/api/connect/bank/sessions')
|
||||
expect(JSON.parse(String(init.body))).toEqual({ code: 'auth-code', connector_state: 'signed-connector-state' })
|
||||
})
|
||||
|
||||
it('omits connector_state from /sessions when none is passed', async () => {
|
||||
fetchSpy.mockResolvedValue(okJson({ session_id: 's1', accounts: [], access: { valid_until: '2027-01-01' } }))
|
||||
await createSession('auth-code')
|
||||
const { init } = lastCall()
|
||||
expect(JSON.parse(String(init.body))).toEqual({ code: 'auth-code' })
|
||||
})
|
||||
|
||||
it('does not engage when the instance has its own EB credentials (own-credentials seam)', async () => {
|
||||
vi.stubEnv('ENABLE_BANKING_APP_ID', 'own-app-id')
|
||||
fetchSpy.mockResolvedValue(okJson({ aspsps: [] }))
|
||||
await getASPSPs('SE')
|
||||
const { url, headers } = lastCall()
|
||||
// Direct EB base (captured at import), never the connector proxy.
|
||||
expect(url).not.toContain('/api/connect/bank')
|
||||
expect(url).toContain('enablebanking.com')
|
||||
expect(headers['Authorization']).toBe('Bearer test-jwt-token')
|
||||
})
|
||||
|
||||
it('never sends X-Connector-Company on the direct path, even with companyId passed', async () => {
|
||||
// Own EB credentials → direct path. companyId is always set on hosted /auth,
|
||||
// so the header must be gated on connector mode, not on companyId: leaking
|
||||
// the internal company UUID to the real Enable Banking API is a regression.
|
||||
vi.stubEnv('ENABLE_BANKING_APP_ID', 'own-app-id')
|
||||
fetchSpy.mockResolvedValue(okJson({ url: 'https://bank/auth', authorization_id: 'a1' }))
|
||||
await startAuthorization('Bank', 'SE', 'https://instance.test/callback', 'oauth-state-1', 'business', undefined, 'company-42')
|
||||
const { url, headers } = lastCall()
|
||||
expect(url).toContain('enablebanking.com')
|
||||
expect(headers['X-Connector-Company']).toBeUndefined()
|
||||
expect(headers['Authorization']).toBe('Bearer test-jwt-token')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user