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
+35
View File
@@ -0,0 +1,35 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
const log = createLogger('ai-usage')
interface TokenUsage {
inputTokens: number
outputTokens: number
model: string
}
/**
* Track AI token usage. Non-blocking — errors are logged, not thrown.
*/
export function trackTokenUsage(
supabase: SupabaseClient,
userId: string,
extensionId: string,
usage: TokenUsage
): void {
supabase
.from('ai_usage_tracking')
.insert({
user_id: userId,
extension_id: extensionId,
model: usage.model,
input_tokens: usage.inputTokens,
output_tokens: usage.outputTokens,
})
.then(({ error }) => {
if (error) {
log.error(`Failed to track usage for ${extensionId}:`, error.message)
}
})
}
@@ -1,12 +1,7 @@
// AUTO-GENERATED — do not edit. Run `npm run setup:extensions` to regenerate.
export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
'receipt-ocr',
'enable-banking',
'ai-categorization',
'ai-chat',
'push-notifications',
'invoice-inbox',
'calendar',
'enable-banking',
'email',
])
+2 -12
View File
@@ -1,21 +1,11 @@
// AUTO-GENERATED — do not edit. Run `npm run setup:extensions` to regenerate.
import type { Extension } from '../types'
import { receiptOcrExtension } from '@/extensions/general/receipt-ocr'
import { enableBankingExtension } from '@/extensions/general/enable-banking'
import { aiCategorizationExtension } from '@/extensions/general/ai-categorization'
import { aiChatExtension } from '@/extensions/general/ai-chat'
import { pushNotificationsExtension } from '@/extensions/general/push-notifications'
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
import { calendarExtension } from '@/extensions/general/calendar'
import { enableBankingExtension } from '@/extensions/general/enable-banking'
import { emailExtension } from '@/extensions/general/email'
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
receiptOcrExtension,
enableBankingExtension,
aiCategorizationExtension,
aiChatExtension,
pushNotificationsExtension,
invoiceInboxExtension,
calendarExtension,
enableBankingExtension,
emailExtension,
]
@@ -4,21 +4,16 @@ import type { ExtensionDefinition } from '../types'
export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
'general': [
{
"slug": "receipt-ocr",
"name": "Kvittoscanning",
"slug": "enable-banking",
"name": "Bankintegration (PSD2)",
"sector": "general",
"category": "import",
"icon": "Camera",
"icon": "Landmark",
"dataPattern": "manual",
"description": "Skanna kvitton och extrahera data automatiskt",
"longDescription": "Ladda upp kvittofoton och låt systemet automatiskt extrahera leverantör, belopp, moms och datum. Sparar tid och minskar manuell inmatning.",
"description": "Automatisk banktransaktionssynk via PSD2",
"longDescription": "Koppla ditt bankkonto direkt och synka transaktioner automatiskt via säker PSD2-bankintegration. Stöder de flesta svenska banker.",
"hasOwnData": true,
"quickAction": {
"label": "Skanna kvitto",
"description": "Fotografera & spara",
"icon": "Camera",
"href": "/receipts/scan"
}
"subscriptionNotice": "Denna integration kräver ett aktivt Enable Banking-abonnemang. Utan abonnemang kommer bankintegration inte att fungera."
},
{
"slug": "ai-categorization",
@@ -62,79 +57,5 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
"event": "open-ai-chat"
}
},
{
"slug": "push-notifications",
"name": "Push-notiser",
"sector": "general",
"category": "operations",
"icon": "Bell",
"dataPattern": "core",
"description": "Händelsenotiser för bokföringsaktiviteter",
"longDescription": "Få push-notiser direkt i webbläsaren när viktiga händelser sker — nya fakturor, förfallna betalningar, slutförda bokföringar med mera.",
"readsCoreTables": [
"journal_entries",
"invoices",
"receipts"
]
},
{
"slug": "invoice-inbox",
"name": "Dokumentinkorg",
"sector": "general",
"category": "import",
"icon": "Inbox",
"dataPattern": "manual",
"description": "Ta emot alla dokument via e-post — fakturor, kvitton och myndighetspost",
"longDescription": "Skicka alla affärsdokument till en dedikerad e-postadress. AI klassificerar automatiskt dokumenttyp (faktura, kvitto, myndighetspost), extraherar data och matchar mot transaktioner. En inkorg för alla dokument.",
"hasOwnData": true,
"quickAction": {
"label": "Dokumentinkorg",
"description": "Granska inkommande dokument",
"icon": "Inbox",
"href": "/e/general/invoice-inbox"
}
},
{
"slug": "calendar",
"name": "Kalender",
"sector": "general",
"category": "operations",
"icon": "Calendar",
"dataPattern": "core",
"description": "Fullständig kalendervy med månads-, vecko- och dagsvisning",
"longDescription": "Se alla fakturadatum och deadlines i en interaktiv kalender med månads-, vecko- och dagsvy.",
"readsCoreTables": [
"invoices",
"deadlines",
"customers"
]
},
{
"slug": "enable-banking",
"name": "Bankintegration (PSD2)",
"sector": "general",
"category": "import",
"icon": "Landmark",
"dataPattern": "manual",
"description": "Automatisk banktransaktionssynk via PSD2",
"longDescription": "Koppla ditt bankkonto direkt och synka transaktioner automatiskt via säker PSD2-bankintegration. Stöder de flesta svenska banker.",
"hasOwnData": true,
"subscriptionNotice": "Denna integration kräver ett aktivt Enable Banking-abonnemang. Utan abonnemang kommer bankintegration inte att fungera."
},
{
"slug": "email",
"name": "E-post (Resend)",
"sector": "general",
"category": "operations",
"icon": "Mail",
"dataPattern": "core",
"description": "Skicka fakturor och påminnelser via e-post",
"longDescription": "Aktiverar e-postfunktioner: skicka fakturor till kunder, automatiska betalningspåminnelser (15/30/45 dagar), och e-postmeddelanden. Kräver ett Resend-konto med verifierad domän.",
"readsCoreTables": [
"invoices",
"customers",
"company_settings"
]
},
],
}
+1 -5
View File
@@ -4,11 +4,7 @@ import type { ComponentType } from 'react'
import type { WorkspaceComponentProps } from '../workspace-registry'
export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>> = {
'general/receipt-ocr': dynamic(() => import('@/components/extensions/general/ReceiptOcrWorkspace')),
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
'general/ai-categorization': dynamic(() => import('@/components/extensions/general/AiCategorizationWorkspace')),
'general/ai-chat': dynamic(() => import('@/components/extensions/general/AiChatWorkspace')),
'general/push-notifications': dynamic(() => import('@/components/extensions/general/PushNotificationsWorkspace')),
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/DocumentInboxWorkspace')),
'general/calendar': dynamic(() => import('@/components/extensions/general/CalendarWorkspace')),
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
}
+52
View File
@@ -2,9 +2,60 @@ import { loadExtensions } from '@/lib/extensions/loader'
import { setContextFactory } from '@/lib/extensions/registry'
import { createExtensionContext } from '@/lib/extensions/context-factory'
import { registerSupplierInvoiceHandler } from '@/lib/bookkeeping/handlers/supplier-invoice-handler'
import { createLogger } from '@/lib/logger'
const log = createLogger('init')
let initialized = false
const REQUIRED_CORE_VARS = [
'NEXT_PUBLIC_SUPABASE_URL',
'NEXT_PUBLIC_SUPABASE_ANON_KEY',
'SUPABASE_SERVICE_ROLE_KEY',
'NEXT_PUBLIC_APP_URL',
'CRON_SECRET',
] as const
const REQUIRED_EXTENSION_VARS = [
'ENABLE_BANKING_APP_ID',
'ENABLE_BANKING_PRIVATE_KEY',
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
] as const
const OPTIONAL_VARS = [
'SENTRY_DSN',
'LANGFUSE_SECRET_KEY',
'LANGFUSE_PUBLIC_KEY',
] as const
function validateEnvironment(): void {
const missing: string[] = []
for (const v of REQUIRED_CORE_VARS) {
if (!process.env[v]) missing.push(v)
}
if (missing.length > 0) {
throw new Error(`Missing required environment variables: ${missing.join(', ')}`)
}
const missingExt: string[] = []
for (const v of REQUIRED_EXTENSION_VARS) {
if (!process.env[v]) missingExt.push(v)
}
if (missingExt.length > 0) {
throw new Error(`Missing required extension environment variables: ${missingExt.join(', ')}`)
}
for (const v of OPTIONAL_VARS) {
if (!process.env[v]) {
log.warn(`Optional environment variable ${v} is not set`)
}
}
}
/**
* Ensure the system is initialized (extensions loaded, context factory wired,
* core event handlers registered).
@@ -15,6 +66,7 @@ export function ensureInitialized(): void {
if (initialized) return
initialized = true
validateEnvironment()
setContextFactory(createExtensionContext)
registerSupplierInvoiceHandler()
loadExtensions()
+15 -2
View File
@@ -116,8 +116,21 @@ export async function generateSIEExport(
}
// === Opening balances (IB) ===
// For now, all zeros unless we have data from previous periods
// #IB 0 accountNumber amount
if (period.opening_balance_entry_id) {
const { data: obEntry } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('id', period.opening_balance_entry_id)
.eq('user_id', userId)
.single()
if (obEntry?.lines) {
for (const line of (obEntry.lines as JournalEntryLine[])) {
const amount = (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
lines.push(`#IB 0 ${line.account_number} ${formatAmount(amount)}`)
}
}
}
// === Journal entries (VER + TRANS) ===
for (const entry of (entries as JournalEntry[]) || []) {