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:
co-authored by
Claude Fable 5.1
parent
a08bf51ced
commit
b56da5d6c5
@@ -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 })
|
||||
}
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user