feat: production readiness — 3-extension deploy with security hardening and observability

- Strip extensions to enable-banking, ai-categorization, ai-chat only
- Remove push-notifications cron from vercel.json
- Add security headers (HSTS, CSP, X-Frame-Options, Permissions-Policy)
- Add /api/health endpoint for uptime monitoring
- Add env var validation in ensureInitialized()
- Fix SIE4 #IB opening balance records from year-end closing entry
- Replace in-memory ai-chat rate limiter with Supabase-backed distributed rate limiting
- Add Sentry error tracking scaffolding (@sentry/nextjs, instrumentation hook)
- Add AI token usage tracking (migration 047, usage-tracker, wired into both AI extensions)
- Include pending enable-banking and dashboard improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-02 11:32:43 +01:00
co-authored by Claude Opus 4.6
parent 1a1c6ba40f
commit 13725ffc16
33 changed files with 2852 additions and 271 deletions
@@ -72,9 +72,11 @@ export async function GET(request: Request) {
.limit(1)
.single()
let connectionId: string
if (findError || !pendingConnection) {
console.error('Could not find pending connection:', findError)
const { error: insertError } = await supabase
const { data: inserted, error: insertError } = await supabase
.from('bank_connections')
.insert({
user_id: state,
@@ -86,11 +88,14 @@ export async function GET(request: Request) {
consent_expires: consentExpiresAt,
last_synced_at: new Date().toISOString(),
})
.select('id')
.single()
if (insertError) {
if (insertError || !inserted) {
console.error('Insert error:', insertError)
throw new Error('Failed to create connection')
}
connectionId = inserted.id
} else {
const { error: updateError } = await supabase
.from('bank_connections')
@@ -106,6 +111,7 @@ export async function GET(request: Request) {
if (updateError) {
throw new Error('Failed to update connection')
}
connectionId = pendingConnection.id
}
const { data: userSettings } = await supabase
@@ -115,8 +121,8 @@ export async function GET(request: Request) {
.single()
const redirectTarget = userSettings?.onboarding_complete
? '/settings?bank_connected=true'
: '/onboarding?bank_connected=true'
? `/settings?bank_connected=true&connection_id=${connectionId}`
: `/onboarding?bank_connected=true&connection_id=${connectionId}`
return NextResponse.redirect(`${baseUrl}${redirectTarget}`)
} catch (error) {
@@ -37,12 +37,25 @@ export async function GET(request: Request) {
const supabase = createClient(supabaseUrl, supabaseServiceKey)
// Clean up stale pending connections (older than 1 hour)
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString()
const { data: stalePending } = await supabase
.from('bank_connections')
.delete()
.eq('status', 'pending')
.lt('created_at', oneHourAgo)
.select('id')
if (stalePending?.length) {
console.log(`[bank-sync-cron] Cleaned up ${stalePending.length} stale pending connections`)
}
const { data: connections, error: connError } = await supabase
.from('bank_connections')
.select('*')
.eq('status', 'active')
.order('last_synced_at', { ascending: true, nullsFirst: true })
.limit(10)
.limit(50)
if (connError) {
console.error('Failed to fetch bank connections:', connError)
@@ -53,6 +66,9 @@ export async function GET(request: Request) {
return NextResponse.json({ message: 'No active connections to sync', processed: 0 })
}
const startTime = Date.now()
const TIME_BUDGET_MS = 50_000 // 50s — leave 10s margin for Vercel timeout
const results: {
connectionId: string
userId: string
@@ -65,6 +81,11 @@ export async function GET(request: Request) {
}[] = []
for (const connection of connections) {
if (Date.now() - startTime > TIME_BUDGET_MS) {
console.log(`[bank-sync-cron] Time budget reached after ${results.length} connections`)
break
}
try {
const daysLeft = getDaysUntilExpiry(connection.consent_expires)
const isExpired = daysLeft !== null && daysLeft <= 0
@@ -97,24 +118,20 @@ export async function GET(request: Request) {
const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
let totalImported = 0
let totalDuplicates = 0
let totalErrors = 0
for (const account of accounts) {
const result = await syncAccountTransactions(
const syncResults = await Promise.all(
accounts.map(account => syncAccountTransactions(
supabase,
connection.user_id,
connection.id,
account,
fromDate,
toDate
)
))
)
totalImported += result.imported
totalDuplicates += result.duplicates
totalErrors += result.errors
}
const totalImported = syncResults.reduce((sum, r) => sum + r.imported, 0)
const totalDuplicates = syncResults.reduce((sum, r) => sum + r.duplicates, 0)
const totalErrors = syncResults.reduce((sum, r) => sum + r.errors, 0)
await supabase
.from('bank_connections')