feat(connect): Enable Banking proxy for self-hosted instances, with a secret-free ownership ledger and a global rate budget (#1751)
* feat(entitlements): partition the self-host bypass so connector capabilities fall through to grants; capability_grants.source accepts 'connector' Sovereign plan WS3 PR3: ships dark, nothing changes for hosted. - lib/entitlements/keys.ts: CONNECTOR_CAPABILITIES = bank_sync, skatteverket, org_lookup, migration (services Accounted operates that a self-hosted instance cannot provide itself) + isConnectorCapability(). Separate from PAID_CAPABILITIES and outside the trial-seed trigger on purpose: a hosted company can never hold a connector grant. - lib/entitlements/has-capability.ts: isPaywallBypassed() -> isBypassedFor(key). Hosted: byte-identical (dev / DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, else the grant lookup). Self-host: local capabilities always on (FORCE_PAYWALL included, as the existing test demands); connector capabilities behave like hosted, i.e. dev bypass, FORCE_PAYWALL, else the grant lookup where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host: local paid keys + active connector keys, state 'paid' with an active connector grant else 'none' (never the hosted trial copy). - Migration 20260820122000: capability_grants.source CHECK gains 'connector', found through pg_constraint (the CHECK was declared inline and auto-named; Postgres stores IN as = ANY, matched accordingly). pg-real test: connector accepted, unknown source rejected, upsert on the (scope, key, source) identity, trial seed writes no connector rows. - Tests: self-hosted connector matrix (local all-on without DB, connector gated by grant/expiry, dev bypass all-on, FORCE_PAYWALL gates connector keys only, bulk resolution, entitlements shape); two pre-existing tests that asserted the old "self-host holds connector keys" contract updated to the new one. Verified: full unit suite green, pg-real suite for lib/entitlements green against a local supabase/postgres with every migration applied, lint ratchet, guards. Deferred to the instance-wiring PR: adding the connector extensions to the self-host Docker preset (dead-end upsells until a key can be issued). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(entitlements): fold the self-host branch into the existing grants query One .or(scopeFilter), not two: the duplicated helper pushed the no-phantom-columns unresolvable-expression count to 380/379. Behaviour is unchanged; the self-host matrix tests still pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(connect): hosted connector-key registry + validate RPC + entitlements endpoint; instance sync writes connector grants hourly Sovereign plan WS3 PR4 ("key infra enabling manual sales"), stacked on the entitlement partition (#1747). Nothing is purchasable yet; this is the plumbing both ends need before the first manually issued key. Hosted side: - Migration 20260820123000: connector_keys (SHA-256 key_hash, prefix, org_number, pinned instance_url, scopes, status, Stripe ids, current_period_end, per-minute rate limit, active_company_count, last_seen/synced) and connector_usage_events (per-request metering, separate from metered_events whose company_id references hosted companies). RLS on, NO policies: service role only. RPC validate_and_increment_connector_key copies the api_keys pattern (FOR UPDATE, minute window, suspended reported not counted, revoked = no row) and is REVOKEd from PUBLIC/anon/authenticated, GRANTed to service_role. pg-real test covers validate/count, unknown+revoked, suspended, rate limit, execute privileges per role, RLS invisibility, usage cascade. - lib/connect/contract.ts (shared wire types), lib/connect/hosted/keys.ts (generate/hash/validate -> 401/403/429 mapping), with-connector-auth.ts (Bearer or X-Connector-Key, one usage row per request, 500 envelope on handler throw), /api/connect/entitlements GET + POST (records active_company_count, pins instance_url on first report, never moves a pinned one), scripts/issue-connector-key.ts (dry run unless --confirm, prints the key once + the .env lines). Instance side: - lib/connect/instance/config.ts (GNUBOK_CONNECTOR_KEY, GNUBOK_CONNECT_URL default https://app.gnubok.se), sync.ts: reports the active company count and writes source='connector' grants for every company x covered scope, expires_at = min(now+72h, period_end+3d); 401/403 or a non-active status deletes them (freeze-and-retain); network/5xx/429 leave them alone. /api/connector/sync/cron (hourly) runs it; not_configured without a key. - Crontab generator gains EXTRA_JOBS (variant-only jobs not in vercel.json, with reasons) + drift tests; docker/crontab.self-hosted regenerated with the hourly sync. Docs (SELF-HOSTING connector section, env templates), DECISIONS. Tests: 52 new unit tests (keys, auth wrapper, route, config, sync outcomes and grant arithmetic, cron route, crontab EXTRA_JOBS) + 7 pg-real tests run locally against supabase/postgres with every migration applied. no-phantom-columns ceiling +1 with a reason (the bulk grant upsert). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(connect): Enable Banking proxy for self-hosted instances, with a secret-free ownership ledger and a global rate budget Sovereign plan WS3 PR5a, stacked on the connector-key infra (#1748). A self-hosted instance with a `bank_sync`-scoped connector key can now connect a bank through Arcim's PSD2 credentials; the bank session id and all transaction data stay in the instance's own database (founder decision: tokens on the instance, proxy stateless). - Migration 20260820124000: `connector_connections` (secret-free ledger: sha256 of the EB session id + account uids, service-role only), `connector_upstream_counters` + RPC `connector_reserve_upstream` (global budget under EB Annex 1 §5's 300/min, shared with hosted), and `connector_keys.limits` jsonb; validate RPC v2 returns limits. All RPCs REVOKEd from PUBLIC/anon/authenticated, GRANTed service_role. pg-real covers all of it. - EB JWT minting moved to lib/connect/upstreams/enable-banking-jwt.ts (core must not import @/extensions/); the extension re-exports it, tests unchanged. - lib/connect/hosted/{state,ledger,upstream-budget}.ts: HMAC-signed connector state (15-min TTL) so the consent redirect can use OUR registered EB callback and bounce back to the instance, no per-instance redirect URI at EB; the callback route gains that connector branch. - app/api/connect/bank/[...path]: path allowlist (aspsps, auth, sessions, accounts/{uid}/{balances,transactions}), never open passthrough. POST /auth enforces the per-company connection quota + rewrites redirect/state; reads/deletes verify ledger ownership; every upstream call takes the global budget (429 + Retry-After when exhausted). - issue-connector-key.ts: scopes default bank_sync,skatteverket (TIC out of v1), --bank/skv-connections-per-company + --sync-min-interval. - Docs (SELF-HOSTING: bank connector live), DECISIONS. Verified: 52 connect unit tests + 13 pg-real (run locally against supabase/postgres with all migrations) + EB extension suite (225, jwt relocation intact); full unit suite 15 979 green; tsc, guards, lint clean. Not in this PR: SKV broker (PR5b) and instance wiring (PR6). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(connect): update ledger pg test to re-versioned migration 20260831200000 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(connect): redact opaque path segments before usage metering; correct stale RPC-source comment GET/DELETE /sessions/{id} and /accounts/{uid}/... carry the raw EB session id / account uid in the pathname; metering persisted it in cleartext next to the ledger that stores only sha256(handle). Opaque segments (UUID, long hex, long base64url) now become ':id' before the connector_usage_events insert. Migration comment now cites the real prior RPC source (20260831190000). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(connect): percent-encoded path segments count as opaque in metering redaction Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(connect): PR #1751 review batch: https-only EB URL, body-covering timeout, quota reservation, delete-after-success, doc fix Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb * fix(connect): bind the /sessions code exchange to its verified pending state; ceiling +1 Verified state signature, key/service match, and an existing pending row now precede the EB exchange; a concurrently consumed state closes the just-minted upstream session and 409s. no-phantom-columns ceiling 391 for countHeldConnections' computed .or() timestamp filter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzNkSsR18pLFitJdYn8QEb --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Emil <emilmattsson14@gmail.com>
This commit is contained in:
@@ -1142,6 +1142,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-20] Unmatched bank rows that no voucher on the account could settle (direction-compatible and equal to the ore) get "Bokfor" linking to /transactions?highlight=<id> instead of a match picker. They are unbooked affarshandelser, not reconciliation work, and the picker held nothing for them. The rule is deliberately strict: a false negative offers booking on a pairable row (a legitimate outcome), a false positive sends the user into an empty picker.
|
||||
[2026-08-20] Entitlement partition for the sovereign self-host (plan WS3 PR3, ships dark): isPaywallBypassed() became isBypassedFor(key). Hosted behaviour is byte-identical (dev/DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, otherwise the grant lookup). On a self-host every LOCAL capability stays always-on, FORCE_PAYWALL included (an AGPL operator's own instance is never gated on what it runs itself, which is also why the existing "FORCE_PAYWALL never overrides self-hosted" test still holds), and only the four CONNECTOR_CAPABILITIES (bank_sync, skatteverket, org_lookup, migration: services Accounted operates) fall through to the grant lookup, where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host reports state 'paid' when a connector grant is active and 'none' otherwise, never 'trial_expired' (that copy talks about a hosted trial). CONNECTOR_CAPABILITIES is deliberately separate from PAID_CAPABILITIES and outside the trial-seed trigger, so a hosted company can never hold a connector grant. The capability_grants.source CHECK gains 'connector' by looking the inline auto-named constraint up through pg_constraint. Adding the connector extensions to the self-host Docker preset was deferred to the instance-wiring PR: until a connector key can actually be issued, shipping those extensions in the image would only show dead-end upsells.
|
||||
[2026-08-20] Connector-key infrastructure (plan WS3 PR4) ships the hosted registry + the instance sync, nothing a customer can buy yet: connector_keys / connector_usage_events are service-role-only tables (RLS on, no policies) with an atomic validate_and_increment_connector_key RPC that copies the api_keys pattern (SHA-256 at rest, FOR UPDATE row lock, per-minute window) and is REVOKEd from PUBLIC, anon and authenticated before anyone can call it (the SECURITY DEFINER exposure lesson applied up front); /api/connect/entitlements behind withConnectorAuth (Bearer or X-Connector-Key, 401/403/429, one usage row per request); keys issued by scripts/issue-connector-key.ts (dry run unless --confirm, prints the key once). The instance side writes source='connector' grants expiring at min(now+72h, period_end+3d) on every hourly sync, deletes them on 401/403 or a non-active status, and leaves them alone on network/5xx errors: the grant rows ARE the offline cache, no new cache code. The hourly job lives only in docker/crontab.self-hosted through a new EXTRA_JOBS table in the crontab generator (with its own drift tests), because vercel.json is the hosted schedule and hosted has no connector key. connector_usage_events is a separate table because metered_events.company_id references hosted companies and a connector key belongs to an instance, not a company here. Deferred: the proxy routes (bank/skv/org/migration: founder legal check with Enable Banking/SKV/TIC is the launch blocker), a connect.gnubok.se host rewrite (the instance calls app.gnubok.se/api/connect directly; a dedicated host is a later DNS decision), the self-host Docker preset change and the settings row.
|
||||
[2026-08-20] Connector bank proxy (plan WS3 PR5a): app/api/connect/bank/[...path] brokers Enable Banking for self-hosted instances with tokens staying on the instance (founder decision) and the proxy stateless apart from a secret-free connection ledger. Design that keeps EB Annex 1 §3/§7 satisfied: the instance never holds the EB JWT (minting moved to lib/connect/upstreams/enable-banking-jwt.ts so core does not import @/extensions/; the extension re-exports it); the consent redirect goes to OUR already-registered EB callback, which detects an HMAC-signed connector state (lib/connect/hosted/state.ts, 15-min TTL, CONNECTOR_STATE_SECRET or a one-way derivation of the service-role key) and 302s the browser back to the instance, so no per-instance redirect URI is registered at EB. Ownership: connector_connections ledger stores sha256(session_id) and the account uids, never the session; GET/DELETE /sessions and /accounts/{uid} calls verify the handle/account belongs to the presenting key. Quotas: per-company bank connection limit (connector_keys.limits, sold in the package, checked at POST /auth), per-key RPM (validate RPC), and a GLOBAL budget (connector_reserve_upstream RPC, connector_upstream_counters, ~30% of EB's 300/min so hosted is never starved; fail-open on a counter error). validate_and_increment_connector_key gained a limits column (v2). issue-connector-key.ts scopes default to bank_sync,skatteverket (TIC/org_lookup out of v1 per founder) and take --bank-connections-per-company etc. Path allowlist only, never an open passthrough. Deferred: the SKV broker (PR5b, same pattern, callback branch on the SKV extension) and the instance-side wiring (PR6: EB client connector-mode branch, self-host preset, settings row).
|
||||
[2026-08-20] Entitlement partition for the sovereign self-host (plan WS3 PR3, ships dark): isPaywallBypassed() became isBypassedFor(key). Hosted behaviour is byte-identical (dev/DISABLE_PAYWALL bypass, FORCE_PAYWALL wins, otherwise the grant lookup). On a self-host every LOCAL capability stays always-on, FORCE_PAYWALL included (an AGPL operator's own instance is never gated on what it runs itself, which is also why the existing "FORCE_PAYWALL never overrides self-hosted" test still holds), and only the four CONNECTOR_CAPABILITIES (bank_sync, skatteverket, org_lookup, migration: services Accounted operates) fall through to the grant lookup, where the connector sync will write source='connector' rows. getCompanyEntitlements on a self-host reports state 'paid' when a connector grant is active and 'none' otherwise, never 'trial_expired' (that copy talks about a hosted trial). CONNECTOR_CAPABILITIES is deliberately separate from PAID_CAPABILITIES; the trial-seed trigger does seed 30-day source='trial' rows for bank_sync/skatteverket (they are PAID keys) but never writes source='connector' and never seeds the connector-only keys (org_lookup, migration), and on a self-host only source='connector' rows unlock a connector capability, so a hosted company can never hold a connector grant. The capability_grants.source CHECK gains 'connector' by looking the inline auto-named constraint up through pg_constraint. Adding the connector extensions to the self-host Docker preset was deferred to the instance-wiring PR: until a connector key can actually be issued, shipping those extensions in the image would only show dead-end upsells.
|
||||
[2026-08-20] Sovereign package docs (plan WS2 PR1): docs/SOVEREIGN.md is written as regulatory-risk elimination with a per-provider fact sheet checked on the vendors' own pages (Elastx CaaS/DBaaS/3 Stockholm AZs/ISO 27001:2022; GleSYS VPS + S3, no managed k8s, EU-owned not Swedish-owned; Safespring S3 with Object Lock COMPLIANCE/GOVERNANCE; Berget api.berget.ai/v1 with gemma-4-31B-it vision and an SLA that excludes serverless; evroc Think Models EU-only), never as "US cloud is illegal", and it leads with the MCP server as the agent surface that needs no AI provider at all (alignment rule R5). The connector subscription is described as planned and not yet available rather than documented as if it shipped. Vercel Speed Insights is now gated behind !isSelfHosted() in app/layout.tsx (the last ungated hosted-only telemetry; read via lib/env/public-flags per the folded-flag rule). Backup/restore ship as scripts/self-host/{backup,restore}.sh (pg_dump custom format + storage volume tar + optional db-config volume for the pgsodium root key, SHA-256 manifest, AWS CLI v2 against any S3-compatible endpoint, optional COMPLIANCE-mode Object Lock) with a bash -n + refusal-path test, because self-hosted Supabase has no managed backups and BFL 7 kap needs a credible 7-year archive. Stale self-host docs fixed: the 4-of-23 cron table replaced by a pointer to the generated crontab and the pgvector line corrected (nothing stores embeddings).
|
||||
[2026-08-20] Vercel build heap is raised through vercel.json `buildCommand` (`NODE_OPTIONS=--max-old-space-size=6144 npm run build`), not a project env var and not `build.env`: a project-level NODE_OPTIONS also reaches function runtime (V8 sizes the heap against a limit the function does not have), and `build.env` is marked deprecated in the vercel.json schema; `buildCommand` scopes the flag to the build exactly like core-build.yml's 8192 does for CI. 6144 fits the standard 4-core/8 GB build machine next to the main next process; the type-check needs ~4.5 GB and was hanging at V8's ~4 GB default ceiling (4 production timeouts 2026-08-14..20).
|
||||
@@ -1406,3 +1407,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-31] Own-credentials seam forward-ported into the entitlement partition (skeptic refutation on PR #1747): a self-host serving bank_sync/skatteverket from its OWN env credentials (the same vars the extensions activate on) counts those as local capabilities, so upgrading an own-credentials self-host never dark-launches the connector gate against a working integration (the 2026-08-17 folded-flag incident shape). lib/entitlements/own-credentials.ts mirrors the connector-mode seam arriving in the instance-wiring PR (connector mode = key AND no own creds) and must stay in sync with it. org_lookup/migration have no own-credentials form. capability_blocked copy now has a self-host variant naming GNUBOK_CONNECTOR_KEY instead of the hosted subscription upsell, which misled operators toward a product they cannot buy for a self-host.
|
||||
[2026-08-31] Connector key validation maps a database/RPC error to 503 CONNECTOR_VALIDATION_UNAVAILABLE, never 401 (skeptic refutation on PR #1748): the instance sync deletes its entire connector grant cache on 401/403 (revocation semantics), so the api-keys fail-closed-to-401 precedent would let a transient hosted DB blip destroy a paying instance's 72h offline grace; 503 lands in the sync's keep-grants branch. In the same pass X-Connector-Key now wins over Authorization in extractConnectorKey: the header exists solely for proxied calls where Authorization carries an upstream token (the SKV data proxy sends both), and Bearer-first hashed the upstream token and 401'd exactly that shape.
|
||||
[2026-08-31] Connector sync deletes its grant cache only on a 401/403 whose JSON body carries a connector rejection code (CONNECTOR_KEY_MISSING/INVALID/SUSPENDED), never on status alone (second skeptic refutation on PR #1748, same failure class as the RPC-error mapping one layer up): a Vercel WAF challenge page, edge deployment protection, or a self-host egress proxy all answer 401/403 without the hosted app running, and status-trusting deletion let any of them wipe a paying instance's 72h offline grace within the hour. A codeless 401/403 now lands in the keep-grants server_error branch and the cache expires naturally if the condition persists.
|
||||
[2026-08-31] Connector usage metering redacts opaque path segments to ':id' before insert (skeptic refutation on PR #1751): the proxied bank paths carry the raw EB session id and account uid as segments, so persisting the raw pathname in connector_usage_events put the cleartext handle next to the ledger that exists precisely to store only sha256(handle). redactEndpoint() replaces UUID/long-hex/long-base64url segments; literal route words survive so metering keys stay useful.
|
||||
[2026-08-31] Bank-proxy review batch (PR #1751): EB base URL must be https (JWT in Authorization; lazy check so a bad env 500s the request, never the build); forwardToEb reads the body inside the abort-timeout window (headers-then-stall no longer wedges the request); the per-company quota uses the pending row as a reservation (pre-count, insert, re-count, roll back own row on loss) with fresh-pending rows holding quota for the 15-min consent window, closing the concurrent-auth TOCTOU without a new RPC; DELETE revokes the ledger row only on upstream success or 404 (a transient EB error no longer strands a live remote session unreachable).
|
||||
[2026-08-31] POST /sessions binds the code exchange to its state's own pending row (Superagent P1 on PR #1751): verified signature + key/service match + existing pending row are preconditions for calling EB, and a concurrent consumption of the same state after exchange closes the upstream session and answers 409 instead of handing out a session the ledger never recorded. Full code-to-state binding at the callback (recording a code hash on the pending row) would need a column and is deferred; the pre-exchange binding plus one-shot pending->active activation removes the cross-key and stateless-exchange paths, and same-key crossover between an instance's own concurrent flows only relabels its own sessions.
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
let currentKey = {
|
||||
id: 'key-1',
|
||||
orgNumber: '5561234567',
|
||||
instanceUrl: 'https://bokforing.example.se',
|
||||
scopes: ['bank_sync'],
|
||||
status: 'active' as const,
|
||||
currentPeriodEnd: null as string | null,
|
||||
limits: { bank_connections_per_company: 1, skv_connections_per_company: 1, sync_min_interval_s: 0 },
|
||||
}
|
||||
vi.mock('@/lib/connect/hosted/with-connector-auth', () => ({
|
||||
withConnectorAuth: (_op: string, handler: (req: Request, ctx: unknown) => Promise<Response>) => (req: Request) =>
|
||||
handler(req, { requestId: 'conn_test', log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, supabase: {}, key: currentKey }),
|
||||
}))
|
||||
vi.mock('@/lib/connect/upstreams/enable-banking-jwt', () => ({ getAuthorizationHeader: () => 'Bearer eb-jwt' }))
|
||||
const h = vi.hoisted(() => ({
|
||||
budget: vi.fn(),
|
||||
ledger: {
|
||||
countHeldConnections: vi.fn(),
|
||||
deletePendingConnectionById: vi.fn(),
|
||||
createPendingConnection: vi.fn(),
|
||||
activateByPendingState: vi.fn(),
|
||||
findByHandle: vi.fn(),
|
||||
findPendingByState: vi.fn(),
|
||||
findByAccountUid: vi.fn(),
|
||||
revokeByHandle: vi.fn(),
|
||||
touchConnection: vi.fn(),
|
||||
},
|
||||
}))
|
||||
const budget = h.budget
|
||||
const ledger = h.ledger
|
||||
vi.mock('@/lib/connect/hosted/upstream-budget', () => ({ reserveUpstream: (...a: unknown[]) => h.budget(...a) }))
|
||||
vi.mock('@/lib/connect/hosted/ledger', () => h.ledger)
|
||||
vi.mock('@/lib/connect/hosted/state', () => ({
|
||||
signConnectorState: () => 'ck1.signed.state',
|
||||
verifyConnectorState: (token: string) =>
|
||||
token === 'ck1.signed.state'
|
||||
? { ok: true, payload: { kid: 'key-1', svc: 'bank', ret: 'https://bokforing.example.se/cb', st: 's', cref: 'company-1', iat: 0 } }
|
||||
: token === 'ck1.other-key.state'
|
||||
? { ok: true, payload: { kid: 'key-OTHER', svc: 'bank', ret: 'x', st: 's', cref: 'c', iat: 0 } }
|
||||
: { ok: false, reason: 'malformed' },
|
||||
}))
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
import { GET, POST, DELETE } from '../route'
|
||||
|
||||
function req(method: string, path: string, body?: unknown, headers: Record<string, string> = {}): Request {
|
||||
return new Request(`https://app.gnubok.se/api/connect/bank${path}`, {
|
||||
method,
|
||||
headers: { 'x-connector-company': 'company-1', ...headers },
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
})
|
||||
}
|
||||
function ebOk(body: unknown, status = 200) {
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.ENABLE_BANKING_API_URL = 'https://api.enablebanking.com'
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.gnubok.se'
|
||||
currentKey = { ...currentKey, scopes: ['bank_sync'], limits: { bank_connections_per_company: 1, skv_connections_per_company: 1, sync_min_interval_s: 0 } }
|
||||
budget.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
describe('bank proxy', () => {
|
||||
it('403s when the key lacks the bank_sync scope', async () => {
|
||||
currentKey = { ...currentKey, scopes: [] }
|
||||
const res = await GET(req('GET', '/aspsps'))
|
||||
expect(res.status).toBe(403)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_SCOPE_MISSING')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards GET /aspsps with the EB JWT', async () => {
|
||||
ebOk({ aspsps: [] })
|
||||
const res = await GET(req('GET', '/aspsps?country=SE'))
|
||||
expect(res.status).toBe(200)
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('https://api.enablebanking.com/aspsps?country=SE')
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer eb-jwt')
|
||||
})
|
||||
|
||||
it('refuses an unknown path', async () => {
|
||||
const res = await GET(req('GET', '/accounts'))
|
||||
expect(res.status).toBe(403)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_PATH_NOT_ALLOWED')
|
||||
})
|
||||
|
||||
it('POST /auth rewrites redirect + state, checks quota, records a pending row', async () => {
|
||||
ledger.countHeldConnections.mockResolvedValue(0)
|
||||
ledger.createPendingConnection.mockResolvedValue('p1')
|
||||
ebOk({ url: 'https://bank.example/consent', authorization_id: 'a1' })
|
||||
const res = await POST(req('POST', '/auth', {
|
||||
aspsp: { name: 'SEB', country: 'SE' },
|
||||
redirect_url: 'https://bokforing.example.se/api/extensions/enable-banking/callback',
|
||||
state: 'inst-state',
|
||||
}))
|
||||
expect(res.status).toBe(200)
|
||||
expect(ledger.createPendingConnection).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ keyId: 'key-1', service: 'bank', companyRef: 'company-1', provider: 'SEB', pendingState: 'ck1.signed.state' }))
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
|
||||
expect(body.redirect_url).toBe('https://app.gnubok.se/api/extensions/enable-banking/callback')
|
||||
expect(body.state).toBe('ck1.signed.state')
|
||||
})
|
||||
|
||||
it('POST /auth rejects a redirect_url off the pinned instance', async () => {
|
||||
const res = await POST(req('POST', '/auth', { aspsp: { name: 'SEB', country: 'SE' }, redirect_url: 'https://evil.example.com/cb', state: 's' }))
|
||||
expect(res.status).toBe(400)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_REDIRECT_INVALID')
|
||||
expect(ledger.createPendingConnection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('POST /auth 403s when the per-company quota is reached', async () => {
|
||||
ledger.countHeldConnections.mockResolvedValue(1)
|
||||
const res = await POST(req('POST', '/auth', { aspsp: { name: 'SEB', country: 'SE' }, redirect_url: 'https://bokforing.example.se/cb', state: 's' }))
|
||||
expect(res.status).toBe(403)
|
||||
expect(await res.json()).toMatchObject({ code: 'CONNECTOR_QUOTA_EXCEEDED', limit: 1 })
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('POST /auth rolls back its own reservation when the post-insert re-count exceeds the limit (concurrent race)', async () => {
|
||||
// Two concurrent /auth calls both pre-count 0; the re-count after insert
|
||||
// sees both reservations and the loser deletes its own row.
|
||||
ledger.countHeldConnections.mockResolvedValueOnce(0).mockResolvedValueOnce(2)
|
||||
ledger.createPendingConnection.mockResolvedValue('p-race')
|
||||
const res = await POST(req('POST', '/auth', { aspsp: { name: 'SEB', country: 'SE' }, redirect_url: 'https://bokforing.example.se/cb', state: 's' }))
|
||||
expect(res.status).toBe(403)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_QUOTA_EXCEEDED')
|
||||
expect(ledger.deletePendingConnectionById).toHaveBeenCalledWith(expect.anything(), 'p-race')
|
||||
})
|
||||
|
||||
it('POST /auth 400s without the company header', async () => {
|
||||
const res = await POST(new Request('https://app.gnubok.se/api/connect/bank/auth', { method: 'POST', body: '{}' }))
|
||||
expect(res.status).toBe(400)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_COMPANY_MISSING')
|
||||
})
|
||||
|
||||
it('POST /sessions activates the ledger row from the connector_state', async () => {
|
||||
ledger.findPendingByState.mockResolvedValue({ id: 'p1', status: 'pending' })
|
||||
ledger.activateByPendingState.mockResolvedValue({ id: 'p1', status: 'active' })
|
||||
ebOk({ session_id: 'sess-9', accounts: [{ uid: 'acc-1' }, { uid: 'acc-2' }] })
|
||||
const res = await POST(req('POST', '/sessions', { code: 'auth-code', connector_state: 'ck1.signed.state' }))
|
||||
expect(res.status).toBe(200)
|
||||
expect(ledger.activateByPendingState).toHaveBeenCalledWith(expect.anything(), { keyId: 'key-1', pendingState: 'ck1.signed.state', handle: 'sess-9', accountUids: ['acc-1', 'acc-2'] })
|
||||
})
|
||||
|
||||
it('POST /sessions refuses an invalid or foreign connector state before touching EB', async () => {
|
||||
let res = await POST(req('POST', '/sessions', { code: 'c', connector_state: 'garbage' }))
|
||||
expect(res.status).toBe(400)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_STATE_INVALID')
|
||||
res = await POST(req('POST', '/sessions', { code: 'c', connector_state: 'ck1.other-key.state' }))
|
||||
expect(res.status).toBe(403)
|
||||
ledger.findPendingByState.mockResolvedValue(null)
|
||||
res = await POST(req('POST', '/sessions', { code: 'c', connector_state: 'ck1.signed.state' }))
|
||||
expect(res.status).toBe(404)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('POST /sessions closes the upstream session and 409s when the state was consumed concurrently', async () => {
|
||||
ledger.findPendingByState.mockResolvedValue({ id: 'p1', status: 'pending' })
|
||||
ledger.activateByPendingState.mockResolvedValue(null)
|
||||
ebOk({ session_id: 'sess-dup', accounts: [] })
|
||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })) // the revoking DELETE
|
||||
const res = await POST(req('POST', '/sessions', { code: 'c', connector_state: 'ck1.signed.state' }))
|
||||
expect(res.status).toBe(409)
|
||||
expect((await res.json()).code).toBe('CONNECTOR_STATE_CONSUMED')
|
||||
expect(fetchMock.mock.calls[1][0]).toContain('/sessions/sess-dup')
|
||||
expect(fetchMock.mock.calls[1][1].method).toBe('DELETE')
|
||||
})
|
||||
|
||||
it('GET /sessions/{id} requires ownership', async () => {
|
||||
ledger.findByHandle.mockResolvedValueOnce(null)
|
||||
expect((await GET(req('GET', '/sessions/sess-x'))).status).toBe(404)
|
||||
ledger.findByHandle.mockResolvedValueOnce({ id: 'l1' })
|
||||
ebOk({ session_id: 'sess-x' })
|
||||
expect((await GET(req('GET', '/sessions/sess-x'))).status).toBe(200)
|
||||
expect(ledger.touchConnection).toHaveBeenCalledWith(expect.anything(), 'l1')
|
||||
})
|
||||
|
||||
it('GET /accounts/{uid}/transactions requires account ownership', async () => {
|
||||
ledger.findByAccountUid.mockResolvedValueOnce(null)
|
||||
expect((await GET(req('GET', '/accounts/acc-1/transactions'))).status).toBe(404)
|
||||
ledger.findByAccountUid.mockResolvedValueOnce({ id: 'l2' })
|
||||
ebOk({ transactions: [] })
|
||||
const res = await GET(req('GET', '/accounts/acc-1/transactions?date_from=2026-01-01'))
|
||||
expect(res.status).toBe(200)
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('https://api.enablebanking.com/accounts/acc-1/transactions?date_from=2026-01-01')
|
||||
})
|
||||
|
||||
it('DELETE /sessions/{id} revokes the ledger row after the upstream delete', async () => {
|
||||
ledger.findByHandle.mockResolvedValueOnce({ id: 'l3' })
|
||||
fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
const res = await DELETE(req('DELETE', '/sessions/sess-z'))
|
||||
expect(res.status).toBe(204)
|
||||
expect(ledger.revokeByHandle).toHaveBeenCalledWith(expect.anything(), { keyId: 'key-1', service: 'bank', handle: 'sess-z' })
|
||||
})
|
||||
|
||||
it('DELETE /sessions/{id} keeps the ledger row on a transient upstream error', async () => {
|
||||
ledger.findByHandle.mockResolvedValueOnce({ id: 'l4' })
|
||||
fetchMock.mockResolvedValueOnce(new Response('upstream boom', { status: 502 }))
|
||||
const res = await DELETE(req('DELETE', '/sessions/sess-w'))
|
||||
expect(res.status).toBe(502)
|
||||
expect(ledger.revokeByHandle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a non-https EB base URL before sending the JWT', async () => {
|
||||
process.env.ENABLE_BANKING_API_URL = 'http://api.enablebanking.com'
|
||||
ledger.findByHandle.mockResolvedValueOnce({ id: 'l5' })
|
||||
await expect(GET(req('GET', '/aspsps'))).rejects.toThrow(/must be https/)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
process.env.ENABLE_BANKING_API_URL = 'https://api.enablebanking.com'
|
||||
})
|
||||
|
||||
it('returns 429 with Retry-After when the global budget is exhausted', async () => {
|
||||
budget.mockResolvedValue({ ok: false, scope: 'minute', retryAfterSec: 60 })
|
||||
const res = await GET(req('GET', '/aspsps'))
|
||||
expect(res.status).toBe(429)
|
||||
expect(res.headers.get('Retry-After')).toBe('60')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,346 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withConnectorAuth, type ConnectorContext } from '@/lib/connect/hosted/with-connector-auth'
|
||||
import { getAuthorizationHeader } from '@/lib/connect/upstreams/enable-banking-jwt'
|
||||
import { reserveUpstream } from '@/lib/connect/hosted/upstream-budget'
|
||||
import {
|
||||
activateByPendingState,
|
||||
countHeldConnections,
|
||||
createPendingConnection,
|
||||
deletePendingConnectionById,
|
||||
findByAccountUid,
|
||||
findByHandle,
|
||||
findPendingByState,
|
||||
revokeByHandle,
|
||||
touchConnection,
|
||||
} from '@/lib/connect/hosted/ledger'
|
||||
import { signConnectorState, verifyConnectorState } from '@/lib/connect/hosted/state'
|
||||
|
||||
/**
|
||||
* Enable Banking proxy for self-hosted instances (WS3 PR5).
|
||||
*
|
||||
* The instance calls this with its connector key; the proxy adds Arcim's EB
|
||||
* JWT and forwards to Enable Banking. The instance never holds the EB
|
||||
* credential; the bank session id it gets back rests on the instance. This
|
||||
* route is what makes that split safe: a strict path allowlist (never an open
|
||||
* passthrough), per-key + global rate budget on every upstream call, a
|
||||
* per-company connection quota checked at authorize time, and an ownership
|
||||
* ledger so an instance can only use sessions/accounts it obtained through its
|
||||
* own key.
|
||||
*
|
||||
* Consent handoff: POST /auth's redirect_url is rewritten to OUR hosted EB
|
||||
* callback (already registered with EB), and the upstream `state` is replaced
|
||||
* by a signed connector state carrying the instance's own return URL. The
|
||||
* hosted EB callback (app/api/extensions/enable-banking/callback) detects that
|
||||
* signed state and 302s the browser back to the instance with the code, so no
|
||||
* new redirect URI has to be registered at Enable Banking per instance.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The EB base URL must be https (http only for loopback dev): forwardToEb
|
||||
* sends Arcim's EB JWT in Authorization, so a plaintext override would ship
|
||||
* the credential unencrypted. Resolved lazily so a bad env fails the request
|
||||
* (500 via the wrapper), never the build.
|
||||
*/
|
||||
function ebBaseUrl(): string {
|
||||
const raw = (
|
||||
process.env.ENABLE_BANKING_API_URL_PRODUCTION ||
|
||||
process.env.ENABLE_BANKING_API_URL ||
|
||||
'https://api.enablebanking.com'
|
||||
).replace(/\/+$/, '')
|
||||
const url = new URL(raw)
|
||||
const loopback = url.hostname === 'localhost' || url.hostname === '127.0.0.1'
|
||||
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
|
||||
throw new Error('ENABLE_BANKING_API_URL must be https: the EB JWT is sent in Authorization')
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
const COMPANY_HEADER = 'x-connector-company'
|
||||
const FETCH_TIMEOUT_MS = 30_000
|
||||
|
||||
function requireScope(ctx: ConnectorContext): NextResponse | null {
|
||||
if (ctx.key.scopes.includes('bank_sync')) return null
|
||||
return NextResponse.json(
|
||||
{ error: 'This connector key does not include bank sync', code: 'CONNECTOR_SCOPE_MISSING' },
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
function companyRef(request: Request): string | null {
|
||||
return request.headers.get(COMPANY_HEADER)?.trim() || null
|
||||
}
|
||||
|
||||
async function budgetOr429(ctx: ConnectorContext): Promise<NextResponse | null> {
|
||||
const budget = await reserveUpstream(ctx.supabase, 'bank')
|
||||
if (budget.ok) return null
|
||||
ctx.log.warn('bank connector budget exhausted', { scope: budget.scope })
|
||||
return NextResponse.json(
|
||||
{ error: 'Bank connector is busy, try again shortly', code: 'CONNECTOR_RATE_LIMITED', scope: budget.scope },
|
||||
{ status: 429, headers: { 'Retry-After': String(budget.retryAfterSec) } },
|
||||
)
|
||||
}
|
||||
|
||||
// Statuses that must carry no body (RFC 9110). new Response(text, {status:204})
|
||||
// throws in undici, so pass a null body through for these.
|
||||
const NULL_BODY_STATUS = new Set([204, 205, 304])
|
||||
|
||||
interface EbResult {
|
||||
status: number
|
||||
ok: boolean
|
||||
/** null for the RFC 9110 bodyless statuses. */
|
||||
text: string | null
|
||||
contentType: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward a request to Enable Banking with Arcim's JWT and read the body
|
||||
* INSIDE the timeout window: clearing the timer at headers-received left an
|
||||
* upstream that stalls mid-body holding the request open forever.
|
||||
*/
|
||||
async function forwardToEb(method: string, path: string, body?: unknown): Promise<EbResult> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(`${ebBaseUrl()}${path}`, {
|
||||
method,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: getAuthorizationHeader(),
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
})
|
||||
const text = NULL_BODY_STATUS.has(res.status) ? null : await res.text()
|
||||
return { status: res.status, ok: res.ok, text, contentType: res.headers.get('content-type') }
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
function passthrough(res: EbResult): NextResponse {
|
||||
if (res.text === null) {
|
||||
return new NextResponse(null, { status: res.status })
|
||||
}
|
||||
return new NextResponse(res.text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.contentType ?? 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
function pathOf(request: Request): string {
|
||||
const idx = request.url.indexOf('/api/connect/bank')
|
||||
const rest = idx === -1 ? '' : request.url.slice(idx + '/api/connect/bank'.length)
|
||||
return rest.split('?')[0].replace(/\/+$/, '') || '/'
|
||||
}
|
||||
function queryOf(request: Request): string {
|
||||
const q = request.url.indexOf('?')
|
||||
return q === -1 ? '' : request.url.slice(q)
|
||||
}
|
||||
|
||||
const HOSTED_EB_CALLBACK = () =>
|
||||
`${(process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000').replace(/\/+$/, '')}/api/extensions/enable-banking/callback`
|
||||
|
||||
export const GET = withConnectorAuth('connect.bank', async (request, ctx) => {
|
||||
const scopeError = requireScope(ctx)
|
||||
if (scopeError) return scopeError
|
||||
const path = pathOf(request)
|
||||
|
||||
// GET /aspsps — bank list. Rate-budgeted, no ownership.
|
||||
if (path === '/aspsps') {
|
||||
const blocked = await budgetOr429(ctx)
|
||||
if (blocked) return blocked
|
||||
return passthrough(await forwardToEb('GET', `/aspsps${queryOf(request)}`))
|
||||
}
|
||||
|
||||
// GET /sessions/{id} — must own the session.
|
||||
const sessionMatch = /^\/sessions\/([^/]+)$/.exec(path)
|
||||
if (sessionMatch) {
|
||||
const sessionId = decodeURIComponent(sessionMatch[1])
|
||||
const owned = await findByHandle(ctx.supabase, { keyId: ctx.key.id, service: 'bank', handle: sessionId })
|
||||
if (!owned) return notOwned()
|
||||
const blocked = await budgetOr429(ctx)
|
||||
if (blocked) return blocked
|
||||
await touchConnection(ctx.supabase, owned.id)
|
||||
return passthrough(await forwardToEb('GET', `/sessions/${encodeURIComponent(sessionId)}`))
|
||||
}
|
||||
|
||||
// GET /accounts/{uid}/balances|transactions — must own the account.
|
||||
const acctMatch = /^\/accounts\/([^/]+)\/(balances|transactions)$/.exec(path)
|
||||
if (acctMatch) {
|
||||
const uid = decodeURIComponent(acctMatch[1])
|
||||
const owned = await findByAccountUid(ctx.supabase, { keyId: ctx.key.id, accountUid: uid })
|
||||
if (!owned) return notOwned()
|
||||
const blocked = await budgetOr429(ctx)
|
||||
if (blocked) return blocked
|
||||
await touchConnection(ctx.supabase, owned.id)
|
||||
return passthrough(await forwardToEb('GET', `/accounts/${encodeURIComponent(uid)}/${acctMatch[2]}${queryOf(request)}`))
|
||||
}
|
||||
|
||||
return notAllowed()
|
||||
})
|
||||
|
||||
export const POST = withConnectorAuth('connect.bank', async (request, ctx) => {
|
||||
const scopeError = requireScope(ctx)
|
||||
if (scopeError) return scopeError
|
||||
const path = pathOf(request)
|
||||
const cref = companyRef(request)
|
||||
|
||||
// POST /auth — start a bank authorization for one company.
|
||||
if (path === '/auth') {
|
||||
if (!cref) return missingCompany()
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = (await request.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON', code: 'BAD_REQUEST' }, { status: 400 })
|
||||
}
|
||||
const instanceReturn = typeof payload.redirect_url === 'string' ? payload.redirect_url : null
|
||||
if (!instanceReturn || !isOnInstance(instanceReturn, ctx.key.instanceUrl)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'redirect_url must be on the connector key\'s instance', code: 'CONNECTOR_REDIRECT_INVALID' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
// Per-company connection quota (the sold package). Counting alone is a
|
||||
// TOCTOU race under concurrent /auth calls, so the pending row doubles as
|
||||
// a reservation: fast-reject on the pre-count, insert, then RE-count and
|
||||
// roll the own row back when over the limit. Fresh pending rows reserve
|
||||
// quota for the 15-minute consent window (see countHeldConnections).
|
||||
const quotaExceeded = () =>
|
||||
NextResponse.json(
|
||||
{
|
||||
error: 'Bank connection quota reached for this company',
|
||||
code: 'CONNECTOR_QUOTA_EXCEEDED',
|
||||
limit: ctx.key.limits.bank_connections_per_company,
|
||||
},
|
||||
{ status: 403 },
|
||||
)
|
||||
const held = await countHeldConnections(ctx.supabase, ctx.key.id, 'bank', cref)
|
||||
if (held >= ctx.key.limits.bank_connections_per_company) return quotaExceeded()
|
||||
const blocked = await budgetOr429(ctx)
|
||||
if (blocked) return blocked
|
||||
|
||||
const instanceState = typeof payload.state === 'string' ? payload.state : ''
|
||||
const signedState = signConnectorState({ kid: ctx.key.id, svc: 'bank', ret: instanceReturn, st: instanceState, cref })
|
||||
const provider = typeof (payload.aspsp as { name?: string } | undefined)?.name === 'string'
|
||||
? String((payload.aspsp as { name: string }).name)
|
||||
: null
|
||||
const pendingId = await createPendingConnection(ctx.supabase, {
|
||||
keyId: ctx.key.id,
|
||||
service: 'bank',
|
||||
companyRef: cref,
|
||||
provider,
|
||||
pendingState: signedState,
|
||||
})
|
||||
const heldAfter = await countHeldConnections(ctx.supabase, ctx.key.id, 'bank', cref)
|
||||
if (heldAfter > ctx.key.limits.bank_connections_per_company) {
|
||||
await deletePendingConnectionById(ctx.supabase, pendingId)
|
||||
return quotaExceeded()
|
||||
}
|
||||
// Rewrite the redirect to OUR registered callback and swap in the signed state.
|
||||
const ebBody = { ...payload, redirect_url: HOSTED_EB_CALLBACK(), state: signedState }
|
||||
return passthrough(await forwardToEb('POST', '/auth', ebBody))
|
||||
}
|
||||
|
||||
// POST /sessions — finalize after consent, record the session in the ledger.
|
||||
if (path === '/sessions') {
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = (await request.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON', code: 'BAD_REQUEST' }, { status: 400 })
|
||||
}
|
||||
// The instance sends back the signed connector state it received. The
|
||||
// code is only exchanged AGAINST that state's own pending row: verified
|
||||
// signature, this key, bank service, and the row still pending. Without
|
||||
// the binding, a code could be exchanged under a foreign or consumed
|
||||
// state, minting an upstream session the ledger never records.
|
||||
const pendingState = typeof payload.connector_state === 'string' ? payload.connector_state : null
|
||||
if (!pendingState) {
|
||||
return NextResponse.json({ error: 'Missing connector_state', code: 'BAD_REQUEST' }, { status: 400 })
|
||||
}
|
||||
const verified = verifyConnectorState(pendingState)
|
||||
if (!verified.ok) {
|
||||
return NextResponse.json({ error: 'Invalid connector state', code: 'CONNECTOR_STATE_INVALID' }, { status: 400 })
|
||||
}
|
||||
if (verified.payload.kid !== ctx.key.id || verified.payload.svc !== 'bank') {
|
||||
return NextResponse.json({ error: 'State does not belong to this key', code: 'CONNECTOR_STATE_INVALID' }, { status: 403 })
|
||||
}
|
||||
const pendingRow = await findPendingByState(ctx.supabase, { keyId: ctx.key.id, pendingState })
|
||||
if (!pendingRow) return notOwned()
|
||||
const blocked = await budgetOr429(ctx)
|
||||
if (blocked) return blocked
|
||||
const ebRes = await forwardToEb('POST', '/sessions', { code: payload.code })
|
||||
if (ebRes.ok && ebRes.text !== null) {
|
||||
try {
|
||||
const session = JSON.parse(ebRes.text) as { session_id?: string; accounts?: Array<{ uid?: string }> }
|
||||
if (session.session_id) {
|
||||
const accountUids = (session.accounts ?? [])
|
||||
.map((a) => a.uid)
|
||||
.filter((u): u is string => typeof u === 'string')
|
||||
const activated = await activateByPendingState(ctx.supabase, {
|
||||
keyId: ctx.key.id,
|
||||
pendingState,
|
||||
handle: session.session_id,
|
||||
accountUids,
|
||||
})
|
||||
if (!activated) {
|
||||
// The pending row was consumed between the precheck and now (a
|
||||
// concurrent replay of the same state). An unrecorded upstream
|
||||
// session must not be handed out: close it and refuse.
|
||||
await forwardToEb('DELETE', `/sessions/${encodeURIComponent(session.session_id)}`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Connector state already consumed', code: 'CONNECTOR_STATE_CONSUMED' },
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.log.warn('could not record session in ledger', { err: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
}
|
||||
return passthrough(ebRes)
|
||||
}
|
||||
|
||||
return notAllowed()
|
||||
})
|
||||
|
||||
export const DELETE = withConnectorAuth('connect.bank', async (request, ctx) => {
|
||||
const scopeError = requireScope(ctx)
|
||||
if (scopeError) return scopeError
|
||||
const path = pathOf(request)
|
||||
const sessionMatch = /^\/sessions\/([^/]+)$/.exec(path)
|
||||
if (!sessionMatch) return notAllowed()
|
||||
const sessionId = decodeURIComponent(sessionMatch[1])
|
||||
const owned = await findByHandle(ctx.supabase, { keyId: ctx.key.id, service: 'bank', handle: sessionId })
|
||||
if (!owned) return notOwned()
|
||||
const blocked = await budgetOr429(ctx)
|
||||
if (blocked) return blocked
|
||||
const res = await forwardToEb('DELETE', `/sessions/${encodeURIComponent(sessionId)}`)
|
||||
// Revoke the ledger row only when the upstream delete actually took (or the
|
||||
// session is already gone upstream): revoking on a transient EB error would
|
||||
// leave the remote session alive but permanently unreachable via the proxy.
|
||||
if (res.ok || res.status === 404) {
|
||||
await revokeByHandle(ctx.supabase, { keyId: ctx.key.id, service: 'bank', handle: sessionId })
|
||||
}
|
||||
return passthrough(res)
|
||||
})
|
||||
|
||||
function isOnInstance(url: string, instanceUrl: string | null): boolean {
|
||||
if (!instanceUrl) return false
|
||||
try {
|
||||
return new URL(url).origin === new URL(instanceUrl).origin
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
function notAllowed(): NextResponse {
|
||||
return NextResponse.json({ error: 'Not allowed', code: 'CONNECTOR_PATH_NOT_ALLOWED' }, { status: 403 })
|
||||
}
|
||||
function notOwned(): NextResponse {
|
||||
return NextResponse.json({ error: 'Unknown connection for this key', code: 'CONNECTOR_NOT_OWNED' }, { status: 404 })
|
||||
}
|
||||
function missingCompany(): NextResponse {
|
||||
return NextResponse.json({ error: 'Missing X-Connector-Company header', code: 'CONNECTOR_COMPANY_MISSING' }, { status: 400 })
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { fanOutSessionRenewal } from '@/extensions/general/enable-banking/lib/se
|
||||
import { supersedeSiblingConnections } from '@/extensions/general/enable-banking/lib/supersede'
|
||||
import { getBankConnectionErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { renderFinalizeShell, renderFinalizeRedirect } from './finalize-page'
|
||||
import { isConnectorState, verifyConnectorState } from '@/lib/connect/hosted/state'
|
||||
|
||||
// This route emits bank_connection.consent_granted / .cash_account_mirror_failed
|
||||
// (ASVS V16 / GDPR Art.30 audit events). ensureInitialized() must run at module
|
||||
@@ -82,6 +83,28 @@ export async function GET(request: Request) {
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
|
||||
// Connector branch: a self-hosted instance started this authorization through
|
||||
// the /api/connect/bank proxy, which replaced the upstream state with a
|
||||
// signed connector state carrying the instance's own return URL. We never
|
||||
// create a session here (the instance does, through the proxy): we just
|
||||
// bounce the browser back to the instance with the code + its original
|
||||
// state, so no per-instance redirect URI has to be registered with EB.
|
||||
if (isConnectorState(state)) {
|
||||
const verified = verifyConnectorState(state as string)
|
||||
if (!verified.ok || verified.payload.svc !== 'bank') {
|
||||
return NextResponse.redirect(`${baseUrl}/?connector_error=${encodeURIComponent(verified.ok ? 'wrong_service' : verified.reason)}`)
|
||||
}
|
||||
const ret = new URL(verified.payload.ret)
|
||||
if (error) ret.searchParams.set('error', error)
|
||||
if (errorDescription) ret.searchParams.set('error_description', errorDescription)
|
||||
if (code) ret.searchParams.set('code', code)
|
||||
if (verified.payload.st) ret.searchParams.set('state', verified.payload.st)
|
||||
// Echo the signed connector state so the instance can present it back to
|
||||
// the proxy's POST /sessions (which finds the pending ledger row by it).
|
||||
ret.searchParams.set('connector_state', state as string)
|
||||
return NextResponse.redirect(ret.toString())
|
||||
}
|
||||
|
||||
if (error) {
|
||||
// Swedish user-facing message carrying the underlying provider error; the
|
||||
// raw code/description stays in the log lines and the audit event below.
|
||||
|
||||
+18
-1
@@ -352,7 +352,24 @@ The cron sidecar calls `/api/connector/sync/cron` hourly (it is listed in `docke
|
||||
curl -sf -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/connector/sync/cron
|
||||
```
|
||||
|
||||
The proxied connector services themselves (bank, Skatteverket, lookup, migration through `app.gnubok.se/api/connect/*`) ship in a following release; until then the key is validated and the grants are written, nothing more.
|
||||
The bank proxy (`app.gnubok.se/api/connect/bank/*`) is live server-side with this release; the Skatteverket broker and the instance-side client wiring that makes the proxies carry traffic ship in following releases. Until that wiring lands, the key is validated and the grants are written, nothing more.
|
||||
|
||||
### Connector subscription (self-hosted instances)
|
||||
|
||||
Everything a self-hosted instance runs itself is free (AGPL). Four capabilities depend on services only Accounted operates and are therefore gated on a self-host: bank sync (our PSD2/AISP credentials), Skatteverket API submission and skattekonto sync (our API client registration), company lookup (TIC) and migration from Fortnox/Visma/Bokio/Björn Lundén (the migration gateway). A **connector key** unlocks them for every company on the instance; it is priced per active company at parity with hosted and is issued manually by Accounted for now (self-serve later).
|
||||
|
||||
```bash
|
||||
GNUBOK_CONNECTOR_KEY=gnubok_ck_... # issued by Accounted, shown once
|
||||
# GNUBOK_CONNECT_URL=https://app.gnubok.se # default: the hosted connector service
|
||||
```
|
||||
|
||||
The cron sidecar calls `/api/connector/sync/cron` hourly (it is listed in `docker/crontab.self-hosted` only): the instance reports its active company count, the hosted service answers with the key's status and scopes, and the instance writes `capability_grants` rows with `source = 'connector'` that expire after **72 hours** (or three days past the paid period, whichever is sooner). Those rows are the offline cache: a hosted outage shorter than that changes nothing, a revoked or lapsed key freezes the connector capabilities within days, and nothing in the instance phones home for permission to run the bookkeeping. An instance without a key answers `not_configured` and stays unaffected. To run the sync once by hand after pasting the key:
|
||||
|
||||
```bash
|
||||
curl -sf -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/connector/sync/cron
|
||||
```
|
||||
|
||||
The **bank connector** proxy is live (`app.gnubok.se/api/connect/bank/*`): with `bank_sync` in your key's scopes, the instance connects a bank through Arcim's PSD2 credentials while the bank session id and all transaction data stay in the instance's own database. Skatteverket, company lookup and migration through the connector ship in following releases; until each lands, a key is validated and its grants are written, and the unshipped services stay unconfigured.
|
||||
|
||||
### Push Notifications
|
||||
|
||||
|
||||
@@ -1,117 +1,14 @@
|
||||
/**
|
||||
* JWT generation for Enable Banking API authentication
|
||||
* Enable Banking JWT (moved to lib/connect/upstreams/enable-banking-jwt.ts).
|
||||
*
|
||||
* Enable Banking requires JWT tokens signed with RS256 using your private key.
|
||||
* The JWT is included in the Authorization header for all API calls.
|
||||
* The hosted connector proxy (core, app/api/connect/*) needs to mint this JWT
|
||||
* too, and core must never import from @/extensions/ (the zero-extension build
|
||||
* would break). So the implementation lives in lib/; the extension re-exports
|
||||
* it here so its own imports and tests are unchanged. The re-import direction
|
||||
* (extension -> lib) is the allowed one.
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto'
|
||||
|
||||
// Prefer _PRODUCTION variants when available (Vercel production deploys)
|
||||
const APP_ID = process.env.ENABLE_BANKING_APP_ID_PRODUCTION || process.env.ENABLE_BANKING_APP_ID
|
||||
const PRIVATE_KEY_RAW = process.env.ENABLE_BANKING_PRIVATE_KEY_PRODUCTION || process.env.ENABLE_BANKING_PRIVATE_KEY
|
||||
|
||||
interface JWTHeader {
|
||||
typ: string
|
||||
alg: string
|
||||
kid: string
|
||||
}
|
||||
|
||||
interface JWTPayload {
|
||||
iss: string
|
||||
aud: string
|
||||
iat: number
|
||||
exp: number
|
||||
}
|
||||
|
||||
function base64UrlEncode(data: Buffer | string): string {
|
||||
const str = typeof data === 'string' ? data : data.toString('base64')
|
||||
return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function getPrivateKey(): string {
|
||||
if (!PRIVATE_KEY_RAW) {
|
||||
throw new Error('ENABLE_BANKING_PRIVATE_KEY environment variable is not set')
|
||||
}
|
||||
|
||||
// Try decoding as base64-encoded PEM (sandbox format: base64 wrapping a PEM string)
|
||||
const decoded = Buffer.from(PRIVATE_KEY_RAW, 'base64').toString('utf-8')
|
||||
if (decoded.startsWith('-----BEGIN')) {
|
||||
return decoded
|
||||
}
|
||||
|
||||
// Otherwise treat as raw base64 DER key material: wrap in PEM headers
|
||||
const lines = PRIVATE_KEY_RAW.match(/.{1,64}/g) || []
|
||||
return `-----BEGIN PRIVATE KEY-----\n${lines.join('\n')}\n-----END PRIVATE KEY-----`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT token for Enable Banking API authentication
|
||||
*
|
||||
* @param expiresInSeconds - Token validity in seconds (default: 3600 = 1 hour)
|
||||
* @returns Signed JWT token
|
||||
*/
|
||||
export function generateJWT(expiresInSeconds: number = 3600): string {
|
||||
if (!APP_ID) {
|
||||
throw new Error('ENABLE_BANKING_APP_ID environment variable is not set')
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
const header: JWTHeader = {
|
||||
typ: 'JWT',
|
||||
alg: 'RS256',
|
||||
kid: APP_ID
|
||||
}
|
||||
|
||||
const payload: JWTPayload = {
|
||||
iss: 'enablebanking.com',
|
||||
aud: 'api.enablebanking.com',
|
||||
iat: now,
|
||||
exp: now + expiresInSeconds
|
||||
}
|
||||
|
||||
// Encode header and payload
|
||||
const headerBase64 = base64UrlEncode(Buffer.from(JSON.stringify(header)))
|
||||
const payloadBase64 = base64UrlEncode(Buffer.from(JSON.stringify(payload)))
|
||||
|
||||
// Create signature
|
||||
const signatureInput = `${headerBase64}.${payloadBase64}`
|
||||
const privateKey = getPrivateKey()
|
||||
|
||||
const sign = crypto.createSign('RSA-SHA256')
|
||||
sign.update(signatureInput)
|
||||
sign.end()
|
||||
|
||||
const signature = sign.sign(privateKey)
|
||||
const signatureBase64 = base64UrlEncode(signature)
|
||||
|
||||
return `${headerBase64}.${payloadBase64}.${signatureBase64}`
|
||||
}
|
||||
|
||||
// JWT token cache
|
||||
let cachedToken: string | null = null
|
||||
let cachedTokenExpiry: number = 0
|
||||
|
||||
/**
|
||||
* Get the Authorization header value for Enable Banking API.
|
||||
* Caches JWT tokens and reuses them until 60s before expiry.
|
||||
*/
|
||||
export function getAuthorizationHeader(): string {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
if (cachedToken && now < cachedTokenExpiry - 60) {
|
||||
return `Bearer ${cachedToken}`
|
||||
}
|
||||
|
||||
const expiresInSeconds = 3600
|
||||
const token = generateJWT(expiresInSeconds)
|
||||
cachedToken = token
|
||||
cachedTokenExpiry = now + expiresInSeconds
|
||||
return `Bearer ${token}`
|
||||
}
|
||||
|
||||
/** @internal Reset token cache: for testing only */
|
||||
export function _resetTokenCache(): void {
|
||||
cachedToken = null
|
||||
cachedTokenExpiry = 0
|
||||
}
|
||||
export {
|
||||
generateJWT,
|
||||
getAuthorizationHeader,
|
||||
_resetTokenCache,
|
||||
} from '@/lib/connect/upstreams/enable-banking-jwt'
|
||||
|
||||
@@ -15,6 +15,7 @@ const ROW = {
|
||||
status: 'active',
|
||||
current_period_end: '2027-01-01T00:00:00.000Z',
|
||||
rate_limited: false,
|
||||
limits: { bank_connections_per_company: 2, skv_connections_per_company: 1, sync_min_interval_s: 3600 },
|
||||
}
|
||||
|
||||
describe('connector key primitives', () => {
|
||||
@@ -56,10 +57,17 @@ describe('validateConnectorKey', () => {
|
||||
scopes: ['bank_sync', 'skatteverket'],
|
||||
status: 'active',
|
||||
currentPeriodEnd: '2027-01-01T00:00:00.000Z',
|
||||
limits: { bank_connections_per_company: 2, skv_connections_per_company: 1, sync_min_interval_s: 3600 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('fills default limits when the RPC returns null limits', async () => {
|
||||
const { key } = generateConnectorKey()
|
||||
const result = await validateConnectorKey(key, supabaseWithRpc({ data: [{ ...ROW, limits: null }] }).supabase)
|
||||
expect(result.ok && result.key.limits).toEqual({ bank_connections_per_company: 1, skv_connections_per_company: 1, sync_min_interval_s: 0 })
|
||||
})
|
||||
|
||||
it('maps no row (unknown/revoked) to 401, but an RPC error to 503', async () => {
|
||||
const { key } = generateConnectorKey()
|
||||
expect(await validateConnectorKey(key, supabaseWithRpc({ data: [] }).supabase)).toMatchObject({ ok: false, status: 401 })
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest'
|
||||
import { isConnectorState, signConnectorState, verifyConnectorState } from '../state'
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
const BASE = { kid: 'k1', svc: 'bank' as const, ret: 'https://bokforing.example.se/cb', st: 'inst-state', cref: 'company-1' }
|
||||
|
||||
describe('connector state', () => {
|
||||
it('round-trips a signed payload', () => {
|
||||
vi.stubEnv('CONNECTOR_STATE_SECRET', 'secret')
|
||||
const now = 1_000_000
|
||||
const token = signConnectorState(BASE, now)
|
||||
expect(isConnectorState(token)).toBe(true)
|
||||
const v = verifyConnectorState(token, now)
|
||||
expect(v).toEqual({ ok: true, payload: { ...BASE, iat: now } })
|
||||
})
|
||||
|
||||
it('rejects a tampered payload', () => {
|
||||
vi.stubEnv('CONNECTOR_STATE_SECRET', 'secret')
|
||||
const token = signConnectorState(BASE, 1000)
|
||||
const [ver, body, sig] = token.split('.')
|
||||
const tamperedBody = Buffer.from(JSON.stringify({ ...BASE, cref: 'other', iat: 1000 })).toString('base64url')
|
||||
expect(verifyConnectorState(`${ver}.${tamperedBody}.${sig}`, 1000)).toEqual({ ok: false, reason: 'bad_signature' })
|
||||
})
|
||||
|
||||
it('rejects a signature made with a different secret', () => {
|
||||
vi.stubEnv('CONNECTOR_STATE_SECRET', 'secret-a')
|
||||
const token = signConnectorState(BASE, 1000)
|
||||
vi.stubEnv('CONNECTOR_STATE_SECRET', 'secret-b')
|
||||
expect(verifyConnectorState(token, 1000)).toEqual({ ok: false, reason: 'bad_signature' })
|
||||
})
|
||||
|
||||
it('expires after the TTL and rejects a future iat', () => {
|
||||
vi.stubEnv('CONNECTOR_STATE_SECRET', 'secret')
|
||||
const token = signConnectorState(BASE, 1000)
|
||||
expect(verifyConnectorState(token, 1000 + 16 * 60 * 1000)).toEqual({ ok: false, reason: 'expired' })
|
||||
const future = signConnectorState(BASE, 10_000_000)
|
||||
expect(verifyConnectorState(future, 1000)).toEqual({ ok: false, reason: 'expired' })
|
||||
})
|
||||
|
||||
it('flags malformed tokens', () => {
|
||||
vi.stubEnv('CONNECTOR_STATE_SECRET', 'secret')
|
||||
expect(verifyConnectorState('nope')).toEqual({ ok: false, reason: 'malformed' })
|
||||
expect(isConnectorState('random-uuid-state')).toBe(false)
|
||||
})
|
||||
|
||||
it('derives a secret from the service-role key when none is set (still verifiable)', () => {
|
||||
vi.stubEnv('CONNECTOR_STATE_SECRET', '')
|
||||
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', 'svc-key')
|
||||
const token = signConnectorState(BASE, 2000)
|
||||
expect(verifyConnectorState(token, 2000).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { budgetFor, reserveUpstream } from '../upstream-budget'
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
function supa(result: { data?: unknown; error?: unknown }) {
|
||||
const rpc = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null })
|
||||
return { supabase: { rpc } as unknown as SupabaseClient, rpc }
|
||||
}
|
||||
|
||||
describe('budgetFor', () => {
|
||||
it('defaults sit under the EB per-minute quota and are env-overridable', () => {
|
||||
expect(budgetFor('bank').minuteMax).toBe(90)
|
||||
vi.stubEnv('CONNECT_BANK_RPM_BUDGET', '50')
|
||||
expect(budgetFor('bank').minuteMax).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reserveUpstream', () => {
|
||||
it('passes the service and the resolved budget to the RPC and returns ok', async () => {
|
||||
const { supabase, rpc } = supa({ data: { ok: true } })
|
||||
expect(await reserveUpstream(supabase, 'bank')).toEqual({ ok: true })
|
||||
expect(rpc).toHaveBeenCalledWith('connector_reserve_upstream', { p_service: 'bank', p_minute_max: 90, p_hour_max: 3000 })
|
||||
})
|
||||
|
||||
it('maps a budget rejection to a Retry-After result', async () => {
|
||||
const { supabase } = supa({ data: { ok: false, scope: 'hour', retry_after_sec: 3600 } })
|
||||
expect(await reserveUpstream(supabase, 'bank')).toEqual({ ok: false, scope: 'hour', retryAfterSec: 3600 })
|
||||
})
|
||||
|
||||
// Fail-open: a broken counter table must not block every connector call.
|
||||
it('fails open on a DB error', async () => {
|
||||
const { supabase } = supa({ error: { message: 'boom' } })
|
||||
expect(await reserveUpstream(supabase, 'skatteverket')).toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,7 @@ vi.mock('@/lib/auth/api-keys', () => ({
|
||||
createServiceClientNoCookies: () => ({ from }),
|
||||
}))
|
||||
|
||||
import { extractConnectorKey, withConnectorAuth } from '../with-connector-auth'
|
||||
import { extractConnectorKey, withConnectorAuth, redactEndpoint } from '../with-connector-auth'
|
||||
|
||||
const VALID = {
|
||||
ok: true,
|
||||
@@ -53,6 +53,23 @@ describe('extractConnectorKey', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('redactEndpoint', () => {
|
||||
it('replaces opaque handle segments with :id so raw EB session ids never rest in metering', () => {
|
||||
expect(redactEndpoint('/api/connect/bank/sessions/8f14e45f-ceea-467f-a8d5-91be6ce7cbc4')).toBe(
|
||||
'/api/connect/bank/sessions/:id',
|
||||
)
|
||||
expect(redactEndpoint('/api/connect/bank/accounts/9a1b2c3d-0000-4111-8222-333344445555/transactions')).toBe(
|
||||
'/api/connect/bank/accounts/:id/transactions',
|
||||
)
|
||||
expect(redactEndpoint('/api/connect/bank/sessions/deadbeefdeadbeefdeadbeef')).toBe('/api/connect/bank/sessions/:id')
|
||||
expect(redactEndpoint('/api/connect/bank/sessions/8f14e45f%2Dceea-467f-a8d5-91be6ce7cbc4')).toBe(
|
||||
'/api/connect/bank/sessions/:id',
|
||||
)
|
||||
expect(redactEndpoint('/api/connect/entitlements')).toBe('/api/connect/entitlements')
|
||||
expect(redactEndpoint('/api/connect/bank/aspsps')).toBe('/api/connect/bank/aspsps')
|
||||
})
|
||||
})
|
||||
|
||||
describe('withConnectorAuth', () => {
|
||||
const handler = vi.fn(async (_req: Request, _ctx: { key: { id: string } }) => NextResponse.json({ data: 'ok' }))
|
||||
const wrapped = withConnectorAuth('connect.entitlements', handler)
|
||||
|
||||
@@ -25,6 +25,18 @@ export function isConnectorKeyFormat(key: string): boolean {
|
||||
return key.startsWith(CONNECTOR_KEY_PREFIX) && key.length > CONNECTOR_KEY_PREFIX.length + 16
|
||||
}
|
||||
|
||||
export interface ConnectorKeyLimits {
|
||||
bank_connections_per_company: number
|
||||
skv_connections_per_company: number
|
||||
sync_min_interval_s: number
|
||||
}
|
||||
|
||||
export const DEFAULT_CONNECTOR_LIMITS: ConnectorKeyLimits = {
|
||||
bank_connections_per_company: 1,
|
||||
skv_connections_per_company: 1,
|
||||
sync_min_interval_s: 0,
|
||||
}
|
||||
|
||||
export interface ValidatedConnectorKey {
|
||||
id: string
|
||||
orgNumber: string
|
||||
@@ -32,6 +44,7 @@ export interface ValidatedConnectorKey {
|
||||
scopes: string[]
|
||||
status: ConnectorKeyStatus
|
||||
currentPeriodEnd: string | null
|
||||
limits: ConnectorKeyLimits
|
||||
}
|
||||
|
||||
export type ConnectorKeyValidation =
|
||||
@@ -80,6 +93,7 @@ export async function validateConnectorKey(
|
||||
status: string
|
||||
current_period_end: string | null
|
||||
rate_limited: boolean
|
||||
limits: Partial<ConnectorKeyLimits> | null
|
||||
}
|
||||
if (row.status !== 'active') {
|
||||
return { ok: false, status: 403, code: 'CONNECTOR_KEY_SUSPENDED', error: 'Connector key is suspended' }
|
||||
@@ -96,6 +110,7 @@ export async function validateConnectorKey(
|
||||
scopes: row.scopes ?? [],
|
||||
status: 'active',
|
||||
currentPeriodEnd: row.current_period_end,
|
||||
limits: { ...DEFAULT_CONNECTOR_LIMITS, ...(row.limits ?? {}) },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import crypto from 'node:crypto'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
/**
|
||||
* The connector connection ledger: proof, without secrets, that a connection
|
||||
* belongs to a given connector key. Every bank/SKV connection is born through
|
||||
* the proxy (the consent redirect is ours), so the proxy records it at
|
||||
* creation and checks ownership on every later use. The upstream handle (EB
|
||||
* session id, SKV access token) is hashed; the value never rests here.
|
||||
*/
|
||||
|
||||
export type ConnectorService = 'bank' | 'skatteverket'
|
||||
|
||||
export function hashHandle(handle: string): string {
|
||||
return crypto.createHash('sha256').update(handle).digest('hex')
|
||||
}
|
||||
|
||||
export interface LedgerRow {
|
||||
id: string
|
||||
connector_key_id: string
|
||||
service: ConnectorService
|
||||
company_ref: string
|
||||
provider: string | null
|
||||
account_uids: string[]
|
||||
status: 'pending' | 'active' | 'revoked'
|
||||
}
|
||||
|
||||
/** Active connections for one company under one key and service. Enforces the per-company limit. */
|
||||
export async function countActiveConnections(
|
||||
supabase: SupabaseClient,
|
||||
keyId: string,
|
||||
service: ConnectorService,
|
||||
companyRef: string,
|
||||
): Promise<number> {
|
||||
const { count, error } = await supabase
|
||||
.from('connector_connections')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('connector_key_id', keyId)
|
||||
.eq('service', service)
|
||||
.eq('company_ref', companyRef)
|
||||
.eq('status', 'active')
|
||||
if (error) throw new Error(`ledger count failed: ${error.message}`)
|
||||
return count ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending rows count toward quota only while their consent window is open:
|
||||
* the signed connector state expires after 15 minutes, so an abandoned
|
||||
* consent stops reserving capacity once its state can no longer activate it.
|
||||
*/
|
||||
export const PENDING_QUOTA_WINDOW_MS = 15 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Rows currently holding or reserving quota for (key, service, company):
|
||||
* active connections plus fresh pending reservations. Used by the /auth
|
||||
* quota check both before insert (fast reject) and after insert (the
|
||||
* reservation re-count that closes the concurrent-auth race).
|
||||
*/
|
||||
export async function countHeldConnections(
|
||||
supabase: SupabaseClient,
|
||||
keyId: string,
|
||||
service: ConnectorService,
|
||||
companyRef: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<number> {
|
||||
const freshPendingSince = new Date(now.getTime() - PENDING_QUOTA_WINDOW_MS).toISOString()
|
||||
const { count, error } = await supabase
|
||||
.from('connector_connections')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('connector_key_id', keyId)
|
||||
.eq('service', service)
|
||||
.eq('company_ref', companyRef)
|
||||
.or(`status.eq.active,and(status.eq.pending,created_at.gte.${freshPendingSince})`)
|
||||
if (error) throw new Error(`ledger count failed: ${error.message}`)
|
||||
return count ?? 0
|
||||
}
|
||||
|
||||
/** Roll back a just-created pending reservation (lost the quota re-count). */
|
||||
export async function deletePendingConnectionById(supabase: SupabaseClient, id: string): Promise<void> {
|
||||
await supabase.from('connector_connections').delete().eq('id', id).eq('status', 'pending')
|
||||
}
|
||||
|
||||
export async function createPendingConnection(
|
||||
supabase: SupabaseClient,
|
||||
params: { keyId: string; service: ConnectorService; companyRef: string; provider: string | null; pendingState: string },
|
||||
): Promise<string> {
|
||||
const { data, error } = await supabase
|
||||
.from('connector_connections')
|
||||
.insert({
|
||||
connector_key_id: params.keyId,
|
||||
service: params.service,
|
||||
company_ref: params.companyRef,
|
||||
provider: params.provider,
|
||||
pending_state: params.pendingState,
|
||||
status: 'pending',
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
if (error || !data) throw new Error(`ledger insert failed: ${error?.message}`)
|
||||
return (data as { id: string }).id
|
||||
}
|
||||
|
||||
/**
|
||||
* The pending row a signed state belongs to, under the presenting key.
|
||||
* Precondition for the code exchange at POST /sessions: exchanging a code
|
||||
* against a state with no pending row would mint an upstream session the
|
||||
* ledger never records (and cross-flow substitution could smuggle a code
|
||||
* into a foreign state). The state's own TTL bounds freshness.
|
||||
*/
|
||||
export async function findPendingByState(
|
||||
supabase: SupabaseClient,
|
||||
params: { keyId: string; pendingState: string },
|
||||
): Promise<LedgerRow | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('connector_connections')
|
||||
.select('id, connector_key_id, service, company_ref, provider, account_uids, status')
|
||||
.eq('connector_key_id', params.keyId)
|
||||
.eq('pending_state', params.pendingState)
|
||||
.eq('status', 'pending')
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`ledger pending lookup failed: ${error.message}`)
|
||||
return (data as LedgerRow | null) ?? null
|
||||
}
|
||||
|
||||
/** Activate a pending connection (found by its signed pending_state) with the live handle + accounts. */
|
||||
export async function activateByPendingState(
|
||||
supabase: SupabaseClient,
|
||||
params: { keyId: string; pendingState: string; handle: string; accountUids?: string[] },
|
||||
): Promise<LedgerRow | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('connector_connections')
|
||||
.update({
|
||||
status: 'active',
|
||||
handle_hash: hashHandle(params.handle),
|
||||
account_uids: params.accountUids ?? [],
|
||||
pending_state: null,
|
||||
activated_at: new Date().toISOString(),
|
||||
last_used_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('connector_key_id', params.keyId)
|
||||
.eq('pending_state', params.pendingState)
|
||||
.eq('status', 'pending')
|
||||
.select('id, connector_key_id, service, company_ref, provider, account_uids, status')
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`ledger activate failed: ${error.message}`)
|
||||
return (data as LedgerRow | null) ?? null
|
||||
}
|
||||
|
||||
/** The active ledger row that owns a given handle under a key. Ownership check for reads/writes. */
|
||||
export async function findByHandle(
|
||||
supabase: SupabaseClient,
|
||||
params: { keyId: string; service: ConnectorService; handle: string },
|
||||
): Promise<LedgerRow | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('connector_connections')
|
||||
.select('id, connector_key_id, service, company_ref, provider, account_uids, status')
|
||||
.eq('connector_key_id', params.keyId)
|
||||
.eq('service', params.service)
|
||||
.eq('handle_hash', hashHandle(params.handle))
|
||||
.eq('status', 'active')
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`ledger lookup failed: ${error.message}`)
|
||||
return (data as LedgerRow | null) ?? null
|
||||
}
|
||||
|
||||
/** The active ledger row that owns a bank account uid under a key. */
|
||||
export async function findByAccountUid(
|
||||
supabase: SupabaseClient,
|
||||
params: { keyId: string; accountUid: string },
|
||||
): Promise<LedgerRow | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('connector_connections')
|
||||
.select('id, connector_key_id, service, company_ref, provider, account_uids, status')
|
||||
.eq('connector_key_id', params.keyId)
|
||||
.eq('service', 'bank')
|
||||
.eq('status', 'active')
|
||||
.contains('account_uids', [params.accountUid])
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`ledger account lookup failed: ${error.message}`)
|
||||
return (data as LedgerRow | null) ?? null
|
||||
}
|
||||
|
||||
export async function touchConnection(supabase: SupabaseClient, id: string): Promise<void> {
|
||||
await supabase.from('connector_connections').update({ last_used_at: new Date().toISOString() }).eq('id', id)
|
||||
}
|
||||
|
||||
/** Revoke by handle (DELETE /sessions). Idempotent. */
|
||||
export async function revokeByHandle(
|
||||
supabase: SupabaseClient,
|
||||
params: { keyId: string; service: ConnectorService; handle: string },
|
||||
): Promise<void> {
|
||||
await supabase
|
||||
.from('connector_connections')
|
||||
.update({ status: 'revoked', revoked_at: new Date().toISOString() })
|
||||
.eq('connector_key_id', params.keyId)
|
||||
.eq('service', params.service)
|
||||
.eq('handle_hash', hashHandle(params.handle))
|
||||
.eq('status', 'active')
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
/**
|
||||
* HMAC-signed connector state for the bank/SKV consent round-trip.
|
||||
*
|
||||
* The instance never registers a redirect URI with Enable Banking or
|
||||
* Skatteverket: the consent redirect goes to OUR hosted callback (already
|
||||
* registered), which then bounces the browser back to the instance. To do
|
||||
* that safely the proxy replaces the upstream `state` with a token that
|
||||
* carries where to return, the original instance state, and which key owns
|
||||
* the flow, all signed so a tampered token is rejected. No storage: the token
|
||||
* is self-contained and short-lived.
|
||||
*
|
||||
* Format: `ck1.<base64url(json)>.<base64url(hmac-sha256)>`.
|
||||
*/
|
||||
|
||||
const VERSION = 'ck1'
|
||||
const DEFAULT_TTL_MS = 15 * 60 * 1000
|
||||
|
||||
export interface ConnectorStatePayload {
|
||||
/** connector_key id that owns this flow. */
|
||||
kid: string
|
||||
/** service: 'bank' | 'skv'. */
|
||||
svc: 'bank' | 'skv'
|
||||
/** Absolute return URL on the instance (the instance's own callback). */
|
||||
ret: string
|
||||
/** The instance's original state value, echoed back untouched. */
|
||||
st: string
|
||||
/** Instance company ref, for the ledger. */
|
||||
cref: string
|
||||
/** issued-at, ms. */
|
||||
iat: number
|
||||
}
|
||||
|
||||
function getSecret(): string {
|
||||
const explicit = process.env.CONNECTOR_STATE_SECRET?.trim()
|
||||
if (explicit) return explicit
|
||||
// Fall back to a value derived from the service-role key so a deployment
|
||||
// that forgot to set the dedicated secret still signs consistently. Never
|
||||
// the raw key: a one-way derivation so the signing secret can't be reversed
|
||||
// into the Supabase credential.
|
||||
const svc = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
if (!svc) throw new Error('CONNECTOR_STATE_SECRET (or SUPABASE_SERVICE_ROLE_KEY) is required to sign connector state')
|
||||
return crypto.createHash('sha256').update(`connector-state:${svc}`).digest('hex')
|
||||
}
|
||||
|
||||
function b64urlEncode(buf: Buffer): string {
|
||||
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
function b64urlDecode(s: string): Buffer {
|
||||
return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
|
||||
}
|
||||
|
||||
export function signConnectorState(payload: Omit<ConnectorStatePayload, 'iat'>, now = Date.now()): string {
|
||||
const body = b64urlEncode(Buffer.from(JSON.stringify({ ...payload, iat: now })))
|
||||
const sig = crypto.createHmac('sha256', getSecret()).update(`${VERSION}.${body}`).digest()
|
||||
return `${VERSION}.${body}.${b64urlEncode(sig)}`
|
||||
}
|
||||
|
||||
export type VerifyStateResult =
|
||||
| { ok: true; payload: ConnectorStatePayload }
|
||||
| { ok: false; reason: 'malformed' | 'bad_signature' | 'expired' }
|
||||
|
||||
export function verifyConnectorState(token: string, now = Date.now(), ttlMs = DEFAULT_TTL_MS): VerifyStateResult {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3 || parts[0] !== VERSION) return { ok: false, reason: 'malformed' }
|
||||
const [, body, sig] = parts
|
||||
const expected = crypto.createHmac('sha256', getSecret()).update(`${VERSION}.${body}`).digest()
|
||||
const given = b64urlDecode(sig)
|
||||
if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
|
||||
return { ok: false, reason: 'bad_signature' }
|
||||
}
|
||||
let payload: ConnectorStatePayload
|
||||
try {
|
||||
payload = JSON.parse(b64urlDecode(body).toString('utf8')) as ConnectorStatePayload
|
||||
} catch {
|
||||
return { ok: false, reason: 'malformed' }
|
||||
}
|
||||
if (typeof payload.iat !== 'number' || now - payload.iat > ttlMs || payload.iat > now + 60_000) {
|
||||
return { ok: false, reason: 'expired' }
|
||||
}
|
||||
return { ok: true, payload }
|
||||
}
|
||||
|
||||
/** True when a raw upstream `state` value is one of our signed connector states. */
|
||||
export function isConnectorState(state: string | null | undefined): boolean {
|
||||
return typeof state === 'string' && state.startsWith(`${VERSION}.`)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
/**
|
||||
* Global upstream rate budget for the connector proxy.
|
||||
*
|
||||
* Enable Banking's quotas (Annex 1 §5: 20 rps / 300 rpm / 10 000 per hour) are
|
||||
* shared by ALL of Arcim's traffic, hosted included. So connector traffic gets
|
||||
* a ceiling well under those, reserved atomically in the DB (RPC
|
||||
* connector_reserve_upstream) so two proxy requests can't both slip past. A
|
||||
* self-hoster that hits the ceiling gets a 429 with Retry-After; hosted bank
|
||||
* sync is never starved because the connector ceiling is a fraction of the
|
||||
* provider quota.
|
||||
*
|
||||
* Configurable per service via env; the defaults sit around 30% of the EB
|
||||
* per-minute quota.
|
||||
*/
|
||||
|
||||
export type UpstreamService = 'bank' | 'skatteverket'
|
||||
|
||||
interface Budget {
|
||||
minuteMax: number
|
||||
hourMax: number
|
||||
}
|
||||
|
||||
function intFromEnv(name: string, fallback: number): number {
|
||||
const v = Number(process.env[name])
|
||||
return Number.isFinite(v) && v > 0 ? Math.floor(v) : fallback
|
||||
}
|
||||
|
||||
export function budgetFor(service: UpstreamService): Budget {
|
||||
if (service === 'bank') {
|
||||
return {
|
||||
minuteMax: intFromEnv('CONNECT_BANK_RPM_BUDGET', 90), // ~30% of EB's 300/min
|
||||
hourMax: intFromEnv('CONNECT_BANK_RPH_BUDGET', 3000), // ~30% of EB's 10 000/h
|
||||
}
|
||||
}
|
||||
return {
|
||||
minuteMax: intFromEnv('CONNECT_SKV_RPM_BUDGET', 120),
|
||||
hourMax: intFromEnv('CONNECT_SKV_RPH_BUDGET', 4000),
|
||||
}
|
||||
}
|
||||
|
||||
export type BudgetResult = { ok: true } | { ok: false; scope: 'minute' | 'hour'; retryAfterSec: number }
|
||||
|
||||
/**
|
||||
* Reserve one upstream call. Returns ok:false with a Retry-After when the
|
||||
* global budget for this service is exhausted. A DB error fails OPEN (ok:true):
|
||||
* the budget is a protective cap, not an auth boundary, and blocking every
|
||||
* connector call because the counter table hiccuped would be worse than a
|
||||
* brief overshoot the provider itself also rate-limits.
|
||||
*/
|
||||
export async function reserveUpstream(
|
||||
supabase: SupabaseClient,
|
||||
service: UpstreamService,
|
||||
): Promise<BudgetResult> {
|
||||
const { minuteMax, hourMax } = budgetFor(service)
|
||||
const { data, error } = await supabase.rpc('connector_reserve_upstream', {
|
||||
p_service: service,
|
||||
p_minute_max: minuteMax,
|
||||
p_hour_max: hourMax,
|
||||
})
|
||||
if (error) return { ok: true }
|
||||
const row = (data ?? {}) as { ok?: boolean; scope?: 'minute' | 'hour'; retry_after_sec?: number }
|
||||
if (row.ok === false) {
|
||||
return { ok: false, scope: row.scope ?? 'minute', retryAfterSec: row.retry_after_sec ?? 60 }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -45,6 +45,23 @@ export function extractConnectorKey(request: Request): string | null {
|
||||
return bearer
|
||||
}
|
||||
|
||||
/**
|
||||
* Opaque path segments (UUIDs, long hex, long base64url tokens) become ':id'
|
||||
* before a path is persisted for metering. Literal route words (sessions,
|
||||
* accounts, balances, aspsps, ...) survive, so the metric keys stay useful.
|
||||
*/
|
||||
const OPAQUE_SEGMENT = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{16,}|[A-Za-z0-9_-]{20,})$/i
|
||||
|
||||
export function redactEndpoint(pathname: string): string {
|
||||
return pathname
|
||||
.split('/')
|
||||
// A percent-encoded segment is opaque too: URL.pathname does not decode,
|
||||
// so an encoded handle would otherwise slip the pattern while the route
|
||||
// decodes and uses it.
|
||||
.map((segment) => (OPAQUE_SEGMENT.test(segment) || segment.includes('%') ? ':id' : segment))
|
||||
.join('/')
|
||||
}
|
||||
|
||||
export function withConnectorAuth(
|
||||
operation: string,
|
||||
handler: ConnectorHandler,
|
||||
@@ -84,10 +101,14 @@ export function withConnectorAuth(
|
||||
}
|
||||
response.headers.set('X-Request-Id', requestId)
|
||||
|
||||
// Metering: one row per request, never on the critical path.
|
||||
// Metering: one row per request, never on the critical path. The path is
|
||||
// REDACTED first: proxied paths carry the EB session id / account uid as
|
||||
// segments, and the whole ledger design is that those handles never rest
|
||||
// hosted-side (connector_connections stores sha256 only). Persisting the
|
||||
// raw pathname would put the cleartext handle in connector_usage_events.
|
||||
const endpoint = (() => {
|
||||
try {
|
||||
return new URL(request.url).pathname
|
||||
return redactEndpoint(new URL(request.url).pathname)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* JWT generation for Enable Banking API authentication
|
||||
*
|
||||
* Enable Banking requires JWT tokens signed with RS256 using your private key.
|
||||
* The JWT is included in the Authorization header for all API calls.
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto'
|
||||
|
||||
// Prefer _PRODUCTION variants when available (Vercel production deploys)
|
||||
const APP_ID = process.env.ENABLE_BANKING_APP_ID_PRODUCTION || process.env.ENABLE_BANKING_APP_ID
|
||||
const PRIVATE_KEY_RAW = process.env.ENABLE_BANKING_PRIVATE_KEY_PRODUCTION || process.env.ENABLE_BANKING_PRIVATE_KEY
|
||||
|
||||
interface JWTHeader {
|
||||
typ: string
|
||||
alg: string
|
||||
kid: string
|
||||
}
|
||||
|
||||
interface JWTPayload {
|
||||
iss: string
|
||||
aud: string
|
||||
iat: number
|
||||
exp: number
|
||||
}
|
||||
|
||||
function base64UrlEncode(data: Buffer | string): string {
|
||||
const str = typeof data === 'string' ? data : data.toString('base64')
|
||||
return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function getPrivateKey(): string {
|
||||
if (!PRIVATE_KEY_RAW) {
|
||||
throw new Error('ENABLE_BANKING_PRIVATE_KEY environment variable is not set')
|
||||
}
|
||||
|
||||
// Try decoding as base64-encoded PEM (sandbox format: base64 wrapping a PEM string)
|
||||
const decoded = Buffer.from(PRIVATE_KEY_RAW, 'base64').toString('utf-8')
|
||||
if (decoded.startsWith('-----BEGIN')) {
|
||||
return decoded
|
||||
}
|
||||
|
||||
// Otherwise treat as raw base64 DER key material: wrap in PEM headers
|
||||
const lines = PRIVATE_KEY_RAW.match(/.{1,64}/g) || []
|
||||
return `-----BEGIN PRIVATE KEY-----\n${lines.join('\n')}\n-----END PRIVATE KEY-----`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT token for Enable Banking API authentication
|
||||
*
|
||||
* @param expiresInSeconds - Token validity in seconds (default: 3600 = 1 hour)
|
||||
* @returns Signed JWT token
|
||||
*/
|
||||
export function generateJWT(expiresInSeconds: number = 3600): string {
|
||||
if (!APP_ID) {
|
||||
throw new Error('ENABLE_BANKING_APP_ID environment variable is not set')
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
const header: JWTHeader = {
|
||||
typ: 'JWT',
|
||||
alg: 'RS256',
|
||||
kid: APP_ID
|
||||
}
|
||||
|
||||
const payload: JWTPayload = {
|
||||
iss: 'enablebanking.com',
|
||||
aud: 'api.enablebanking.com',
|
||||
iat: now,
|
||||
exp: now + expiresInSeconds
|
||||
}
|
||||
|
||||
// Encode header and payload
|
||||
const headerBase64 = base64UrlEncode(Buffer.from(JSON.stringify(header)))
|
||||
const payloadBase64 = base64UrlEncode(Buffer.from(JSON.stringify(payload)))
|
||||
|
||||
// Create signature
|
||||
const signatureInput = `${headerBase64}.${payloadBase64}`
|
||||
const privateKey = getPrivateKey()
|
||||
|
||||
const sign = crypto.createSign('RSA-SHA256')
|
||||
sign.update(signatureInput)
|
||||
sign.end()
|
||||
|
||||
const signature = sign.sign(privateKey)
|
||||
const signatureBase64 = base64UrlEncode(signature)
|
||||
|
||||
return `${headerBase64}.${payloadBase64}.${signatureBase64}`
|
||||
}
|
||||
|
||||
// JWT token cache
|
||||
let cachedToken: string | null = null
|
||||
let cachedTokenExpiry: number = 0
|
||||
|
||||
/**
|
||||
* Get the Authorization header value for Enable Banking API.
|
||||
* Caches JWT tokens and reuses them until 60s before expiry.
|
||||
*/
|
||||
export function getAuthorizationHeader(): string {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
if (cachedToken && now < cachedTokenExpiry - 60) {
|
||||
return `Bearer ${cachedToken}`
|
||||
}
|
||||
|
||||
const expiresInSeconds = 3600
|
||||
const token = generateJWT(expiresInSeconds)
|
||||
cachedToken = token
|
||||
cachedTokenExpiry = now + expiresInSeconds
|
||||
return `Bearer ${token}`
|
||||
}
|
||||
|
||||
/** @internal Reset token cache: for testing only */
|
||||
export function _resetTokenCache(): void {
|
||||
cachedToken = null
|
||||
cachedTokenExpiry = 0
|
||||
}
|
||||
@@ -38,7 +38,10 @@ async function main(): Promise<void> {
|
||||
const name = arg('name') ?? ''
|
||||
const instance = arg('instance') ?? ''
|
||||
const months = Number(arg('months') ?? '12')
|
||||
const scopes = (arg('scopes') ?? CONNECTOR_CAPABILITIES.join(',')).split(',').map((s) => s.trim()).filter(Boolean)
|
||||
const scopes = (arg('scopes') ?? 'bank_sync,skatteverket').split(',').map((s) => s.trim()).filter(Boolean)
|
||||
const bankPerCompany = Number(arg('bank-connections-per-company') ?? '1')
|
||||
const skvPerCompany = Number(arg('skv-connections-per-company') ?? '1')
|
||||
const syncMinInterval = Number(arg('sync-min-interval') ?? '0')
|
||||
const notes = arg('notes') ?? null
|
||||
|
||||
const problems: string[] = []
|
||||
@@ -53,6 +56,10 @@ async function main(): Promise<void> {
|
||||
if (!Number.isInteger(months) || months <= 0 || months > 120) problems.push('--months must be an integer 1..120')
|
||||
const unknown = scopes.filter((s) => !(CONNECTOR_CAPABILITIES as readonly string[]).includes(s))
|
||||
if (unknown.length) problems.push(`unknown scopes: ${unknown.join(', ')} (allowed: ${CONNECTOR_CAPABILITIES.join(', ')})`)
|
||||
for (const [name, v] of [['bank-connections-per-company', bankPerCompany], ['skv-connections-per-company', skvPerCompany]] as const) {
|
||||
if (!Number.isFinite(v) || v < 0 || v > 100) problems.push(`--${name} must be 0..100`)
|
||||
}
|
||||
if (!Number.isFinite(syncMinInterval) || syncMinInterval < 0) problems.push('--sync-min-interval must be >= 0 seconds')
|
||||
if (problems.length) {
|
||||
console.error(problems.map((p) => ` x ${p}`).join('\n'))
|
||||
process.exit(2)
|
||||
@@ -71,6 +78,7 @@ async function main(): Promise<void> {
|
||||
console.log(`Licensee: ${name} (${org})`)
|
||||
console.log(`Instance: ${new URL(instance).origin}`)
|
||||
console.log(`Scopes: ${scopes.join(', ')}`)
|
||||
console.log(`Limits: ${bankPerCompany} bank + ${skvPerCompany} SKV connection(s)/company, min sync interval ${syncMinInterval}s`)
|
||||
console.log(`Period: until ${periodEnd.toISOString().slice(0, 10)} (${months} months)`)
|
||||
if (!flag('confirm')) {
|
||||
console.log('\nDry run. Re-run with --confirm to issue the key.')
|
||||
@@ -90,6 +98,11 @@ async function main(): Promise<void> {
|
||||
scopes,
|
||||
status: 'active',
|
||||
current_period_end: periodEnd.toISOString(),
|
||||
limits: {
|
||||
bank_connections_per_company: Math.floor(bankPerCompany),
|
||||
skv_connections_per_company: Math.floor(skvPerCompany),
|
||||
sync_min_interval_s: Math.floor(syncMinInterval),
|
||||
},
|
||||
notes,
|
||||
})
|
||||
.select('id')
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
-- Connector proxy: per-connection ledger, global upstream budget, key limits.
|
||||
--
|
||||
-- WS3 PR5. The hosted proxy under /api/connect/* brokers Enable Banking (and,
|
||||
-- next, Skatteverket) for self-hosted instances. Tokens stay on the instance;
|
||||
-- the proxy holds NO usable credential per customer. What it needs hosted-side
|
||||
-- is only: (1) a way to recognise a connection an instance obtained through
|
||||
-- its own key so it cannot use one it did not create or one belonging to
|
||||
-- another instance, (2) a global rate budget so all connector traffic plus
|
||||
-- hosted stays under Enable Banking's shared quota (Annex 1 §5: 20 rps /
|
||||
-- 300 rpm / 10 000 per hour), and (3) per-key entitlement limits (e.g. one
|
||||
-- bank connection per company) that the package sells.
|
||||
--
|
||||
-- Everything here is service-role only (RLS on, no policies); the validate and
|
||||
-- reserve RPCs are SECURITY DEFINER and REVOKEd from PUBLIC/anon/authenticated.
|
||||
|
||||
-- 1. Per-key limits (the sellable package shape). Defaulted so existing keys
|
||||
-- get sane values without a data backfill.
|
||||
ALTER TABLE public.connector_keys
|
||||
ADD COLUMN limits jsonb NOT NULL DEFAULT
|
||||
'{"bank_connections_per_company": 1, "skv_connections_per_company": 1, "sync_min_interval_s": 0}'::jsonb;
|
||||
|
||||
-- 2. Connection ledger: one row per bank/SKV connection an instance holds,
|
||||
-- identified by the SHA-256 of the upstream handle (EB session id, SKV
|
||||
-- access token). The handle value itself is NEVER stored: the row proves
|
||||
-- "this key created this connection for this company_ref" and nothing more.
|
||||
CREATE TABLE public.connector_connections (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
connector_key_id uuid NOT NULL REFERENCES public.connector_keys(id) ON DELETE CASCADE,
|
||||
service text NOT NULL CHECK (service IN ('bank', 'skatteverket')),
|
||||
-- The instance's own company id, opaque to us: the "per company" unit for
|
||||
-- the entitlement limit. Never resolvable to a hosted company.
|
||||
company_ref text NOT NULL,
|
||||
provider text,
|
||||
-- SHA-256 of the live handle (EB session_id / SKV access_token). Rotated on
|
||||
-- refresh. Unique per service so a handle maps to at most one ledger row.
|
||||
handle_hash text,
|
||||
-- SHA-256 of the SKV refresh token, so a refresh exchange can be tied back
|
||||
-- to its connection without storing the token.
|
||||
refresh_hash text,
|
||||
-- Bank account uids this connection covers (EB), for ownership checks on
|
||||
-- /accounts/{uid}/... calls.
|
||||
account_uids text[] NOT NULL DEFAULT '{}',
|
||||
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'revoked')),
|
||||
-- Signed connector-state nonce while a consent round-trip is in flight, so
|
||||
-- the callback can find the pending row.
|
||||
pending_state text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
activated_at timestamptz,
|
||||
last_used_at timestamptz,
|
||||
revoked_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX idx_connector_connections_key ON public.connector_connections (connector_key_id, service, status);
|
||||
CREATE INDEX idx_connector_connections_company ON public.connector_connections (connector_key_id, company_ref, service) WHERE status = 'active';
|
||||
CREATE UNIQUE INDEX idx_connector_connections_handle ON public.connector_connections (service, handle_hash) WHERE handle_hash IS NOT NULL;
|
||||
CREATE INDEX idx_connector_connections_pending_state ON public.connector_connections (pending_state) WHERE pending_state IS NOT NULL;
|
||||
|
||||
ALTER TABLE public.connector_connections ENABLE ROW LEVEL SECURITY;
|
||||
-- No policies: service role only.
|
||||
|
||||
-- 3. Global upstream budget. One shared counter per (service, window), so all
|
||||
-- connector traffic is bounded well below the ASPSP-provider quota that is
|
||||
-- shared with hosted. Same shape and RPC pattern as agent_rate_counters.
|
||||
CREATE TABLE public.connector_upstream_counters (
|
||||
service text NOT NULL,
|
||||
window_kind text NOT NULL CHECK (window_kind IN ('minute', 'hour')),
|
||||
window_key text NOT NULL,
|
||||
count integer NOT NULL DEFAULT 0,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (service, window_kind, window_key)
|
||||
);
|
||||
|
||||
ALTER TABLE public.connector_upstream_counters ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY connector_upstream_counters_no_select ON public.connector_upstream_counters FOR SELECT USING (false);
|
||||
|
||||
-- Reserve one upstream call against the global budget. Returns { ok, scope,
|
||||
-- retry_after_sec }. Atomic increment-then-check with rollback, so two
|
||||
-- concurrent proxy requests cannot both slip past the ceiling.
|
||||
CREATE OR REPLACE FUNCTION public.connector_reserve_upstream(
|
||||
p_service text,
|
||||
p_minute_max integer,
|
||||
p_hour_max integer
|
||||
) RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_minute_key text := to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI');
|
||||
v_hour_key text := to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24');
|
||||
v_minute_count integer;
|
||||
v_hour_count integer;
|
||||
BEGIN
|
||||
INSERT INTO public.connector_upstream_counters (service, window_kind, window_key, count)
|
||||
VALUES (p_service, 'minute', v_minute_key, 1)
|
||||
ON CONFLICT (service, window_kind, window_key)
|
||||
DO UPDATE SET count = connector_upstream_counters.count + 1, updated_at = now()
|
||||
RETURNING count INTO v_minute_count;
|
||||
|
||||
IF v_minute_count > p_minute_max THEN
|
||||
UPDATE public.connector_upstream_counters SET count = count - 1
|
||||
WHERE service = p_service AND window_kind = 'minute' AND window_key = v_minute_key;
|
||||
RETURN jsonb_build_object('ok', false, 'scope', 'minute', 'retry_after_sec', 60);
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.connector_upstream_counters (service, window_kind, window_key, count)
|
||||
VALUES (p_service, 'hour', v_hour_key, 1)
|
||||
ON CONFLICT (service, window_kind, window_key)
|
||||
DO UPDATE SET count = connector_upstream_counters.count + 1, updated_at = now()
|
||||
RETURNING count INTO v_hour_count;
|
||||
|
||||
IF v_hour_count > p_hour_max THEN
|
||||
UPDATE public.connector_upstream_counters SET count = count - 1
|
||||
WHERE service = p_service AND window_kind = 'hour' AND window_key = v_hour_key;
|
||||
UPDATE public.connector_upstream_counters SET count = count - 1
|
||||
WHERE service = p_service AND window_kind = 'minute' AND window_key = v_minute_key;
|
||||
RETURN jsonb_build_object('ok', false, 'scope', 'hour', 'retry_after_sec', 3600);
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('ok', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.connector_reserve_upstream(text, integer, integer) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.connector_reserve_upstream(text, integer, integer) TO service_role;
|
||||
|
||||
-- 4. validate RPC v2: also return the key's limits, so the proxy has the
|
||||
-- entitlement caps in the same round-trip as auth. Additive: the return
|
||||
-- columns 1-7 are unchanged from 20260831190000, `limits` is appended.
|
||||
DROP FUNCTION IF EXISTS public.validate_and_increment_connector_key(text);
|
||||
CREATE FUNCTION public.validate_and_increment_connector_key(p_key_hash text)
|
||||
RETURNS TABLE(
|
||||
connector_key_id uuid,
|
||||
org_number text,
|
||||
instance_url text,
|
||||
scopes text[],
|
||||
status text,
|
||||
current_period_end timestamptz,
|
||||
rate_limited boolean,
|
||||
limits jsonb
|
||||
)
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id uuid;
|
||||
v_org_number text;
|
||||
v_instance_url text;
|
||||
v_scopes text[];
|
||||
v_status text;
|
||||
v_period_end timestamptz;
|
||||
v_limits jsonb;
|
||||
v_rate_limit_rpm integer;
|
||||
v_request_count integer;
|
||||
v_window_start timestamptz;
|
||||
BEGIN
|
||||
SELECT ck.id, ck.org_number, ck.instance_url, ck.scopes, ck.status, ck.current_period_end, ck.limits,
|
||||
ck.rate_limit_rpm, ck.request_count, ck.rate_limit_window_start
|
||||
INTO v_id, v_org_number, v_instance_url, v_scopes, v_status, v_period_end, v_limits,
|
||||
v_rate_limit_rpm, v_request_count, v_window_start
|
||||
FROM public.connector_keys ck
|
||||
WHERE ck.key_hash = p_key_hash
|
||||
AND ck.revoked_at IS NULL
|
||||
AND ck.status <> 'revoked'
|
||||
FOR UPDATE;
|
||||
|
||||
IF v_id IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
IF v_status <> 'active' THEN
|
||||
UPDATE public.connector_keys SET last_seen_at = now() WHERE id = v_id;
|
||||
RETURN QUERY SELECT v_id, v_org_number, v_instance_url, v_scopes, v_status, v_period_end, false, v_limits;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
IF v_window_start IS NULL OR v_window_start < now() - interval '1 minute' THEN
|
||||
UPDATE public.connector_keys
|
||||
SET request_count = 1, rate_limit_window_start = now(), last_seen_at = now()
|
||||
WHERE id = v_id;
|
||||
RETURN QUERY SELECT v_id, v_org_number, v_instance_url, v_scopes, v_status, v_period_end, false, v_limits;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
IF v_request_count >= v_rate_limit_rpm THEN
|
||||
RETURN QUERY SELECT v_id, v_org_number, v_instance_url, v_scopes, v_status, v_period_end, true, v_limits;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
UPDATE public.connector_keys
|
||||
SET request_count = request_count + 1, last_seen_at = now()
|
||||
WHERE id = v_id;
|
||||
|
||||
RETURN QUERY SELECT v_id, v_org_number, v_instance_url, v_scopes, v_status, v_period_end, false, v_limits;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.validate_and_increment_connector_key(text) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.validate_and_increment_connector_key(text) TO service_role;
|
||||
|
||||
COMMENT ON TABLE public.connector_connections IS
|
||||
'Secret-free ledger of bank/SKV connections a self-hosted instance obtained through its connector key. handle_hash = sha256 of the upstream handle; the handle never rests here. Service-role only.';
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { getPool, withUserContext } from './setup'
|
||||
import { insertAuthUser } from './fixtures'
|
||||
|
||||
// pg-real for migration 20260831200000: the connection ledger, the global
|
||||
// upstream budget RPC, and validate v2 returning limits. Service-role-only
|
||||
// exposure is asserted the same way as the base connector-keys test.
|
||||
|
||||
function hash(s: string): string {
|
||||
return createHash('sha256').update(s).digest('hex')
|
||||
}
|
||||
|
||||
async function insertKey(limits?: Record<string, number>): Promise<{ id: string; hash: string }> {
|
||||
const key = `gnubok_ck_${randomBytes(16).toString('base64url')}`
|
||||
const h = hash(key)
|
||||
const { rows } = await getPool().query<{ id: string }>(
|
||||
`INSERT INTO public.connector_keys (key_hash, key_prefix, org_number, scopes, status${limits ? ', limits' : ''})
|
||||
VALUES ($1, $2, '5561234567', ARRAY['bank_sync','skatteverket'], 'active'${limits ? ', $3' : ''})
|
||||
RETURNING id`,
|
||||
limits ? [h, key.slice(0, 18), JSON.stringify(limits)] : [h, key.slice(0, 18)],
|
||||
)
|
||||
return { id: rows[0].id, hash: h }
|
||||
}
|
||||
|
||||
describe('validate_and_increment_connector_key v2', () => {
|
||||
it('returns the key limits (default when unset)', async () => {
|
||||
const { hash: h } = await insertKey()
|
||||
const { rows } = await getPool().query(`SELECT * FROM public.validate_and_increment_connector_key($1)`, [h])
|
||||
expect(rows[0].limits).toEqual({ bank_connections_per_company: 1, skv_connections_per_company: 1, sync_min_interval_s: 0 })
|
||||
})
|
||||
|
||||
it('returns custom limits verbatim', async () => {
|
||||
const { hash: h } = await insertKey({ bank_connections_per_company: 3, skv_connections_per_company: 2, sync_min_interval_s: 1800 })
|
||||
const { rows } = await getPool().query(`SELECT * FROM public.validate_and_increment_connector_key($1)`, [h])
|
||||
expect(rows[0].limits).toEqual({ bank_connections_per_company: 3, skv_connections_per_company: 2, sync_min_interval_s: 1800 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('connector_reserve_upstream', () => {
|
||||
it('reserves under the ceiling and rejects over it, service-scoped', async () => {
|
||||
const svc = `bank-${randomBytes(4).toString('hex')}`
|
||||
const call = async () => {
|
||||
const { rows } = await getPool().query(`SELECT public.connector_reserve_upstream($1, 2, 100) AS r`, [svc])
|
||||
return rows[0].r as { ok: boolean; scope?: string }
|
||||
}
|
||||
expect((await call()).ok).toBe(true)
|
||||
expect((await call()).ok).toBe(true)
|
||||
const third = await call()
|
||||
expect(third.ok).toBe(false)
|
||||
expect(third.scope).toBe('minute')
|
||||
// A different service has its own budget.
|
||||
const { rows } = await getPool().query(`SELECT public.connector_reserve_upstream($1, 2, 100) AS r`, [`skv-${randomBytes(4).toString('hex')}`])
|
||||
expect((rows[0].r as { ok: boolean }).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('is executable by service_role only', async () => {
|
||||
const { rows } = await getPool().query<{ role: string; ok: boolean }>(
|
||||
`SELECT r.role, has_function_privilege(r.role, 'public.connector_reserve_upstream(text,integer,integer)', 'execute') AS ok
|
||||
FROM (VALUES ('anon'), ('authenticated'), ('service_role')) AS r(role)`,
|
||||
)
|
||||
expect(Object.fromEntries(rows.map((r) => [r.role, r.ok]))).toEqual({ anon: false, authenticated: false, service_role: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('connector_connections ledger', () => {
|
||||
it('enforces the handle uniqueness per service and cascades with the key', async () => {
|
||||
const { id: keyId } = await insertKey()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.connector_connections (connector_key_id, service, company_ref, handle_hash, status)
|
||||
VALUES ($1, 'bank', 'c1', $2, 'active')`,
|
||||
[keyId, hash('sess-1')],
|
||||
)
|
||||
await expect(
|
||||
getPool().query(
|
||||
`INSERT INTO public.connector_connections (connector_key_id, service, company_ref, handle_hash, status)
|
||||
VALUES ($1, 'bank', 'c2', $2, 'active')`,
|
||||
[keyId, hash('sess-1')],
|
||||
),
|
||||
).rejects.toThrow(/idx_connector_connections_handle|duplicate key/)
|
||||
await getPool().query(`DELETE FROM public.connector_keys WHERE id = $1`, [keyId])
|
||||
const { rows } = await getPool().query(`SELECT count(*)::int AS n FROM public.connector_connections WHERE connector_key_id = $1`, [keyId])
|
||||
expect(rows[0].n).toBe(0)
|
||||
})
|
||||
|
||||
it('is invisible to an authenticated user (service-role only)', async () => {
|
||||
const { id: keyId } = await insertKey()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.connector_connections (connector_key_id, service, company_ref, status) VALUES ($1, 'bank', 'c1', 'pending')`,
|
||||
[keyId],
|
||||
)
|
||||
const userId = await insertAuthUser()
|
||||
await withUserContext(userId, async (client) => {
|
||||
const r = await client.query(`SELECT id FROM public.connector_connections`)
|
||||
expect(r.rowCount).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -136,7 +136,10 @@ const KNOWN_STALE_ON_CONFLICT: Record<string, string> = {}
|
||||
// bulk write); the columns it writes are the same five the Stripe grant writer
|
||||
// uses literally, so the literal guard already covers them. Merged with main
|
||||
// at 389: 390.
|
||||
const UNRESOLVED_CEILING = 390
|
||||
// 2026-08-31: +1 for lib/connect/hosted/ledger.ts countHeldConnections, whose
|
||||
// .or() filter interpolates a computed timestamp (fresh-pending quota window);
|
||||
// the columns it references (status, created_at) are literals in the string.
|
||||
const UNRESOLVED_CEILING = 391
|
||||
|
||||
/**
|
||||
* Floor on statically resolved column references. Guards the guard: if a change
|
||||
|
||||
Reference in New Issue
Block a user