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).`, )