Files
accounted/extensions/general/skatteverket/lib/connection-store.ts
T
bjornbergenheimandClaude Opus 5 43a71aec3c fix(supabase): stop server clients leaking a 30s refresh ticker per request (#1612)
* fix(supabase): stop server clients leaking a 30s refresh ticker per request

`autoRefreshToken` defaults to true in supabase-js, and off-browser
@supabase/auth-js starts the refresh ticker unconditionally:

    // in non-browser environments the refresh token ticker runs always
    this.startAutoRefresh()

That is a setInterval firing every 30 s. It calls unref(), so the process
still exits, tests pass, and Vercel never notices because the process is
torn down long before the tickers accumulate. But unref() does not make a
timer collectable: it stays registered in the event loop and remains a GC
root for its callback, which closes over the GoTrueClient, the
SupabaseClient, and the whole request scope around it.

A long-running self-hosted instance therefore leaks one timer plus one
entire request graph (socket, IncomingMessage, ServerResponse, headers,
route context: ~100 kB) per client constructed. One died of "JavaScript
heap out of memory" after 42 h, the last 24 of them completely idle. The
heap snapshot showed 445 retained request graphs and ~1050 Timeouts in
the 30 000 ms bucket, retained via `autoRefreshTicker`, and the rate
matched the traffic exactly: the Docker healthcheck polls /api/health
every 30 s and the webhook dispatch cron runs every minute, so
3 clients/min x 148 min = 444.

- new lib/supabase/service-client.ts: createServiceRoleClient() applies
  SERVER_AUTH_OPTIONS, spread LAST so a caller passing its own auth block
  cannot re-enable the ticker
- 22 call sites migrated; only booking-templates/sync/cron had ever
  passed the options itself
- guard 9 in no-new-antipatterns.mjs fails CI on any new value import of
  supabase-js's createClient outside the wrapper; type-only imports are
  fine. Verified to fail on a deliberate regression and pass once fixed
- browser clients untouched: a signed-in tab genuinely needs the refresh,
  and lib/supabase/client.ts is built on createBrowserClient anyway

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(checks): catch namespace imports in the leaky-supabase-client guard

The guard only matched named imports, so

    import * as sb from '@supabase/supabase-js'
    sb.createClient(url, key)

reached createClient through member access without ever naming it, and
passed. Verified against the real script before and after: the shape is
flagged now, and `import type * as sb` still passes.

Namespace value imports are treated as leaky outright rather than tracking
member access, which keeps the check a regex over source text with no new
dependency.

Review also suggested excluding *.test.tsx alongside *.test.ts. Skipped: the
repo has no .test.tsx files, and all four sibling checks in this file use
`.test.ts`. Diverging in one of them would read as an accident; if such files
appear, all four should change together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:30:17 +02:00

237 lines
7.9 KiB
TypeScript

import { type SupabaseClient } from '@supabase/supabase-js'
import { createServiceRoleClient } from '@/lib/supabase/service-client'
import { createLogger } from '@/lib/logger'
const log = createLogger('skatteverket-connection-store')
/**
* Accessor for skatteverket_company_connections: the per-company system-auth
* (ombud grant) state.
*
* All writes route through a service-role client (mirrors token-store.ts):
* probes run with system credentials server-side and the calling routes
* enforce user identity and role before reaching this module. User sessions
* can only SELECT (RLS).
*/
let _serviceClient: SupabaseClient | null = null
function getServiceClient(): SupabaseClient {
if (_serviceClient) return _serviceClient
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
const key = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!url || !key) {
throw new Error(
'skatteverket connection-store requires NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY'
)
}
_serviceClient = createServiceRoleClient(url, key)
return _serviceClient
}
export type SkvEnvironment = 'test' | 'production'
export type SkvBehorighet = 'lasombud' | 'moms_ombud'
export type GrantStatus = 'unknown' | 'granted' | 'denied' | 'error'
export type ConnectionStatus = 'pending' | 'partial' | 'verified' | 'revoked' | 'error'
export interface SkvCompanyConnection {
id: string
company_id: string
environment: SkvEnvironment
org_number: string
status: ConnectionStatus
lasombud_status: GrantStatus
lasombud_checked_at: string | null
moms_ombud_status: GrantStatus
moms_ombud_checked_at: string | null
verified_at: string | null
last_probe_at: string | null
last_probe_detail: Record<string, unknown> | null
last_error: string | null
}
const CONNECTION_COLUMNS =
'id, company_id, environment, org_number, status, lasombud_status, lasombud_checked_at, ' +
'moms_ombud_status, moms_ombud_checked_at, verified_at, last_probe_at, last_probe_detail, last_error'
export async function getConnection(
companyId: string,
environment: SkvEnvironment
): Promise<SkvCompanyConnection | null> {
const { data, error } = await getServiceClient()
.from('skatteverket_company_connections')
.select(CONNECTION_COLUMNS)
.eq('company_id', companyId)
.eq('environment', environment)
.maybeSingle()
if (error) {
log.warn('getConnection failed', { companyId, environment, error: error.message })
return null
}
return (data as SkvCompanyConnection | null) ?? null
}
/** Aggregate status from the per-behorighet grant states. */
function aggregateStatus(
lasombud: GrantStatus,
momsOmbud: GrantStatus
): ConnectionStatus {
const states = [lasombud, momsOmbud]
const grantedCount = states.filter((s) => s === 'granted').length
if (grantedCount === states.length) return 'verified'
if (grantedCount > 0) return 'partial'
if (states.every((s) => s === 'denied')) return 'pending'
if (states.some((s) => s === 'error')) return 'error'
return 'pending'
}
export interface ProbeResultInput {
companyId: string
environment: SkvEnvironment
orgNumber: string
createdBy?: string
lasombud?: { status: GrantStatus; detail?: unknown }
momsOmbud?: { status: GrantStatus; detail?: unknown }
error?: string | null
}
/**
* Persist a probe outcome. Transient errors never downgrade a previously
* granted behorighet: only an explicit 'denied' classification does.
*/
export async function recordProbeResult(
input: ProbeResultInput
): Promise<SkvCompanyConnection | null> {
const supabase = getServiceClient()
const existing = await getConnection(input.companyId, input.environment)
const now = new Date().toISOString()
const nextGrant = (
previous: GrantStatus,
probe: { status: GrantStatus } | undefined
): GrantStatus => {
if (!probe) return previous
if (probe.status === 'error' && previous === 'granted') return 'granted'
return probe.status
}
const lasombudStatus = nextGrant(existing?.lasombud_status ?? 'unknown', input.lasombud)
const momsOmbudStatus = nextGrant(existing?.moms_ombud_status ?? 'unknown', input.momsOmbud)
const status = aggregateStatus(lasombudStatus, momsOmbudStatus)
const row: Record<string, unknown> = {
company_id: input.companyId,
environment: input.environment,
org_number: input.orgNumber,
status,
lasombud_status: lasombudStatus,
moms_ombud_status: momsOmbudStatus,
last_probe_at: now,
last_probe_detail: {
lasombud: input.lasombud ?? null,
moms_ombud: input.momsOmbud ?? null,
},
last_error: input.error ?? null,
}
if (input.lasombud) row.lasombud_checked_at = now
if (input.momsOmbud) row.moms_ombud_checked_at = now
if (input.createdBy && !existing) row.created_by = input.createdBy
if (status === 'verified' && !existing?.verified_at) row.verified_at = now
const { data, error } = await supabase
.from('skatteverket_company_connections')
.upsert(row, { onConflict: 'company_id,environment' })
.select(CONNECTION_COLUMNS)
.single()
if (error) {
log.error('recordProbeResult failed', error, {
companyId: input.companyId,
environment: input.environment,
})
return null
}
return data as unknown as SkvCompanyConnection
}
/**
* Downgrade a behorighet after a company-level OMBUD_GRANT_MISSING observed
* during background work (companies can withdraw the grant at any time).
*/
export async function markGrantRevoked(
companyId: string,
environment: SkvEnvironment,
behorighet: SkvBehorighet,
errorCode?: string
): Promise<void> {
const existing = await getConnection(companyId, environment)
if (!existing) return
const now = new Date().toISOString()
const lasombudStatus = behorighet === 'lasombud' ? 'denied' : existing.lasombud_status
const momsOmbudStatus = behorighet === 'moms_ombud' ? 'denied' : existing.moms_ombud_status
const anyGranted = lasombudStatus === 'granted' || momsOmbudStatus === 'granted'
const { error } = await getServiceClient()
.from('skatteverket_company_connections')
.update({
lasombud_status: lasombudStatus,
moms_ombud_status: momsOmbudStatus,
...(behorighet === 'lasombud'
? { lasombud_checked_at: now }
: { moms_ombud_checked_at: now }),
status: anyGranted ? 'partial' : 'revoked',
last_error: errorCode ?? 'OMBUD_GRANT_MISSING',
})
.eq('id', existing.id)
if (error) {
log.warn('markGrantRevoked failed', { companyId, behorighet, error: error.message })
}
}
/** Set the whole connection revoked (explicit user disconnect). */
export async function markConnectionRevoked(
companyId: string,
environment: SkvEnvironment
): Promise<void> {
const { error } = await getServiceClient()
.from('skatteverket_company_connections')
.update({
status: 'revoked',
lasombud_status: 'unknown',
moms_ombud_status: 'unknown',
})
.eq('company_id', companyId)
.eq('environment', environment)
if (error) {
log.warn('markConnectionRevoked failed', { companyId, error: error.message })
}
}
/**
* Companies whose given behorighet is granted, for cron enumeration.
*/
export async function listVerifiedCompanies(
environment: SkvEnvironment,
behorighet: SkvBehorighet,
limit = 200
): Promise<Array<{ company_id: string; org_number: string; created_by: string | null }>> {
const column = behorighet === 'lasombud' ? 'lasombud_status' : 'moms_ombud_status'
const { data, error } = await getServiceClient()
.from('skatteverket_company_connections')
.select('company_id, org_number, created_by')
.eq('environment', environment)
.in('status', ['verified', 'partial'])
.eq(column, 'granted')
.limit(limit)
if (error) {
log.warn('listVerifiedCompanies failed', { environment, behorighet, error: error.message })
return []
}
return (data ?? []) as Array<{ company_id: string; org_number: string; created_by: string | null }>
}
/** Test hook: reset the memoized service client. */
export function __resetConnectionStoreForTests(): void {
_serviceClient = null
}