* 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>
151 lines
4.7 KiB
TypeScript
151 lines
4.7 KiB
TypeScript
import { createServiceRoleClient } from '@/lib/supabase/service-client'
|
|
import { NextResponse } from 'next/server'
|
|
import { generateCalendarFeed } from '@/lib/calendar/ics-generator'
|
|
import { createLogger } from '@/lib/logger'
|
|
|
|
const log = createLogger('api/calendar/feed-token')
|
|
|
|
// In-memory rate limiting: token -> { count, resetAt }
|
|
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
|
const RATE_LIMIT_WINDOW_MS = 60_000 // 1 minute
|
|
const RATE_LIMIT_MAX = 60 // 60 requests per minute per token
|
|
|
|
// Periodic cleanup to prevent memory leaks (every 5 minutes)
|
|
let lastCleanup = Date.now()
|
|
function cleanupRateLimitMap() {
|
|
const now = Date.now()
|
|
if (now - lastCleanup < 5 * 60_000) return
|
|
lastCleanup = now
|
|
for (const [key, value] of rateLimitMap) {
|
|
if (now > value.resetAt) rateLimitMap.delete(key)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /api/calendar/feed/[token]
|
|
* Returns an ICS calendar feed for the given token
|
|
* No authentication required - the token IS the authentication
|
|
*/
|
|
export async function GET(
|
|
request: Request,
|
|
{ params }: { params: Promise<{ token: string }> }
|
|
) {
|
|
const { token } = await params
|
|
|
|
// Validate token format (UUID)
|
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
if (!uuidRegex.test(token)) {
|
|
return new NextResponse('Invalid token', { status: 400 })
|
|
}
|
|
|
|
// Rate limiting per token
|
|
cleanupRateLimitMap()
|
|
const nowMs = Date.now()
|
|
const rateEntry = rateLimitMap.get(token)
|
|
if (rateEntry && nowMs < rateEntry.resetAt) {
|
|
if (rateEntry.count >= RATE_LIMIT_MAX) {
|
|
return new NextResponse('Too many requests', { status: 429 })
|
|
}
|
|
rateEntry.count++
|
|
} else {
|
|
rateLimitMap.set(token, { count: 1, resetAt: nowMs + RATE_LIMIT_WINDOW_MS })
|
|
}
|
|
|
|
// Create service client (no user auth required)
|
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
|
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
|
|
if (!supabaseUrl || !supabaseServiceKey) {
|
|
return new NextResponse('Server configuration error', { status: 500 })
|
|
}
|
|
|
|
const supabase = createServiceRoleClient(supabaseUrl, supabaseServiceKey)
|
|
|
|
// Fetch feed settings by token
|
|
const { data: feed, error: feedError } = await supabase
|
|
.from('calendar_feeds')
|
|
.select('*')
|
|
.eq('feed_token', token)
|
|
.eq('is_active', true)
|
|
.single()
|
|
|
|
if (feedError || !feed) {
|
|
return new NextResponse('Feed not found or inactive', { status: 404 })
|
|
}
|
|
|
|
// Check token expiry
|
|
if (feed.expires_at && new Date(feed.expires_at) < new Date()) {
|
|
return new NextResponse('Feed token has expired', { status: 410 })
|
|
}
|
|
|
|
// Update access tracking
|
|
await supabase
|
|
.from('calendar_feeds')
|
|
.update({
|
|
last_accessed_at: new Date().toISOString(),
|
|
access_count: feed.access_count + 1,
|
|
})
|
|
.eq('id', feed.id)
|
|
|
|
// Calculate date range: 3 months back, 12 months forward
|
|
const now = new Date()
|
|
const startDate = new Date(now)
|
|
startDate.setMonth(startDate.getMonth() - 3)
|
|
const endDate = new Date(now)
|
|
endDate.setMonth(endDate.getMonth() + 12)
|
|
|
|
const startStr = startDate.toISOString().split('T')[0]
|
|
const endStr = endDate.toISOString().split('T')[0]
|
|
|
|
// Fetch relevant data based on feed options. Deadlines are always
|
|
// fetched: include_tax_deadlines only hides SYSTEM rows (the generator
|
|
// filters by source), while user-created deadlines always appear.
|
|
const [deadlinesResult, invoicesResult] = await Promise.all([
|
|
supabase
|
|
.from('deadlines')
|
|
.select('*')
|
|
.eq('company_id', feed.company_id)
|
|
.is('dismissed_at', null)
|
|
.gte('due_date', startStr)
|
|
.lte('due_date', endStr)
|
|
.order('due_date'),
|
|
|
|
// Invoices
|
|
feed.include_invoices
|
|
? supabase
|
|
.from('invoices')
|
|
.select('*, customer:customers(*)')
|
|
.eq('company_id', feed.company_id)
|
|
.gte('due_date', startStr)
|
|
.lte('due_date', endStr)
|
|
.order('due_date')
|
|
: { data: [] },
|
|
])
|
|
|
|
try {
|
|
const icsContent = await generateCalendarFeed(
|
|
{
|
|
deadlines: deadlinesResult.data || [],
|
|
invoices: invoicesResult.data || [],
|
|
},
|
|
{
|
|
includeTaxDeadlines: feed.include_tax_deadlines,
|
|
includeInvoices: feed.include_invoices,
|
|
}
|
|
)
|
|
|
|
return new NextResponse(icsContent, {
|
|
headers: {
|
|
'Content-Type': 'text/calendar; charset=utf-8',
|
|
'Content-Disposition': 'attachment; filename="erp-base.ics"',
|
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
'Pragma': 'no-cache',
|
|
'Expires': '0',
|
|
},
|
|
})
|
|
} catch (error) {
|
|
log.error('Error generating ICS feed', error as Error, { feedId: feed.id })
|
|
return new NextResponse('Failed to generate calendar feed', { status: 500 })
|
|
}
|
|
}
|