From 43a71aec3c86d5d3f39158e6c1f3a68075b53e7a Mon Sep 17 00:00:00 2001 From: bjornbergenheim <29535152+bjornbergenheim@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:30:17 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Opus 5 --- DECISIONS.md | 2 + app/api/calendar/feed/[token]/route.ts | 4 +- app/api/deadlines/status/cron/route.ts | 4 +- app/api/documents/verify/cron/route.ts | 4 +- .../cloud-backup/auto-sync/cron/route.ts | 4 +- .../enable-banking/sync/cron/route.ts | 5 +- .../push-notifications/cron/route.ts | 4 +- .../extensions/shopify/orders/cron/route.ts | 4 +- .../skatteverket/agi/kvittenser/cron/route.ts | 4 +- .../skattekonto/sync/cron/route.ts | 4 +- .../skatteverket/vat/kvittenser/cron/route.ts | 4 +- app/api/extensions/stripe/sync/cron/route.ts | 4 +- .../stripe/transactions/cron/route.ts | 4 +- .../woocommerce/orders/cron/route.ts | 4 +- app/api/health/route.ts | 4 +- app/api/sandbox/cleanup/cron/route.ts | 4 +- .../booking-templates/sync/cron/route.ts | 6 +- app/api/tax-deadlines/cron/route.ts | 4 +- extensions/general/invoice-inbox/index.ts | 6 +- .../skatteverket/lib/connection-store.ts | 5 +- .../general/skatteverket/lib/token-store.ts | 5 +- extensions/general/whatsapp-inbox/index.ts | 4 +- lib/api/v1/with-api-v1.ts | 5 +- lib/auth/api-keys.ts | 4 +- lib/supabase/__tests__/service-client.test.ts | 84 ++++++++++++++++++ lib/supabase/service-client.ts | 63 ++++++++++++++ scripts/checks/no-new-antipatterns.mjs | 87 ++++++++++++++++++- 27 files changed, 285 insertions(+), 51 deletions(-) create mode 100644 lib/supabase/__tests__/service-client.test.ts create mode 100644 lib/supabase/service-client.ts diff --git a/DECISIONS.md b/DECISIONS.md index 7a8ee9b7..d6538132 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -946,6 +946,8 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-13] Kontantmetoden year-end VAT supersedes the 2026-08-06 VAT-reporting premise: BAS 2618/2628/2638 and 2648 feed the final declaration, reverse-charge purchases include both VAT sides and their basis, and only the mechanical day-one reversal is excluded from later VAT periods. Skatteverket requires unpaid invoice VAT in the final period and warns against reporting it twice after year end. [2026-08-13] Per-account VAT treatment is class-aware and explicit values override the static BAS mapping; SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. [2026-08-13] Per-account VAT treatment is class-aware; explicit values extend custom accounts while canonical accounts keep their static BAS momsdeklaration mapping. SIE #SRU and #KTYP never supply it because they encode tax-return fields and account class, not momsdeklaration treatment. VMB carries no default account rate because its VAT base is the margin, not gross sales. +[2026-08-13] AGI needs two SKV scopes, not one: added `agdredovisningperiod` (hanteraredovisningsperiod: kvittenser/las) alongside `agd` (inlamning) in the per-flow DEFAULT_SCOPES, after a real prod filing signed fine and then 403'd on "Hamta kvittens". Pinned with a scope-set regression test since this is the third scope-cleanup casualty; the AGIPanel missing-scope banner now checks both, because a token with `agd` alone fails only at the last step. +[2026-08-14] Server-side Supabase clients now go through createServiceRoleClient() (lib/supabase/service-client.ts) instead of importing supabase-js's createClient directly. autoRefreshToken defaults to true and auth-js starts the 30 s refresh ticker unconditionally off-browser; the ticker calls unref(), so the process still exits and nothing fails in CI or on Vercel, where the process is short-lived. But unref() does not make a timer collectable: it stays a GC root for its callback and retains the GoTrueClient, the SupabaseClient and the entire request scope captured around it (socket, IncomingMessage, ServerResponse, headers, route context; roughly 100 kB per client). A self-hosted instance died of heap exhaustion after 42 h, the last 24 completely idle, with a heap snapshot showing 445 retained request graphs and ~1050 Timeouts in the 30 000 ms bucket retained via autoRefreshTicker; the rate matched traffic exactly (healthcheck every 30 s plus the per-minute webhook dispatch cron = 3 clients/min x 148 min = 444). 22 call sites were migrated; only app/api/settings/booking-templates/sync/cron/route.ts had ever passed the options. SERVER_AUTH_OPTIONS is spread LAST inside the helper so a caller passing its own auth block cannot re-enable the ticker, and check 9 in scripts/checks/no-new-antipatterns.mjs fails CI on any new value import of createClient (type-only imports are fine). Browser clients are deliberately untouched: a signed-in tab genuinely needs the refresh, and lib/supabase/client.ts uses @supabase/ssr's createBrowserClient anyway. [2026-08-13] Correct the shipped VAT-treatment constraint in a new migration: the predecessor PR reached main while the replacement review was active, so the immutable original migration stays byte-identical and the class-aware vocabulary is enforced additively with NOT VALID plus validation. [2026-08-13] Issue #1457 supersedes the stale canonical-account exception above: an explicit per-account VAT treatment always overrides BAS fallback, while a reverse-charge treatment derives the editable 25 percent standard rate only when no booking default exists; class 5-6 SIE accounts require review when a treatment is actually suggested, not for every ordinary expense account. [2026-08-13] AGI needs two SKV scopes, not one: added `agdredovisningperiod` (hanteraredovisningsperiod: kvittenser/las) alongside `agd` (inlamning) in the per-flow DEFAULT_SCOPES, after a real prod filing signed fine and then 403'd on "Hamta kvittens". Pinned with a scope-set regression test since this is the third scope-cleanup casualty; the AGIPanel missing-scope banner now checks both, because a token with `agd` alone fails only at the last step. diff --git a/app/api/calendar/feed/[token]/route.ts b/app/api/calendar/feed/[token]/route.ts index b2198f0b..6d7fb01f 100644 --- a/app/api/calendar/feed/[token]/route.ts +++ b/app/api/calendar/feed/[token]/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { generateCalendarFeed } from '@/lib/calendar/ics-generator' import { createLogger } from '@/lib/logger' @@ -59,7 +59,7 @@ export async function GET( return new NextResponse('Server configuration error', { status: 500 }) } - const supabase = createClient(supabaseUrl, supabaseServiceKey) + const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey) // Fetch feed settings by token const { data: feed, error: feedError } = await supabase diff --git a/app/api/deadlines/status/cron/route.ts b/app/api/deadlines/status/cron/route.ts index 58fcf043..a7812398 100644 --- a/app/api/deadlines/status/cron/route.ts +++ b/app/api/deadlines/status/cron/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { updateDeadlineStatuses } from '@/lib/deadlines/status-engine' import { withCronContext } from '@/lib/api/with-cron-context' @@ -19,7 +19,7 @@ export const GET = withCronContext('cron.deadlines_status', async (_request, ctx }) } - const supabase = createClient(supabaseUrl, supabaseServiceKey) + const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey) const result = await updateDeadlineStatuses(supabase) diff --git a/app/api/documents/verify/cron/route.ts b/app/api/documents/verify/cron/route.ts index 4418f98f..d37f7a7f 100644 --- a/app/api/documents/verify/cron/route.ts +++ b/app/api/documents/verify/cron/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { withCronContext } from '@/lib/api/with-cron-context' import { downloadDocumentObject } from '@/lib/core/documents/document-service' @@ -33,7 +33,7 @@ export const GET = withCronContext('cron.documents_verify', async (_request, ctx }) } - const supabase = createClient(supabaseUrl, supabaseServiceKey) + const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey) const { data: documents, error: fetchError } = await supabase .from('document_attachments') diff --git a/app/api/extensions/cloud-backup/auto-sync/cron/route.ts b/app/api/extensions/cloud-backup/auto-sync/cron/route.ts index faa3f1ec..f3464925 100644 --- a/app/api/extensions/cloud-backup/auto-sync/cron/route.ts +++ b/app/api/extensions/cloud-backup/auto-sync/cron/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { withCronContext } from '@/lib/api/with-cron-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' @@ -50,7 +50,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques }) } - const supabase = createClient(supabaseUrl, supabaseServiceKey) + const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey) const now = new Date() const origin = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index f6f8ea35..16a2e52b 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -1,4 +1,5 @@ -import { createClient, type SupabaseClient } from '@supabase/supabase-js' +import { type SupabaseClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { syncAccountTransactions } from '@/extensions/general/enable-banking/lib/sync' import { @@ -52,7 +53,7 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => { }) } - const supabase = createClient(supabaseUrl, supabaseServiceKey) + const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey) // Clean up stale pending connections (older than 1 hour) const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString() diff --git a/app/api/extensions/push-notifications/cron/route.ts b/app/api/extensions/push-notifications/cron/route.ts index 50b2e1e5..88f38a06 100644 --- a/app/api/extensions/push-notifications/cron/route.ts +++ b/app/api/extensions/push-notifications/cron/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { loadExtensions } from '@/lib/extensions/loader' import { extensionRegistry } from '@/lib/extensions/registry' @@ -46,7 +46,7 @@ export const GET = withCronContext('cron.push_notifications', async (_request, c }) } - const supabase = createClient(supabaseUrl, supabaseServiceKey) + const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey) try { const [taxResult, invoiceResult, underlagResult] = await Promise.all([ diff --git a/app/api/extensions/shopify/orders/cron/route.ts b/app/api/extensions/shopify/orders/cron/route.ts index 2f46bf74..18d61ee7 100644 --- a/app/api/extensions/shopify/orders/cron/route.ts +++ b/app/api/extensions/shopify/orders/cron/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { withCronContext } from '@/lib/api/with-cron-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' @@ -51,7 +51,7 @@ export const GET = withCronContext('cron.shopify_order_sync', async (_request, c return NextResponse.json({ message: 'Shopify not configured', processed: 0 }) } - const supabase = createClient(supabaseUrl, supabaseServiceKey) + const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey) const { data: connections, error: connError } = await supabase .from('shopify_connections') diff --git a/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts b/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts index 36a8ea54..ce903ce6 100644 --- a/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts +++ b/app/api/extensions/skatteverket/agi/kvittenser/cron/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { createLogger } from '@/lib/logger' @@ -68,7 +68,7 @@ export async function GET(request: Request) { ) } - const supabase = createClient(supabaseUrl, supabaseServiceKey) + const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey) const { data: pending, error: pendingError } = await supabase .from('agi_declarations') diff --git a/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts b/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts index a77e57ab..942d932f 100644 --- a/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts +++ b/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { verifyCronSecret } from '@/lib/auth/cron' @@ -66,7 +66,7 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Missing Supabase configuration' }, { status: 500 }) } - const supabase = createClient(supabaseUrl, supabaseServiceKey) + const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey) // User-token entries. The token row is keyed by user_id but carries // company_id (multi-tenant refactor). Rows flagged needs_reconsent are diff --git a/app/api/extensions/skatteverket/vat/kvittenser/cron/route.ts b/app/api/extensions/skatteverket/vat/kvittenser/cron/route.ts index e1fa3a4d..db8eea15 100644 --- a/app/api/extensions/skatteverket/vat/kvittenser/cron/route.ts +++ b/app/api/extensions/skatteverket/vat/kvittenser/cron/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { verifyCronSecret } from '@/lib/auth/cron' @@ -48,7 +48,7 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Missing Supabase configuration' }, { status: 500 }) } - const supabase = createClient(supabaseUrl, supabaseServiceKey) + const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey) // AGI submission state uses the distinct `agi_submission_` prefix, so the // `submission_` filter below cannot match AGI rows. diff --git a/app/api/extensions/stripe/sync/cron/route.ts b/app/api/extensions/stripe/sync/cron/route.ts index 54a69b67..6853f533 100644 --- a/app/api/extensions/stripe/sync/cron/route.ts +++ b/app/api/extensions/stripe/sync/cron/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { withCronContext } from '@/lib/api/with-cron-context' @@ -38,7 +38,7 @@ export const GET = withCronContext('cron.stripe_sync', async (_request, ctx) => return NextResponse.json({ message: 'Stripe Connect not configured', processed: 0 }) } - const supabase = createClient(supabaseUrl, supabaseServiceKey) + const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey) const { data: connections, error: connError } = await supabase .from('stripe_connections') diff --git a/app/api/extensions/stripe/transactions/cron/route.ts b/app/api/extensions/stripe/transactions/cron/route.ts index 8cd8e2ae..0a91c8a2 100644 --- a/app/api/extensions/stripe/transactions/cron/route.ts +++ b/app/api/extensions/stripe/transactions/cron/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { withCronContext } from '@/lib/api/with-cron-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' @@ -36,7 +36,7 @@ export const GET = withCronContext('cron.stripe_transaction_sync', async (_reque return NextResponse.json({ message: 'Stripe Connect not configured', processed: 0 }) } - const supabase = createClient(supabaseUrl, supabaseServiceKey) + const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey) const { data: connections, error: connError } = await supabase .from('stripe_connections') diff --git a/app/api/extensions/woocommerce/orders/cron/route.ts b/app/api/extensions/woocommerce/orders/cron/route.ts index b8b1ac1f..a31ed937 100644 --- a/app/api/extensions/woocommerce/orders/cron/route.ts +++ b/app/api/extensions/woocommerce/orders/cron/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { withCronContext } from '@/lib/api/with-cron-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' @@ -51,7 +51,7 @@ export const GET = withCronContext('cron.woocommerce_order_sync', async (_reques return NextResponse.json({ message: 'WooCommerce not configured', processed: 0 }) } - const supabase = createClient(supabaseUrl, supabaseServiceKey) + const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey) const { data: connections, error: connError } = await supabase .from('woocommerce_connections') diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 930b8ed0..e32d7cc8 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { createLogger } from '@/lib/logger' @@ -79,7 +79,7 @@ async function runHealthCheck(): Promise { } try { - const supabase = createClient(supabaseUrl, supabaseServiceKey) + const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey) const { error } = await supabase .from('fiscal_periods') .select('id', { count: 'exact', head: true }) diff --git a/app/api/sandbox/cleanup/cron/route.ts b/app/api/sandbox/cleanup/cron/route.ts index 18967b51..b3dee150 100644 --- a/app/api/sandbox/cleanup/cron/route.ts +++ b/app/api/sandbox/cleanup/cron/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { withCronContext } from '@/lib/api/with-cron-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' @@ -35,7 +35,7 @@ export const GET = withCronContext('cron.sandbox_cleanup', async (_request, ctx) }) } - const supabase = createClient(supabaseUrl, supabaseServiceKey) + const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey) const started = Date.now() const totals = { cleaned: 0, failed: 0, orphans_removed: 0, batches: 0 } diff --git a/app/api/settings/booking-templates/sync/cron/route.ts b/app/api/settings/booking-templates/sync/cron/route.ts index 65fb75db..0eede189 100644 --- a/app/api/settings/booking-templates/sync/cron/route.ts +++ b/app/api/settings/booking-templates/sync/cron/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { withCronContext } from '@/lib/api/with-cron-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' @@ -39,9 +39,7 @@ export const GET = withCronContext('cron.booking_templates_sync', async (_reques }) } - const supabase = createClient(supabaseUrl, supabaseServiceKey, { - auth: { persistSession: false, autoRefreshToken: false }, - }) + const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey) const result = await syncSystemPacks(supabase) diff --git a/app/api/tax-deadlines/cron/route.ts b/app/api/tax-deadlines/cron/route.ts index 5507d2f9..b29cb8b4 100644 --- a/app/api/tax-deadlines/cron/route.ts +++ b/app/api/tax-deadlines/cron/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { backfillMissingTaxDeadlines, @@ -21,7 +21,7 @@ export const GET = withCronContext('cron.tax_deadlines', async (_request, ctx) = }) } - const supabase = createClient(supabaseUrl, supabaseServiceKey) + const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey) const now = new Date() const isAnnualRun = now.getUTCMonth() === 0 && now.getUTCDate() === 2 diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index dbbe3492..d1b38822 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -1,6 +1,6 @@ import type { Extension, ExtensionContext } from '@/lib/extensions/types' import { NextResponse } from 'next/server' -import { createClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { z } from 'zod' import { uploadDocument } from '@/lib/core/documents/document-service' import { createServiceClient } from '@/lib/supabase/server' @@ -1375,7 +1375,7 @@ export const invoiceInboxExtension: Extension = { // the user pressing "Kontrollera igen" (requires the event type to be // subscribed on the Resend webhook; harmless when it isn't). if (event.type === 'domain.updated') { - const domainServiceSupabase = createClient( + const domainServiceSupabase = createServiceRoleClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! ) @@ -1393,7 +1393,7 @@ export const invoiceInboxExtension: Extension = { const { email_id, to, from, subject, message_id, created_at } = event.data - const serviceSupabase = createClient( + const serviceSupabase = createServiceRoleClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! ) diff --git a/extensions/general/skatteverket/lib/connection-store.ts b/extensions/general/skatteverket/lib/connection-store.ts index ad998bbe..ada34249 100644 --- a/extensions/general/skatteverket/lib/connection-store.ts +++ b/extensions/general/skatteverket/lib/connection-store.ts @@ -1,4 +1,5 @@ -import { createClient, type SupabaseClient } from '@supabase/supabase-js' +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') @@ -23,7 +24,7 @@ function getServiceClient(): SupabaseClient { 'skatteverket connection-store requires NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY' ) } - _serviceClient = createClient(url, key, { auth: { persistSession: false } }) + _serviceClient = createServiceRoleClient(url, key) return _serviceClient } diff --git a/extensions/general/skatteverket/lib/token-store.ts b/extensions/general/skatteverket/lib/token-store.ts index 441dbc14..6348ad56 100644 --- a/extensions/general/skatteverket/lib/token-store.ts +++ b/extensions/general/skatteverket/lib/token-store.ts @@ -1,5 +1,6 @@ import crypto from 'crypto' -import { createClient, type SupabaseClient } from '@supabase/supabase-js' +import { type SupabaseClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { createLogger } from '@/lib/logger' import type { SkatteverketTokens } from '../types' import { SkatteverketAuthError } from './api-client' @@ -32,7 +33,7 @@ function getServiceClient(): SupabaseClient { if (!url || !key) { throw new Error('skatteverket token-store requires NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY') } - _serviceClient = createClient(url, key, { auth: { persistSession: false } }) + _serviceClient = createServiceRoleClient(url, key) return _serviceClient } diff --git a/extensions/general/whatsapp-inbox/index.ts b/extensions/general/whatsapp-inbox/index.ts index 9d149827..8b2c6c46 100644 --- a/extensions/general/whatsapp-inbox/index.ts +++ b/extensions/general/whatsapp-inbox/index.ts @@ -28,7 +28,7 @@ import type { Extension, ExtensionContext } from '@/lib/extensions/types' import { NextResponse } from 'next/server' -import { createClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import type { SupabaseClient } from '@supabase/supabase-js' import { z } from 'zod' import { createServiceClient } from '@/lib/supabase/server' @@ -90,7 +90,7 @@ const DefaultCompanySchema = z.object({ }) function buildServiceClient(): SupabaseClient { - return createClient( + return createServiceRoleClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, ) diff --git a/lib/api/v1/with-api-v1.ts b/lib/api/v1/with-api-v1.ts index 082ffe5f..0dada60f 100644 --- a/lib/api/v1/with-api-v1.ts +++ b/lib/api/v1/with-api-v1.ts @@ -34,7 +34,8 @@ * }) */ -import { createClient, type SupabaseClient } from '@supabase/supabase-js' +import { type SupabaseClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { @@ -149,7 +150,7 @@ function createAnonClient(): SupabaseClient { '[api/v1] NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY must be set to serve public-scope v1 endpoints', ) } - return createClient(url, key) + return createServiceRoleClient(url, key) } /** diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 963b86ce..5c983edb 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -1,5 +1,5 @@ import crypto from 'crypto' -import { createClient } from '@supabase/supabase-js' +import { createServiceRoleClient } from '@/lib/supabase/service-client' const KEY_PREFIX = 'gnubok_sk_' const REFRESH_TOKEN_PREFIX = 'gnubok_rt_' @@ -363,7 +363,7 @@ export function validateScopes(scopes: unknown): ApiKeyScope[] | null { * Used for API key validation (MCP, webhooks) where there's no browser session. */ export function createServiceClientNoCookies() { - return createClient( + return createServiceRoleClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! ) diff --git a/lib/supabase/__tests__/service-client.test.ts b/lib/supabase/__tests__/service-client.test.ts new file mode 100644 index 00000000..dc2fa68c --- /dev/null +++ b/lib/supabase/__tests__/service-client.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const createClientMock = vi.fn(() => ({ from: vi.fn() })) + +vi.mock('@supabase/supabase-js', () => ({ + createClient: (...args: unknown[]) => createClientMock(...(args as [])), +})) + +import { createServiceRoleClient, SERVER_AUTH_OPTIONS } from '../service-client' + +/** The auth block supabase-js was actually constructed with. */ +function authArg() { + const [, , options] = createClientMock.mock.calls[0] as unknown as [ + string, + string, + { auth?: Record } | undefined, + ] + return options?.auth +} + +describe('createServiceRoleClient', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('disables the auth refresh ticker', () => { + // The whole point: autoRefreshToken defaults to true, and off-browser + // auth-js starts a 30 s setInterval that is never cleared. unref() keeps + // the process exitable but leaves the timer a GC root, so every client + // retains its request scope until the heap is gone. + createServiceRoleClient('https://example.supabase.co', 'service-key') + + expect(authArg()).toMatchObject({ autoRefreshToken: false, persistSession: false }) + }) + + it('passes url and key through unchanged', () => { + createServiceRoleClient('https://example.supabase.co', 'service-key') + + const [url, key] = createClientMock.mock.calls[0] as unknown as [string, string] + expect(url).toBe('https://example.supabase.co') + expect(key).toBe('service-key') + }) + + it('keeps caller options that are not auth', () => { + createServiceRoleClient('https://example.supabase.co', 'service-key', { + db: { schema: 'public' }, + global: { headers: { 'x-test': '1' } }, + }) + + const [, , options] = createClientMock.mock.calls[0] as unknown as [ + string, + string, + { db?: unknown; global?: unknown }, + ] + expect(options.db).toEqual({ schema: 'public' }) + expect(options.global).toEqual({ headers: { 'x-test': '1' } }) + }) + + it('refuses to let a caller re-enable the ticker', () => { + // SERVER_AUTH_OPTIONS is spread last precisely so this cannot happen: a + // caller copying an old snippet must not be able to reintroduce the leak. + createServiceRoleClient('https://example.supabase.co', 'service-key', { + auth: { autoRefreshToken: true, persistSession: true }, + }) + + expect(authArg()).toMatchObject({ autoRefreshToken: false, persistSession: false }) + }) + + it('keeps unrelated auth options the caller set', () => { + createServiceRoleClient('https://example.supabase.co', 'service-key', { + auth: { storageKey: 'custom-key' }, + }) + + expect(authArg()).toMatchObject({ + storageKey: 'custom-key', + autoRefreshToken: false, + persistSession: false, + }) + }) + + it('exports the options it applies', () => { + expect(SERVER_AUTH_OPTIONS).toEqual({ persistSession: false, autoRefreshToken: false }) + }) +}) diff --git a/lib/supabase/service-client.ts b/lib/supabase/service-client.ts new file mode 100644 index 00000000..32849c30 --- /dev/null +++ b/lib/supabase/service-client.ts @@ -0,0 +1,63 @@ +import { createClient, type SupabaseClient, type SupabaseClientOptions } from '@supabase/supabase-js' + +/** + * Auth options every server-side Supabase client MUST use. + * + * `autoRefreshToken` defaults to TRUE in supabase-js, and in a non-browser + * environment @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, stored on the client as + * `autoRefreshTicker`. It calls `.unref()`, so the process can still exit and + * nothing looks wrong in tests or on Vercel, where the process is short-lived + * and torn down before the tickers accumulate. But `unref()` does NOT make a + * timer collectable: it stays registered in the event loop's timer list and + * remains a GC root for its callback, which closes over the GoTrueClient, the + * SupabaseClient, and everything the surrounding request scope captured. + * + * A long-running self-hosted process therefore leaks one timer plus one entire + * request graph (socket, IncomingMessage, ServerResponse, headers, cookies, + * route context: roughly 100 kB) per client constructed. Observed in the wild + * on 2026-08-13: a self-hosted instance died of "JavaScript heap out of memory" + * after 42 h, the last 24 of them completely idle. A heap snapshot showed 445 + * retained request graphs and ~1050 Timeouts in the 30 000 ms bucket, retained + * via `autoRefreshTicker`. The rate matched the traffic exactly: the Docker + * healthcheck polls /api/health every 30 s (2 clients/min) and the webhook + * dispatch cron runs every minute (1 client/min), so 3/min x 148 min = 444. + * + * `persistSession` is disabled for the same reason it always is on the server: + * there is no browser storage to persist into, and a service-role client has no + * user session to keep. + */ +export const SERVER_AUTH_OPTIONS = { + persistSession: false, + autoRefreshToken: false, +} as const + +/** + * Construct a server-side Supabase client that cannot leak a refresh ticker. + * + * Use this instead of importing `createClient` from '@supabase/supabase-js' + * directly in any code that runs on the server (API routes, crons, extension + * handlers, service-role helpers). `scripts/checks/no-new-antipatterns.mjs` + * enforces it. + * + * The auth options are spread LAST, so a caller passing its own `auth` block + * cannot accidentally re-enable the ticker. + * + * Browser clients are a different case and must keep the ticker: a signed-in + * tab genuinely needs its access token refreshed. Those go through + * `lib/supabase/client.ts`, which is unaffected. + */ +export function createServiceRoleClient( + url: string, + key: string, + options?: SupabaseClientOptions<'public'>, +): SupabaseClient { + return createClient(url, key, { + ...options, + auth: { ...options?.auth, ...SERVER_AUTH_OPTIONS }, + }) +} diff --git a/scripts/checks/no-new-antipatterns.mjs b/scripts/checks/no-new-antipatterns.mjs index 30b50069..79a79bd9 100644 --- a/scripts/checks/no-new-antipatterns.mjs +++ b/scripts/checks/no-new-antipatterns.mjs @@ -58,7 +58,14 @@ * the four Skatteverket-bound org-number paths disagreed outright about * what "valid" meant, which is the kind of drift a customer only discovers * when a filing fails at the deadline. Tracked as a count. - * 9. off-ladder-radius: a border-radius class outside the locked ladder + * 9. leaky-supabase-client: server code importing supabase-js's `createClient` + * as a value instead of `createServiceRoleClient()`. The default + * `autoRefreshToken: true` starts a 30 s setInterval that is never + * cleared; `unref()` keeps the process exitable but not the timer + * collectable, so each constructed client retains its whole request scope. + * Killed a self-hosted instance after 42 idle hours (2026-08-13). No + * baseline: the count is 0 today. + * 10. off-ladder-radius: a border-radius class outside the locked ladder * (pill / rounded-xl overlays / rounded-lg surfaces / rounded-sm leaves; * see .claude/rules/design.md). Before the 2026-08 migration the UI had * seven radii in circulation (4/5/6/8/12/16px + pill) and one toolbar row @@ -181,6 +188,64 @@ function findDirectJelInserts() { .sort() } +// The one module allowed to import supabase-js's createClient as a value: it +// is the wrapper that applies SERVER_AUTH_OPTIONS. +const LEAKY_CLIENT_SANCTIONED = new Set(['lib/supabase/service-client.ts']) +const SUPABASE_JS_IMPORT_RE = /import\s+(type\s+)?\{([^}]*)\}\s*from\s*['"]@supabase\/supabase-js['"]/g +// A namespace import hands over the whole module, so `sb.createClient(...)` is +// reachable without ever naming it in the import. Treat any value-namespace +// import as leaky rather than trying to track member access. +const SUPABASE_JS_NAMESPACE_RE = + /import\s+(type\s+)?\*\s+as\s+\w+\s+from\s*['"]@supabase\/supabase-js['"]/g + +/** + * Files that import supabase-js's `createClient` as a VALUE instead of going + * through createServiceRoleClient(). + * + * `autoRefreshToken` defaults to true, and auth-js starts the 30 s refresh + * ticker unconditionally off-browser. The ticker calls unref(), so the process + * still exits and nothing fails in tests or on Vercel, but unref does not make + * a timer collectable: it stays a GC root for its callback and retains the + * client plus the whole request scope around it. A self-hosted instance died of + * heap exhaustion after 42 idle hours this way (2026-08-13), holding 445 + * request graphs and ~1050 Timeouts in the 30 000 ms bucket. + * + * Both named (`{ createClient }`) and namespace (`* as sb`) value imports count: + * the latter reaches createClient through member access without naming it. + * + * Type-only imports are fine; so is the browser client, which needs the ticker + * and is built on @supabase/ssr's createBrowserClient anyway. + */ +function findLeakySupabaseClients() { + const files = [ + ...walk(path.join(ROOT, 'lib'), ['.ts', '.tsx']), + ...walk(path.join(ROOT, 'app'), ['.ts', '.tsx']), + ...walk(path.join(ROOT, 'extensions'), ['.ts', '.tsx']), + ] + return files + .filter((f) => { + const r = rel(f) + if (LEAKY_CLIENT_SANCTIONED.has(r)) return false + if (r.includes('__tests__/') || r.endsWith('.test.ts')) return false + const src = fs.readFileSync(f, 'utf8') + for (const m of src.matchAll(SUPABASE_JS_IMPORT_RE)) { + const [, typeOnly, bindings] = m + if (typeOnly) continue + const bindsCreateClient = bindings + .split(',') + .map((b) => b.trim()) + .some((b) => b === 'createClient' || b.startsWith('createClient as')) + if (bindsCreateClient) return true + } + for (const m of src.matchAll(SUPABASE_JS_NAMESPACE_RE)) { + if (!m[1]) return true + } + return false + }) + .map(rel) + .sort() +} + // Statement generators that legitimately read journal_entry_lines directly: // the trial-balance stack itself, and the reports whose whole job is to list // vouchers or lines rather than to aggregate a fiscal year's balances. @@ -678,6 +743,7 @@ const current = { handRolledInvariants: countHandRolledInvariants(), ledgerScanningReports: findLedgerScanningReports(), directJelInsert: findDirectJelInserts(), + leakySupabaseClients: findLeakySupabaseClients(), pinnedDepViolations: findPinnedDepViolations(), rawUserErrors: findRawUserErrors(), sekLabelledAmounts: findSekLabelledFxAmounts(ROOT), @@ -744,6 +810,23 @@ if (current.directJelInsert.length) { ) } +// 1b2. leaky-supabase-client: server code must construct clients through +// createServiceRoleClient(). No baseline: the count is 0 today. +if (current.leakySupabaseClients.length) { + failed = true + console.error( + `\nāœ— leaky-supabase-client: ${current.leakySupabaseClients.length} file(s) import supabase-js's ` + + `createClient as a value instead of createServiceRoleClient():`, + ) + current.leakySupabaseClients.forEach((f) => console.error(` ${f}`)) + console.error( + ' → import { createServiceRoleClient } from "@/lib/supabase/service-client". Constructing a\n' + + ' client directly leaves autoRefreshToken on, which starts a 30 s setInterval that is never\n' + + ' cleared and retains the client plus the whole request scope (heap death after ~42 h).\n' + + ' Type-only imports are fine: use `import type { SupabaseClient } from "@supabase/supabase-js"`.', + ) +} + // 1c. pinned-dep: a version-pinned dependency must match its pin EXACTLY, in // both package.json and the lockfile. No baseline: any drift is a hard failure. if (current.pinnedDepViolations.length) { @@ -916,5 +999,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, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.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, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted).`, )