feat(api): expose bank-connection freshness in MCP and v1 REST (#2124)

* feat(api): expose bank-connection freshness in MCP and v1 REST

gnubok_connect_bank now returns last_synced_at, consent_expires and
error_message per connection, and its instructions tell the agent to
flag stale or expiring connections. New read-only endpoint
GET /api/v1/companies/{companyId}/bank-connections exposes the same
fields to API-key integrations (scope companies:read).

Background: a user's PSD2 feed died silently in July; bookkeeping
looked complete while three weeks stale, and nothing on the API/MCP
surface could reveal it. Sync stays cron-driven; an agent-triggerable
sync was considered and deferred (see DECISIONS.md).

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

* fix(api): address skeptic findings on bank-connection freshness

- Map the bank-connections group into skills/accounted-api (apiskill:check
  crashed on the unmapped group; regenerated skill files included).
- Gate the v1 route on the bank_sync capability, mirroring the MCP twin:
  a lapsed entitlement now answers with a capability error instead of
  status=active with a frozen last_synced_at.
- Reword MCP instructions + v1 pitfalls: null last_synced_at right after
  connecting is normal, staleness threshold aligned to the UI's 36 hours,
  and re-authorisation is only advised for expired/error/consent-out, not
  for stale-but-active connections (lapsed subscription or deselected
  accounts are the usual causes there).

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

* fix(mcp): keep gnubok_connect_bank schema under the tools/list token ceiling

The enriched outputSchema plus the worked examples that landed on main
(#2100) pushed the projected tools/list payload 20 tokens over the
61.6K context-budget ceiling. Drop the per-property descriptions from
the new freshness fields; the instructions string (runtime output, not
catalog payload) already explains them.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-01 20:54:55 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent a08bf51ced
commit b56da5d6c5
11 changed files with 453 additions and 14 deletions
@@ -0,0 +1,183 @@
/**
* Tests for GET /api/v1/companies/{companyId}/bank-connections.
*
* Exercises the real withApiV1 wrapper (auth, scope, company membership)
* with the Supabase client mocked.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
const { requireCapabilityMock } = vi.hoisted(() => ({ requireCapabilityMock: vi.fn() }))
vi.mock('@/lib/entitlements/has-capability', () => ({
requireCapability: requireCapabilityMock,
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GET } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
const queues = new Map<string, MockResult[]>()
for (const [t, val] of Object.entries(byTable)) queues.set(t, Array.isArray(val) ? [...val] : [val])
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const BASE = `http://localhost/api/v1/companies/${COMPANY_ID}/bank-connections`
const CONNECTION_ROW = {
id: '11111111-1111-4111-8111-111111111111',
bank_name: 'Swedbank',
status: 'active',
created_at: '2026-08-01T00:00:00Z',
last_synced_at: '2026-08-31T05:04:12Z',
consent_expires: '2026-11-01T00:00:00Z',
error_message: null,
}
function req(): Request {
return new Request(BASE, {
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
})
}
function authOk(scopes: string[]) {
mockValidate.mockResolvedValue({
valid: true,
userId: 'user-1',
keyId: 'key-1',
keyName: 'Test key',
scopes,
mode: 'live',
})
}
const params = { params: Promise.resolve({ companyId: COMPANY_ID }) }
describe('v1 bank-connections list', () => {
beforeEach(() => {
vi.clearAllMocks()
requireCapabilityMock.mockResolvedValue(null)
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { role: 'owner' } },
bank_connections: { data: [CONNECTION_ROW] },
}),
)
})
it('401 without a valid key', async () => {
mockValidate.mockResolvedValue({ valid: false, error: 'invalid' })
const res = await GET(req(), params)
expect(res.status).toBe(401)
})
it('403 INSUFFICIENT_SCOPE when the key lacks companies:read', async () => {
authOk(['transactions:read'])
const res = await GET(req(), params)
expect(res.status).toBe(403)
})
it('returns the capability-blocked response when bank_sync is not entitled', async () => {
authOk(['companies:read'])
requireCapabilityMock.mockResolvedValue(
Response.json({ error: { code: 'CAPABILITY_BLOCKED' } }, { status: 403 }),
)
const res = await GET(req(), params)
expect(res.status).toBe(403)
expect(requireCapabilityMock).toHaveBeenCalledWith(expect.anything(), COMPANY_ID, 'bank_sync')
})
it('404 when the key user is not a member of the company', async () => {
authOk(['companies:read'])
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: null },
}),
)
const res = await GET(req(), params)
expect(res.status).toBe(404)
})
it('returns connections with freshness fields under qualified names', async () => {
authOk(['companies:read'])
const res = await GET(req(), params)
expect(res.status).toBe(200)
const body = (await res.json()) as {
data: { bank_connections: Array<Record<string, unknown>> }
meta: { request_id: string }
}
expect(body.data.bank_connections).toEqual([
{
connection_id: '11111111-1111-4111-8111-111111111111',
bank: 'Swedbank',
status: 'active',
since: '2026-08-01T00:00:00Z',
last_synced_at: '2026-08-31T05:04:12Z',
consent_expires: '2026-11-01T00:00:00Z',
error_message: null,
},
])
expect(body.meta.request_id).toMatch(/^req_/)
})
it('returns an empty list when the company has no connections', async () => {
authOk(['companies:read'])
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { role: 'owner' } },
bank_connections: { data: [] },
}),
)
const res = await GET(req(), params)
expect(res.status).toBe(200)
const body = (await res.json()) as { data: { bank_connections: unknown[] } }
expect(body.data.bank_connections).toEqual([])
})
it('maps a database error into the v1 error envelope', async () => {
authOk(['companies:read'])
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { role: 'owner' } },
bank_connections: { data: null, error: { message: 'boom' } },
}),
)
const res = await GET(req(), params)
expect(res.status).toBeGreaterThanOrEqual(500)
const body = (await res.json()) as { error: { code: string } }
expect(body.error.code).toBeTruthy()
})
})
@@ -0,0 +1,118 @@
/**
* GET /api/v1/companies/{companyId}/bank-connections
*
* List PSD2 bank connections with freshness metadata: last_synced_at,
* consent_expires, status, error_message. This is the API-key surface's
* answer to "is my bank data current?": a connection whose last_synced_at
* is stale, or whose status is expired/error, means the transaction and
* balance data downstream is old even though it looks complete.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse } from '@/lib/api/v1/errors'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
const BankConnection = z.object({
connection_id: z.string(),
bank: z.string().nullable(),
status: z.enum(['pending', 'pending_selection', 'active', 'expired', 'error']),
since: z.string(),
last_synced_at: z.string().nullable(),
consent_expires: z.string().nullable(),
error_message: z.string().nullable(),
})
const BankConnectionsResponse = dataEnvelope(
z.object({ bank_connections: z.array(BankConnection) }),
)
registerEndpoint({
operation: 'bank-connections.list',
method: 'GET',
path: '/api/v1/companies/:companyId/bank-connections',
summary: 'List PSD2 bank connections with sync freshness and consent expiry.',
description:
'Returns every bank connection for the company with its status, last successful sync (last_synced_at), consent expiry (consent_expires) and any user-facing error message. Connections sync automatically once a day server-side; this endpoint tells you whether that is still happening.',
useWhen:
'You need to verify bank data is current before building on it (liquidity, reconciliation, reports), or to detect a dead connection that needs BankID re-authorisation.',
doNotUseFor:
'Fetching transactions (use /transactions) or account balances (use /cash-accounts). Triggering a sync: not available on this surface; syncing is automatic.',
pitfalls: [
'last_synced_at is null until the first sync completes (about a minute after connecting); it does NOT mean the connection is broken.',
'A connection can hold status=active with a stale last_synced_at (older than ~36 hours): treat the data as suspect, but do NOT assume re-authorisation fixes it. Common causes are a lapsed subscription (this endpoint then answers with a capability error) or every account deselected in settings.',
'status=expired means the PSD2 consent is dead: only the user can fix it, with BankID in a browser.',
'error_message is Swedish and user-facing: show it verbatim rather than translating.',
],
example: {
response: {
data: {
bank_connections: [
{
connection_id: '4f6c…',
bank: 'Swedbank',
status: 'active',
since: '2026-08-01T00:00:00Z',
last_synced_at: '2026-08-31T05:04:12Z',
consent_expires: '2026-11-01T00:00:00Z',
error_message: null,
},
],
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'companies:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: BankConnectionsResponse },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'bank-connections.list',
async (_request, ctx) => {
// Mirror the MCP twin (gnubok_connect_bank is gated on bank_sync via
// MCP_TOOL_CAPABILITY_MAP): without this, a company whose entitlement
// lapsed reads status=active with a frozen last_synced_at and the docs
// steer the agent toward a needless BankID re-auth. The capability error
// names the real cause instead.
const capBlocked = await requireCapability(ctx.supabase, ctx.companyId!, CAPABILITY.bank_sync)
if (capBlocked) return capBlocked
try {
const { data, error } = await ctx.supabase
.from('bank_connections')
.select('id, bank_name, status, created_at, last_synced_at, consent_expires, error_message')
.eq('company_id', ctx.companyId!)
.in('status', ['pending', 'pending_selection', 'active', 'expired', 'error'])
.order('created_at', { ascending: false })
if (error) throw error
type Row = {
id: string
bank_name: string | null
status: string
created_at: string
last_synced_at: string | null
consent_expires: string | null
error_message: string | null
}
const bank_connections = ((data ?? []) as Row[]).map((c) => ({
connection_id: c.id,
bank: c.bank_name,
status: c.status,
since: c.created_at,
last_synced_at: c.last_synced_at,
consent_expires: c.consent_expires,
error_message: c.error_message,
}))
return ok({ bank_connections }, { requestId: ctx.requestId })
} catch (error) {
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
},
)