chore(connect): provider-host ratchet in check:guards, drop the dead Arcim gateway client (#2178)
* chore(connect): provider-host ratchet in check:guards, drop the dead Arcim gateway client Two boundary chores from the Connect plan. (1) A per-file ratchet in scripts/checks/no-new-antipatterns.mjs over files under lib/, app/ and extensions/ that name a provider API host (Enable Banking, Skatteverket, Qvalia, Fortnox, Visma, Briox, Bjorn Lunden, Bokio, Bolagsverket, TIC, Meta, Gmail). The 22 files that do so today are grandfathered in the baseline; a new one fails the guard with the connector routing as the remedy, and the set may only shrink as upstreams move behind the connector. (2) The client for the retired Arcim Sync gateway (extensions/general/arcim-migration/lib/ arcim-client.ts) is deleted with its test: provider-client.ts replaced it and nothing else imported it. The --update rewrite also locks in the lower naive-ore-round count (620 to 617) that main already reached. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> * chore(guards): provider-host ratchet is case-insensitive and skips colocated .test.tsx; document the own-credentials exception Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> --------- Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<typeof vi.spyOn>
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
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<typeof setTimeout>
|
||||
}
|
||||
// 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
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
timeoutMs: number = 120_000
|
||||
): Promise<T> {
|
||||
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<ConsentRecord> {
|
||||
return request<ConsentRecord>('/api/v1/consents', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, provider, orgNumber, companyName }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getConsent(consentId: string): Promise<ConsentRecord> {
|
||||
return request<ConsentRecord>(`/api/v1/consents/${consentId}`)
|
||||
}
|
||||
|
||||
// ── Resource fetching (paginated) ───────────────────────────────────
|
||||
|
||||
async function fetchAllPages<T>(
|
||||
consentId: string,
|
||||
resource: string,
|
||||
params?: Record<string, string>,
|
||||
pageSize: number = 100,
|
||||
maxPages: number = 500
|
||||
): Promise<T[]> {
|
||||
const all: T[] = []
|
||||
let page = 1
|
||||
|
||||
while (page <= maxPages) {
|
||||
const query = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
...params,
|
||||
})
|
||||
const result = await request<PaginatedResponse<T>>(
|
||||
`/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<CompanyInformationDto | null> {
|
||||
// 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<CustomerDto[]> {
|
||||
return fetchAllPages<CustomerDto>(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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).`,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user