diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4f736a24..7f4cd6fd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -98,6 +98,14 @@ The boundary is strict and CI-enforced: extensions enabled, so a direct import breaks the build. - Extensions integrate through the event bus and documented extension APIs, and are wired via a generated static registry (`npm run setup:extensions`). +- Provider integrations (banks, Skatteverket, Peppol, migration sources) are + moving behind the connector: a self-hosted instance with a connector key + and no credentials of its own for an upstream reaches that upstream through + the hosted `app/api/connect/*` side (an instance running on its own + registered credentials talks to the provider directly, see + `docs/SELF-HOSTING.md`), and the open ledger keeps the contract plus the + manual file paths. `npm run check:guards` ratchets the set of files that + name a provider API host directly; that set may only shrink. Licensing follows the same boundary: the project is AGPL-3.0, with an extension exception that allows third-party extensions using only the diff --git a/extensions/general/arcim-migration/lib/__tests__/arcim-client.test.ts b/extensions/general/arcim-migration/lib/__tests__/arcim-client.test.ts deleted file mode 100644 index e2c60854..00000000 --- a/extensions/general/arcim-migration/lib/__tests__/arcim-client.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' - -vi.stubEnv('ARCIM_SYNC_GATEWAY_URL', 'https://arcim.test.com') -vi.stubEnv('ARCIM_SYNC_API_KEY', 'test-api-key') - -import { getConsent, createConsent, fetchCompanyInfo, fetchCustomers } from '../arcim-client' - -describe('arcim-client', () => { - let fetchSpy: ReturnType - let warnSpy: ReturnType - - beforeEach(() => { - vi.clearAllMocks() - fetchSpy = vi.spyOn(globalThis, 'fetch') - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - - afterEach(() => { - fetchSpy.mockRestore() - warnSpy.mockRestore() - }) - - // ------------------------------------------------------------------------- - // Auth & headers - // ------------------------------------------------------------------------- - describe('request headers', () => { - it('sends Authorization and Content-Type headers', async () => { - fetchSpy.mockResolvedValueOnce( - new Response(JSON.stringify({ id: 'c1', status: 'active' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) - - await getConsent('c1') - - expect(fetchSpy).toHaveBeenCalledTimes(1) - const [, opts] = fetchSpy.mock.calls[0] - expect(opts?.headers).toMatchObject({ - Authorization: 'Bearer test-api-key', - 'Content-Type': 'application/json', - }) - }) - }) - - // ------------------------------------------------------------------------- - // Retry on retryable HTTP status - // ------------------------------------------------------------------------- - describe('retry', () => { - it('retries on 503 and succeeds', async () => { - const fail = new Response('Service Unavailable', { status: 503 }) - const success = new Response( - JSON.stringify({ id: 'c1', status: 'active' }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ) - - fetchSpy - .mockResolvedValueOnce(fail) - .mockResolvedValueOnce(success) - - const result = await getConsent('c1') - expect(result).toEqual({ id: 'c1', status: 'active' }) - expect(fetchSpy).toHaveBeenCalledTimes(2) - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('returned 503, retrying') - ) - }) - - it('retries on 429 (rate limit) and succeeds', async () => { - const rateLimit = new Response('Too Many Requests', { status: 429 }) - const success = new Response( - JSON.stringify({ id: 'c1', status: 'active' }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ) - - fetchSpy - .mockResolvedValueOnce(rateLimit) - .mockResolvedValueOnce(success) - - const result = await getConsent('c1') - expect(result).toEqual({ id: 'c1', status: 'active' }) - expect(fetchSpy).toHaveBeenCalledTimes(2) - }) - - it('retries on 502 and 504 as well', async () => { - const bad502 = new Response('Bad Gateway', { status: 502 }) - const bad504 = new Response('Gateway Timeout', { status: 504 }) - const success = new Response( - JSON.stringify({ id: 'c1', status: 'active' }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ) - - fetchSpy - .mockResolvedValueOnce(bad502) - .mockResolvedValueOnce(bad504) - .mockResolvedValueOnce(success) - - const result = await getConsent('c1') - expect(result).toEqual({ id: 'c1', status: 'active' }) - expect(fetchSpy).toHaveBeenCalledTimes(3) - }) - - it('throws after exhausting all retries on retryable status', async () => { - const fail = new Response('Service Unavailable', { status: 503 }) - - fetchSpy - .mockResolvedValueOnce(fail.clone()) - .mockResolvedValueOnce(fail.clone()) - .mockResolvedValueOnce(fail.clone()) - - await expect(getConsent('c1')).rejects.toThrow('Arcim API 503') - expect(fetchSpy).toHaveBeenCalledTimes(3) // 1 original + 2 retries - }) - - it('does not retry on 400 errors', async () => { - const badRequest = new Response('Bad Request', { status: 400 }) - fetchSpy.mockResolvedValueOnce(badRequest) - - await expect(getConsent('c1')).rejects.toThrow('Arcim API 400') - expect(fetchSpy).toHaveBeenCalledTimes(1) - }) - - it('does not retry on 404 errors', async () => { - const notFound = new Response('Not Found', { status: 404 }) - fetchSpy.mockResolvedValueOnce(notFound) - - await expect(getConsent('c1')).rejects.toThrow('Arcim API 404') - expect(fetchSpy).toHaveBeenCalledTimes(1) - }) - }) - - // ------------------------------------------------------------------------- - // Retry on timeout (AbortError) - // ------------------------------------------------------------------------- - describe('timeout retry', () => { - it('retries on AbortError and succeeds', async () => { - const abortError = new DOMException('Aborted', 'AbortError') - const success = new Response( - JSON.stringify({ id: 'c1', status: 'active' }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ) - - fetchSpy - .mockRejectedValueOnce(abortError) - .mockResolvedValueOnce(success) - - const result = await getConsent('c1') - expect(result).toEqual({ id: 'c1', status: 'active' }) - expect(fetchSpy).toHaveBeenCalledTimes(2) - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('timed out, retrying') - ) - }) - - it('throws timeout error after exhausting retries on AbortError', async () => { - const abortError = new DOMException('Aborted', 'AbortError') - - fetchSpy - .mockRejectedValueOnce(abortError) - .mockRejectedValueOnce(abortError) - .mockRejectedValueOnce(abortError) - - await expect(getConsent('c1')).rejects.toThrow('Arcim API timeout') - expect(fetchSpy).toHaveBeenCalledTimes(3) - }) - - it('does not retry on non-abort network errors', async () => { - const networkError = new TypeError('fetch failed') - fetchSpy.mockRejectedValueOnce(networkError) - - await expect(getConsent('c1')).rejects.toThrow('fetch failed') - expect(fetchSpy).toHaveBeenCalledTimes(1) - }) - }) - - // ------------------------------------------------------------------------- - // Exponential backoff - // ------------------------------------------------------------------------- - describe('backoff', () => { - it('increases delay on successive retries', async () => { - const delays: number[] = [] - const realSetTimeout = globalThis.setTimeout - // Only intercept retry delays (1000-10000ms range), pass abort timers through - vi.spyOn(globalThis, 'setTimeout').mockImplementation((fn, ms) => { - if (ms && ms >= 1000 && ms < 120_000) { - delays.push(ms as number) - // Execute retry delay callback immediately - if (typeof fn === 'function') fn() - return 0 as unknown as ReturnType - } - // Let abort controller timers run through real setTimeout - return realSetTimeout(fn, ms) - }) - - const fail = new Response('Service Unavailable', { status: 503 }) - const success = new Response( - JSON.stringify({ id: 'c1', status: 'active' }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ) - - fetchSpy - .mockResolvedValueOnce(fail.clone()) - .mockResolvedValueOnce(fail.clone()) - .mockResolvedValueOnce(success) - - await getConsent('c1') - - // attempt 0 → delay = 1000 * (0+1) = 1000 - // attempt 1 → delay = 1000 * (1+1) = 2000 - expect(delays).toEqual([1000, 2000]) - - vi.restoreAllMocks() - // Re-set our spies since restoreAllMocks clears them - fetchSpy = vi.spyOn(globalThis, 'fetch') - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - }) - }) - - // ------------------------------------------------------------------------- - // POST body - // ------------------------------------------------------------------------- - describe('request body', () => { - it('sends JSON body for POST requests', async () => { - fetchSpy.mockResolvedValueOnce( - new Response( - JSON.stringify({ id: 'c1', status: 'pending', provider: 'fortnox' }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ) - ) - - await createConsent('fortnox', 'Test', '5591234567', 'Test AB') - - const [url, opts] = fetchSpy.mock.calls[0] - expect(url).toBe('https://arcim.test.com/api/v1/consents') - expect(opts?.method).toBe('POST') - expect(JSON.parse(opts?.body as string)).toEqual({ - name: 'Test', - provider: 'fortnox', - orgNumber: '5591234567', - companyName: 'Test AB', - }) - }) - }) - - // ------------------------------------------------------------------------- - // Pagination (fetchAllPages) - // ------------------------------------------------------------------------- - describe('pagination', () => { - it('fetches all pages until hasMore is false', async () => { - const page1 = { data: [{ id: 'c1' }, { id: 'c2' }], hasMore: true } - const page2 = { data: [{ id: 'c3' }], hasMore: false } - - fetchSpy - .mockResolvedValueOnce( - new Response(JSON.stringify(page1), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) - .mockResolvedValueOnce( - new Response(JSON.stringify(page2), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) - - const result = await fetchCustomers('consent-1') - expect(result).toHaveLength(3) - expect(result.map((c) => c.id)).toEqual(['c1', 'c2', 'c3']) - expect(fetchSpy).toHaveBeenCalledTimes(2) - }) - - it('stops when page returns empty data', async () => { - const page1 = { data: [{ id: 'c1' }], hasMore: true } - const page2 = { data: [], hasMore: true } - - fetchSpy - .mockResolvedValueOnce( - new Response(JSON.stringify(page1), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) - .mockResolvedValueOnce( - new Response(JSON.stringify(page2), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) - - const result = await fetchCustomers('consent-1') - expect(result).toHaveLength(1) - expect(fetchSpy).toHaveBeenCalledTimes(2) - }) - }) - - // ------------------------------------------------------------------------- - // Singleton resource (fetchCompanyInfo) - // ------------------------------------------------------------------------- - describe('singleton resource', () => { - it('unwraps { data } envelope for company info', async () => { - const company = { orgNumber: '5591234567', name: 'Test AB' } - fetchSpy.mockResolvedValueOnce( - new Response(JSON.stringify({ data: company }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) - - const result = await fetchCompanyInfo('consent-1') - expect(result).toEqual(company) - }) - - it('returns null when data is undefined', async () => { - fetchSpy.mockResolvedValueOnce( - new Response(JSON.stringify({}), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - ) - - const result = await fetchCompanyInfo('consent-1') - expect(result).toBeNull() - }) - }) - - // ------------------------------------------------------------------------- - // Missing env vars - // ------------------------------------------------------------------------- - describe('environment validation', () => { - it('throws when ARCIM_SYNC_GATEWAY_URL is missing', async () => { - const orig = process.env.ARCIM_SYNC_GATEWAY_URL - delete process.env.ARCIM_SYNC_GATEWAY_URL - - try { - await expect(getConsent('c1')).rejects.toThrow( - 'ARCIM_SYNC_GATEWAY_URL is not configured' - ) - } finally { - process.env.ARCIM_SYNC_GATEWAY_URL = orig - } - }) - - it('throws when ARCIM_SYNC_API_KEY is missing', async () => { - const orig = process.env.ARCIM_SYNC_API_KEY - delete process.env.ARCIM_SYNC_API_KEY - - try { - await expect(getConsent('c1')).rejects.toThrow( - 'ARCIM_SYNC_API_KEY is not configured' - ) - } finally { - process.env.ARCIM_SYNC_API_KEY = orig - } - }) - }) -}) diff --git a/extensions/general/arcim-migration/lib/arcim-client.ts b/extensions/general/arcim-migration/lib/arcim-client.ts deleted file mode 100644 index 6cfb6052..00000000 --- a/extensions/general/arcim-migration/lib/arcim-client.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * HTTP client for the Arcim Sync gateway API. - * - * Targets the consent-based resource API (/api/v1/consents/...) which - * provides typed, normalized access to any Swedish accounting provider. - */ - -import type { - ArcimProvider, - ConsentRecord, - PaginatedResponse, - CompanyInformationDto, - CustomerDto, -} from '../types' - -function getBaseUrl(): string { - const url = process.env.ARCIM_SYNC_GATEWAY_URL - if (!url) throw new Error('ARCIM_SYNC_GATEWAY_URL is not configured') - return url.replace(/\/$/, '') -} - -function getApiKey(): string { - const key = process.env.ARCIM_SYNC_API_KEY - if (!key) throw new Error('ARCIM_SYNC_API_KEY is not configured') - return key -} - -const MAX_RETRIES = 2 -const RETRY_DELAY_MS = 1_000 -const RETRYABLE_STATUSES = [429, 502, 503, 504] - -async function request( - path: string, - options: RequestInit = {}, - timeoutMs: number = 120_000 -): Promise { - const url = `${getBaseUrl()}${path}` - - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), timeoutMs) - - let response: Response - try { - response = await fetch(url, { - ...options, - signal: controller.signal, - headers: { - 'Authorization': `Bearer ${getApiKey()}`, - 'Content-Type': 'application/json', - ...options.headers, - }, - }) - } catch (err) { - const isAbort = err instanceof DOMException || (err instanceof Error && err.name === 'AbortError') - if (attempt < MAX_RETRIES && isAbort) { - console.warn(`[arcim] ${path} timed out, retrying (attempt ${attempt + 1})`) - await new Promise(r => setTimeout(r, RETRY_DELAY_MS * (attempt + 1))) - continue - } - if (isAbort) { - throw new Error(`Arcim API timeout after ${Math.round(timeoutMs / 1000)}s: ${path}`) - } - throw err - } finally { - clearTimeout(timer) - } - - if (attempt < MAX_RETRIES && RETRYABLE_STATUSES.includes(response.status)) { - console.warn(`[arcim] ${path} returned ${response.status}, retrying (attempt ${attempt + 1})`) - await new Promise(r => setTimeout(r, RETRY_DELAY_MS * (attempt + 1))) - continue - } - - if (!response.ok) { - const body = await response.text().catch(() => '') - throw new Error(`Arcim API ${response.status}: ${body || response.statusText}`) - } - - return response.json() - } - - throw new Error(`Arcim API failed after ${MAX_RETRIES + 1} attempts: ${path}`) -} - -// ── Consent lifecycle ─────────────────────────────────────────────── - -export async function createConsent( - provider: ArcimProvider, - name: string, - orgNumber?: string, - companyName?: string -): Promise { - return request('/api/v1/consents', { - method: 'POST', - body: JSON.stringify({ name, provider, orgNumber, companyName }), - }) -} - -export async function getConsent(consentId: string): Promise { - return request(`/api/v1/consents/${consentId}`) -} - -// ── Resource fetching (paginated) ─────────────────────────────────── - -async function fetchAllPages( - consentId: string, - resource: string, - params?: Record, - pageSize: number = 100, - maxPages: number = 500 -): Promise { - const all: T[] = [] - let page = 1 - - while (page <= maxPages) { - const query = new URLSearchParams({ - page: String(page), - pageSize: String(pageSize), - ...params, - }) - const result = await request>( - `/api/v1/consents/${consentId}/${resource}?${query}` - ) - all.push(...result.data) - - if (!result.hasMore || result.data.length === 0) break - page++ - } - - return all -} - -// ── Typed resource accessors ──────────────────────────────────────── - -export async function fetchCompanyInfo( - consentId: string -): Promise { - // CompanyInformation is a singleton resource: gateway returns { data: object } - const result = await request<{ data: CompanyInformationDto }>( - `/api/v1/consents/${consentId}/companyinformation` - ) - return result.data ?? null -} - -export async function fetchCustomers(consentId: string): Promise { - return fetchAllPages(consentId, 'customers') -} - -// The gateway SIE export path (fetchSIEExport/SIEExportFile) was deliberately -// removed: it returned SIE as a pre-decoded string, and the gateway's decode of -// CP437 bytes as windows-1252 caused the 2026-03-17 mojibake incident. Provider -// SIE now travels as raw bytes through lib/sie-fetcher.ts and the repo's own -// encoding detection. Do not re-add a string-typed SIE fetch here. diff --git a/scripts/checks/antipatterns-baseline.json b/scripts/checks/antipatterns-baseline.json index 814276df..b1ec6c35 100644 --- a/scripts/checks/antipatterns-baseline.json +++ b/scripts/checks/antipatterns-baseline.json @@ -7,7 +7,7 @@ ] }, "naiveOreRound": { - "count": 620 + "count": 617 }, "handRolledInvariants": { "count": 113 @@ -36,5 +36,32 @@ "rawReferenceFetch": { "count": 0, "files": [] + }, + "providerHosts": { + "count": 22, + "files": [ + "app/api/connect/bank/[...path]/route.ts", + "extensions/general/bolagsverket/lib/client.ts", + "extensions/general/enable-banking/index.ts", + "extensions/general/enable-banking/lib/api-client.ts", + "extensions/general/mail/lib/gmail-client.ts", + "extensions/general/skatteverket/index.ts", + "extensions/general/skatteverket/lib/connector-mode.ts", + "extensions/general/skatteverket/lib/ombud-client.ts", + "extensions/general/skatteverket/lib/skattekonto-client.ts", + "extensions/general/tic/index.ts", + "extensions/general/tic/lib/bankid-client.ts", + "extensions/general/tic/lib/bankid-types.ts", + "extensions/general/whatsapp-inbox/lib/graph-api.ts", + "lib/connect/upstreams/enable-banking-jwt.ts", + "lib/invoices/transports/qvalia.ts", + "lib/providers/bjornlunden/config.ts", + "lib/providers/bjornlunden/oauth.ts", + "lib/providers/bokio/client.ts", + "lib/providers/bokio/config.ts", + "lib/providers/briox/config.ts", + "lib/providers/fortnox/config.ts", + "lib/providers/visma/config.ts" + ] } } diff --git a/scripts/checks/no-new-antipatterns.mjs b/scripts/checks/no-new-antipatterns.mjs index fbeb0b24..292be09b 100644 --- a/scripts/checks/no-new-antipatterns.mjs +++ b/scripts/checks/no-new-antipatterns.mjs @@ -390,6 +390,34 @@ function countNaiveRound() { return count } +/** + * 9. provider-host: files that talk to an external provider API directly. + * Provider integration logic is moving behind the connector (hosted + * `app/api/connect/*` today, the Accounted Connect service later): the open + * repo keeps the ledger, the contract and the manual file paths, and a + * self-hosted instance reaches every provider through its connector key. + * Per-file ratchet: the grandfathered set may only shrink. A NEW file naming a + * provider API host is a boundary violation unless it is the connector's own + * hosted adapter side. + */ +const PROVIDER_HOST_RE = + /api\.enablebanking\.com|api\.tilisy\.com|api\.skatteverket\.se|peroauth2\.skatteverket\.se|sso\.skatteverket\.se|api\.qvalia\.com|api-test\.qvalia\.com|api\.fortnox\.se|apps\.fortnox\.se|vismaonline\.com|briox\.services|apigateway\.blinfo\.se|api\.bokio\.se|api\.bolagsverket\.se|api-accept2\.bolagsverket\.se|id\.tic\.io|graph\.facebook\.com|gmail\.googleapis\.com/i + +function findProviderHostFiles() { + const files = [ + ...walk(path.join(ROOT, 'lib'), ['.ts', '.tsx']), + ...walk(path.join(ROOT, 'app'), ['.ts', '.tsx']), + ...walk(path.join(ROOT, 'extensions'), ['.ts', '.tsx']), + ] + const found = [] + for (const f of files) { + const r = rel(f) + if (r.includes('__tests__/') || r.endsWith('.test.ts') || r.endsWith('.test.tsx')) continue + if (PROVIDER_HOST_RE.test(fs.readFileSync(f, 'utf8'))) found.push(r) + } + return found.sort() +} + /** * Occurrences of a shared format rule written out by hand instead of imported * from lib/invariants/. Counted, not file-setted: the campaign lowers the @@ -1025,6 +1053,7 @@ const current = { rawRouteAuth: findRawRouteAuth(), naiveOreRound: countNaiveRound(), handRolledInvariants: countHandRolledInvariants(), + providerHosts: findProviderHostFiles(), ledgerScanningReports: findLedgerScanningReports(), directJelInsert: findDirectJelInserts(), leakySupabaseClients: findLeakySupabaseClients(), @@ -1064,6 +1093,10 @@ if (isUpdate) { count: current.rawReferenceFetch.length, files: current.rawReferenceFetch, }, + providerHosts: { + count: current.providerHosts.length, + files: current.providerHosts, + }, } fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + '\n') console.log( @@ -1344,6 +1377,26 @@ if (newLedgerScans.length) { ) } +// 1c2. provider-host: a file naming a provider API host outside the +// grandfathered set is a NEW direct integration in the open repo. +const providerHostBaseline = new Set(baseline.providerHosts?.files ?? []) +const newProviderHosts = current.providerHosts.filter((f) => !providerHostBaseline.has(f)) +const fixedProviderHosts = (baseline.providerHosts?.files ?? []).filter((f) => !current.providerHosts.includes(f)) +if (baseline.providerHosts && newProviderHosts.length) { + failed = true + console.error( + `\n✗ provider-host: ${newProviderHosts.length} new file(s) call a provider API host directly:`, + ) + newProviderHosts.forEach((f) => console.error(` ${f}`)) + console.error( + ' → provider integration logic lives behind the connector, not in the open ledger:\n' + + ' route the call through the hosted connector (app/api/connect/*) and the\n' + + ' instance-side connector-mode seam (lib/connect/instance/upstreams.ts), or\n' + + ' keep the manual file path. If this file IS the connector\'s own hosted adapter\n' + + ' side, re-baseline with --update and say so in the PR.', + ) +} + // 1d. raw-reference-fetch: per-file ratchet. A file outside the baseline set // that fetches reference data raw (see raw-reference-fetch.mjs) is a NEW // violation; grandfathered files stay until they move to the hooks. Once the @@ -1410,6 +1463,7 @@ if ( fixedLedgerScans.length || fixedDialogOverflow.length || fixedRawRefs.length || + fixedProviderHosts.length || current.naiveOreRound < baseline.naiveOreRound.count ) { console.log('\n✓ Progress since baseline:') @@ -1422,6 +1476,8 @@ if ( console.log(` raw-reference-fetch: -${fixedRawRefs.length} file(s)`) if (current.naiveOreRound < baseline.naiveOreRound.count) console.log(` naive-ore-round: -${baseline.naiveOreRound.count - current.naiveOreRound} occurrence(s)`) + if (fixedProviderHosts.length) + console.log(` provider-host: -${fixedProviderHosts.length} file(s) no longer call a provider directly`) console.log(' Run with --update to ratchet the baseline down and lock in the gains.') } if (migratedDirectAi.length) { @@ -1444,5 +1500,5 @@ if (failed) { process.exit(1) } console.log( - `\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, leaky-supabase-client: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, folded-public-flag: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted, dialog-overflow-risk: ${dialogOverflowFiles.length} file(s), raw-reference-fetch: ${current.rawReferenceFetch.length} file(s), client-node-builtin: ${current.clientNodeBuiltins.length}, ambiguous-embed: ${current.ambiguousEmbeds.length}, direct-ai-client: ${current.directAiClients.length}/${DIRECT_AI_CLIENT_ALLOWED.size} allowlisted).`, + `\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, leaky-supabase-client: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, folded-public-flag: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted, dialog-overflow-risk: ${dialogOverflowFiles.length} file(s), raw-reference-fetch: ${current.rawReferenceFetch.length} file(s), client-node-builtin: ${current.clientNodeBuiltins.length}, ambiguous-embed: ${current.ambiguousEmbeds.length}, provider-host: ${current.providerHosts.length} file(s), direct-ai-client: ${current.directAiClients.length}/${DIRECT_AI_CLIENT_ALLOWED.size} allowlisted).`, )