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
@@ -1,5 +1,6 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { formatDate } from '@/lib/utils'
import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client'
@@ -67,8 +68,19 @@ export function BankConnectionStatus({
name?: string
currency: string
balance?: number
balance_updated_at?: string
}>) || []
const [now] = useState(() => Date.now())
function formatBalanceAge(updatedAt: string): string {
const hoursAgo = Math.floor((now - new Date(updatedAt).getTime()) / (1000 * 60 * 60))
if (hoursAgo < 1) return 'Nyss uppdaterat'
if (hoursAgo < 24) return `${hoursAgo}h sedan`
const daysAgo = Math.floor(hoursAgo / 24)
return `${daysAgo}d sedan`
}
return (
<div className="border rounded-lg p-4 space-y-4">
{/* Header */}
@@ -155,6 +167,11 @@ export function BankConnectionStatus({
currency: account.currency,
}).format(account.balance)}
</p>
{account.balance_updated_at && (
<p className="text-[10px] text-muted-foreground">
{formatBalanceAge(account.balance_updated_at)}
</p>
)}
</div>
)}
</div>
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { Loader2, Landmark } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import { BankSelector, type Bank } from './BankSelector'
@@ -18,8 +19,10 @@ export default function BankingSettingsPanel() {
const { toast } = useToast()
const supabase = createClient()
const { dialogProps, confirm } = useDestructiveConfirm()
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
const [isSyncing, setIsSyncing] = useState(false)
const [syncingConnectionId, setSyncingConnectionId] = useState<string | null>(null)
const [isConnecting, setIsConnecting] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [selectedBank, setSelectedBank] = useState<Bank | null>(null)
@@ -71,7 +74,7 @@ export default function BankingSettingsPanel() {
}
async function handleSyncTransactions(connectionId: string) {
setIsSyncing(true)
setSyncingConnectionId(connectionId)
try {
const response = await fetch('/api/extensions/ext/enable-banking/sync', {
@@ -100,27 +103,41 @@ export default function BankingSettingsPanel() {
})
}
setIsSyncing(false)
setSyncingConnectionId(null)
}
async function handleDisconnectBank(connectionId: string) {
const { error } = await supabase
.from('bank_connections')
.update({ status: 'revoked' })
.eq('id', connectionId)
const ok = await confirm({
title: 'Koppla bort bank?',
description: 'PSD2-samtycket kommer återkallas. Befintliga transaktioner påverkas inte.',
confirmLabel: 'Koppla bort',
variant: 'warning',
})
if (!ok) return
if (error) {
toast({
title: 'Fel',
description: 'Kunde inte koppla bort bank',
variant: 'destructive',
try {
const response = await fetch('/api/extensions/ext/enable-banking/disconnect', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connectionId }),
})
} else {
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Disconnect failed')
}
toast({
title: 'Bank bortkopplad',
description: 'Bankanslutningen har tagits bort',
description: 'Bankanslutningen och PSD2-samtycket har återkallats',
})
fetchConnections()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte koppla bort bank',
variant: 'destructive',
})
}
}
@@ -136,6 +153,8 @@ export default function BankingSettingsPanel() {
return (
<div className="space-y-6">
<DestructiveConfirmDialog {...dialogProps} />
{/* Connected banks */}
{activeConnections.length > 0 && (
<Card>
@@ -149,7 +168,7 @@ export default function BankingSettingsPanel() {
connection={connection}
onSync={handleSyncTransactions}
onDisconnect={handleDisconnectBank}
isSyncing={isSyncing}
isSyncing={syncingConnectionId === connection.id}
/>
))}
</CardContent>
+68 -10
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import {
startAuthorization,
getASPSPs,
deleteSession,
type ASPSP,
} from './lib/api-client'
import { syncAccountTransactions } from './lib/sync'
@@ -80,6 +81,15 @@ export const enableBankingExtension: Extension = {
}
try {
// Determine PSU type from entity type
const { data: companySettings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('user_id', user.id)
.single()
const psuType = companySettings?.entity_type === 'aktiebolag' ? 'business' : 'personal'
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/extensions/enable-banking/callback`
const { url, authorization_id } = await startAuthorization(
@@ -87,7 +97,7 @@ export const enableBankingExtension: Extension = {
aspsp_country,
redirectUrl,
user.id,
'personal'
psuType
)
const { data: connection, error } = await supabase
@@ -160,11 +170,8 @@ export const enableBankingExtension: Extension = {
// Use ctx.services.ingestTransactions when available
const ingestFn = ctx?.services.ingestTransactions
let totalImported = 0
let totalDuplicates = 0
for (const account of accounts) {
const result = await syncAccountTransactions(
const results = await Promise.all(
accounts.map(account => syncAccountTransactions(
supabase,
user.id,
connection.id,
@@ -172,11 +179,11 @@ export const enableBankingExtension: Extension = {
fromDate,
toDate,
ingestFn
)
))
)
totalImported += result.imported
totalDuplicates += result.duplicates
}
const totalImported = results.reduce((sum, r) => sum + r.imported, 0)
const totalDuplicates = results.reduce((sum, r) => sum + r.duplicates, 0)
const syncedAt = new Date().toISOString()
await supabase
@@ -220,6 +227,57 @@ export const enableBankingExtension: Extension = {
}
},
},
{
method: 'DELETE',
path: '/disconnect',
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { connection_id } = await request.json()
if (!connection_id) {
return NextResponse.json({ error: 'connection_id is required' }, { status: 400 })
}
const { data: connection, error: findError } = await supabase
.from('bank_connections')
.select('id, session_id, status')
.eq('id', connection_id)
.eq('user_id', user.id)
.single()
if (findError || !connection) {
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
}
// Revoke PSD2 consent if session exists
if (connection.session_id) {
try {
await deleteSession(connection.session_id)
} catch (error) {
// Consent may already be expired — log and continue
log.error('Failed to revoke PSD2 session (may be expired):', error)
}
}
const { error: updateError } = await supabase
.from('bank_connections')
.update({ status: 'revoked', session_id: null })
.eq('id', connection.id)
if (updateError) {
return NextResponse.json({ error: 'Failed to disconnect' }, { status: 500 })
}
return NextResponse.json({ success: true })
},
},
],
eventHandlers: [],
@@ -63,8 +63,9 @@ export async function syncAccountTransactions(
try {
const balance = await getAccountBalance(account.uid)
account.balance = balance.amount
account.balance_updated_at = new Date().toISOString()
} catch {
// Ignore balance fetch errors
// Keep previous balance, don't update timestamp
}
return {
@@ -6,6 +6,7 @@ export interface StoredAccount {
name?: string
currency: string
balance?: number
balance_updated_at?: string
}
// Re-export API types from the client