fix(pitch-deck): close auth gaps, isolate finanzplan scenario access, enforce TS
Some checks failed
Build pitch-deck / build-push-deploy (push) Failing after 1m4s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 57s
CI / test-python-voice (push) Successful in 42s
CI / test-bqas (push) Successful in 42s

D1: Remove /api/admin/fp-patch from PUBLIC_PATHS — it was returning live financial
data (fp_liquiditaet rows) to any unauthenticated caller; middleware admin gate now
applies as it does for all /api/admin/* paths.

D2: Add PITCH_ADMIN_SECRET bearer guard to POST /api/financial-model (create scenario)
and PUT /api/financial-model/assumptions (update assumptions) — any authenticated
investor could previously create/modify global financial model data.

D3: Add PITCH_ADMIN_SECRET bearer guard to POST /api/finanzplan/compute — any
investor could trigger a full DB recomputation across all fp_* tables. Also replace
String(error) in error response with a static message.

D4: GET /api/finanzplan/[sheetName] now ignores ?scenarioId= for non-admin callers;
investors always receive the default scenario only. Previously any investor could
enumerate UUIDs and read any scenario's financials including other investors' plans.

D9: Remove `name` from the non-admin /api/finanzplan response — scenario names like
"Wandeldarlehen v2" reveal internal versioning to investors.

D10: Remove hardcoded postgres://breakpilot:breakpilot123@localhost fallback from
lib/db.ts — missing DATABASE_URL now fails loudly instead of silently using stale
credentials that are committed to the repository.

D6: Fix all 4 TypeScript errors that were masked by ignoreBuildErrors:true; bump
tsconfig target to ES2018 (regex s flag in ChatFAB), type lang as 'de'|'en' in
chat route, add 'as string' assertion in adapter.ts. Remove ignoreBuildErrors:true
from next.config.js so future type errors fail the build rather than being silently
shipped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-24 09:08:50 +02:00
parent 75bd0c29f3
commit 41bc522b5b
11 changed files with 105 additions and 41 deletions

View File

@@ -296,7 +296,8 @@ ${fpSummary ? '\n' + fpSummary : ''}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const { message, history = [], lang = 'de', slideContext, faqContext } = body const { message, history = [], lang: langParam = 'de', slideContext, faqContext } = body
const lang: 'de' | 'en' = langParam === 'en' ? 'en' : 'de'
if (!message || typeof message !== 'string') { if (!message || typeof message !== 'string') {
return NextResponse.json({ error: 'Message is required' }, { status: 400 }) return NextResponse.json({ error: 'Message is required' }, { status: 400 })

View File

@@ -1,8 +1,12 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db' import pool from '@/lib/db'
import { validateAdminSecret } from '@/lib/auth'
// PUT: Update a single assumption and trigger recompute // PUT: Update a single assumption — admin only
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
if (!validateAdminSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const { scenarioId, key, value } = body const { scenarioId, key, value } = body

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db' import pool from '@/lib/db'
import { getSessionFromCookie } from '@/lib/auth' import { getSessionFromCookie, validateAdminSecret } from '@/lib/auth'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -67,8 +67,11 @@ export async function GET() {
} }
} }
// POST: Create a new scenario // POST: Create a new scenario — admin only
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
if (!validateAdminSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const { name, description, color, copyFrom } = body const { name, description, color, copyFrom } = body

View File

@@ -14,6 +14,30 @@ const TABLE_MAP: Record<string, string> = {
guv: 'fp_guv', guv: 'fp_guv',
} }
// Whitelist of scalar columns that may be edited per table
const SCALAR_COLUMNS_WHITELIST: Record<string, string[]> = {
fp_personalkosten: ['row_label', 'start_date', 'end_date', 'position', 'sort_order'],
fp_investitionen: ['row_label', 'sort_order', 'position'],
fp_betriebliche_aufwendungen: ['row_label', 'sort_order'],
fp_umsatzerloese: ['row_label', 'sort_order'],
fp_materialaufwand: ['row_label', 'sort_order'],
fp_liquiditaet: ['row_label', 'sort_order'],
fp_kunden: ['row_label', 'sort_order'],
fp_kunden_summary: ['row_label', 'sort_order'],
fp_sonst_ertraege: ['row_label', 'sort_order'],
fp_guv: ['row_label', 'sort_order'],
}
// Valid month key: m1 .. m60
const MONTH_KEY_RE = /^m([1-9]|[1-5][0-9]|60)$/
function validateAdminSecret(request: NextRequest): boolean {
const secret = process.env.PITCH_ADMIN_SECRET
if (!secret) return false
const auth = request.headers.get('authorization') ?? ''
return auth === `Bearer ${secret}`
}
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ sheetName: string }> } { params }: { params: Promise<{ sheetName: string }> }
@@ -24,7 +48,9 @@ export async function GET(
return NextResponse.json({ error: `Unknown sheet: ${sheetName}` }, { status: 400 }) return NextResponse.json({ error: `Unknown sheet: ${sheetName}` }, { status: 400 })
} }
const scenarioId = request.nextUrl.searchParams.get('scenarioId') // Only admin callers may query an arbitrary scenarioId; investors always see the default
const isAdmin = validateAdminSecret(request)
const scenarioId = isAdmin ? request.nextUrl.searchParams.get('scenarioId') : null
try { try {
let query = `SELECT * FROM ${table}` let query = `SELECT * FROM ${table}`
@@ -42,8 +68,8 @@ export async function GET(
return NextResponse.json({ sheet: sheetName, rows }, { return NextResponse.json({ sheet: sheetName, rows }, {
headers: { 'Cache-Control': 'no-store' }, headers: { 'Cache-Control': 'no-store' },
}) })
} catch (error) { } catch {
return NextResponse.json({ error: String(error) }, { status: 500 }) return NextResponse.json({ error: 'Query failed' }, { status: 500 })
} }
} }
@@ -51,6 +77,11 @@ export async function PUT(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ sheetName: string }> } { params }: { params: Promise<{ sheetName: string }> }
) { ) {
// C2: Admin-only — require PITCH_ADMIN_SECRET bearer token
if (!validateAdminSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { sheetName } = await params const { sheetName } = await params
const table = TABLE_MAP[sheetName] const table = TABLE_MAP[sheetName]
if (!table) { if (!table) {
@@ -59,33 +90,47 @@ export async function PUT(
try { try {
const body = await request.json() const body = await request.json()
const { rowId, updates } = body // updates: { field: value } or { m3: 1500 } for monthly values const { rowId, updates } = body
if (!rowId) { if (!rowId || typeof rowId !== 'string') {
return NextResponse.json({ error: 'rowId required' }, { status: 400 }) return NextResponse.json({ error: 'rowId required' }, { status: 400 })
} }
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
return NextResponse.json({ error: 'updates object required' }, { status: 400 })
}
// Check if updating monthly values (JSONB) or scalar fields // C1: Separate and validate monthly vs scalar keys
const monthlyKeys = Object.keys(updates).filter(k => k.startsWith('m') && !isNaN(parseInt(k.substring(1)))) const monthlyKeys = Object.keys(updates).filter(k => MONTH_KEY_RE.test(k))
const scalarKeys = Object.keys(updates).filter(k => !k.startsWith('m') || isNaN(parseInt(k.substring(1)))) const scalarKeys = Object.keys(updates).filter(k => !MONTH_KEY_RE.test(k))
// Validate monthly values are numbers
for (const k of monthlyKeys) {
if (typeof updates[k] !== 'number' && isNaN(Number(updates[k]))) {
return NextResponse.json({ error: `Invalid value for ${k}` }, { status: 400 })
}
}
if (monthlyKeys.length > 0) { if (monthlyKeys.length > 0) {
// Update specific months in the values JSONB
const jsonbSet = monthlyKeys.map(k => `'${k}', '${updates[k]}'::jsonb`).join(', ')
const valuesCol = sheetName === 'personalkosten' ? 'values_brutto' : 'values' const valuesCol = sheetName === 'personalkosten' ? 'values_brutto' : 'values'
// Use jsonb_set for each key // Build sanitized JSON patch object — no interpolation of user data into SQL
let updateSql = `UPDATE ${table} SET ` const patch: Record<string, number> = {}
const setClauses: string[] = []
for (const k of monthlyKeys) { for (const k of monthlyKeys) {
setClauses.push(`${valuesCol} = jsonb_set(${valuesCol}, '{${k}}', '${updates[k]}')`) patch[k] = Number(updates[k])
} }
setClauses.push(`updated_at = NOW()`) await pool.query(
updateSql += setClauses.join(', ') + ` WHERE id = $1` `UPDATE ${table} SET ${valuesCol} = ${valuesCol} || $1::jsonb, updated_at = NOW() WHERE id = $2`,
await pool.query(updateSql, [rowId]) [JSON.stringify(patch), rowId]
)
} }
if (scalarKeys.length > 0) { if (scalarKeys.length > 0) {
// Update scalar columns directly // C1: Validate scalar keys against whitelist
const allowed = SCALAR_COLUMNS_WHITELIST[table] ?? []
for (const k of scalarKeys) {
if (!allowed.includes(k)) {
return NextResponse.json({ error: `Column '${k}' is not editable` }, { status: 400 })
}
}
const setClauses = scalarKeys.map((k, i) => `${k} = $${i + 2}`).join(', ') const setClauses = scalarKeys.map((k, i) => `${k} = $${i + 2}`).join(', ')
await pool.query( await pool.query(
`UPDATE ${table} SET ${setClauses}, updated_at = NOW() WHERE id = $1`, `UPDATE ${table} SET ${setClauses}, updated_at = NOW() WHERE id = $1`,
@@ -93,10 +138,9 @@ export async function PUT(
) )
} }
// Return updated row
const { rows } = await pool.query(`SELECT * FROM ${table} WHERE id = $1`, [rowId]) const { rows } = await pool.query(`SELECT * FROM ${table} WHERE id = $1`, [rowId])
return NextResponse.json({ updated: rows[0] }) return NextResponse.json({ updated: rows[0] })
} catch (error) { } catch {
return NextResponse.json({ error: String(error) }, { status: 500 }) return NextResponse.json({ error: 'Update failed' }, { status: 500 })
} }
} }

View File

@@ -1,8 +1,12 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db' import pool from '@/lib/db'
import { computeFinanzplan } from '@/lib/finanzplan/engine' import { computeFinanzplan } from '@/lib/finanzplan/engine'
import { validateAdminSecret } from '@/lib/auth'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
if (!validateAdminSecret(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json().catch(() => ({})) const body = await request.json().catch(() => ({}))
const scenarioId = body.scenarioId const scenarioId = body.scenarioId
@@ -33,6 +37,6 @@ export async function POST(request: NextRequest) {
}) })
} catch (error) { } catch (error) {
console.error('Finanzplan compute error:', error) console.error('Finanzplan compute error:', error)
return NextResponse.json({ error: String(error) }, { status: 500 }) return NextResponse.json({ error: 'Compute failed' }, { status: 500 })
} }
} }

View File

@@ -1,10 +1,18 @@
import { NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import pool from '@/lib/db' import pool from '@/lib/db'
import { SHEET_LIST } from '@/lib/finanzplan/types' import { SHEET_LIST } from '@/lib/finanzplan/types'
export async function GET() { export async function GET(request: NextRequest) {
// Only expose scenario list to admin callers (bearer token)
const secret = process.env.PITCH_ADMIN_SECRET
const auth = request.headers.get('authorization') ?? ''
const isAdmin = secret && auth === `Bearer ${secret}`
try { try {
const scenarios = await pool.query('SELECT * FROM fp_scenarios ORDER BY is_default DESC, name') // Investors see only the default scenario — no names of other scenarios leaked
const scenarios = isAdmin
? await pool.query('SELECT * FROM fp_scenarios ORDER BY is_default DESC, name')
: await pool.query('SELECT id FROM fp_scenarios WHERE is_default = true LIMIT 1')
// Get row counts per sheet // Get row counts per sheet
const sheets = await Promise.all( const sheets = await Promise.all(

View File

@@ -8,7 +8,7 @@ import { Pool, types } from 'pg'
types.setTypeParser(types.builtins.NUMERIC, (val) => (val === null ? null : parseFloat(val))) types.setTypeParser(types.builtins.NUMERIC, (val) => (val === null ? null : parseFloat(val)))
const pool = new Pool({ const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_db', connectionString: process.env.DATABASE_URL,
max: 20, max: 20,
idleTimeoutMillis: 30000, idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000, connectionTimeoutMillis: 10000,

View File

@@ -53,8 +53,8 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr
const afaRow = betrieb.find((r: any) => r.row_label === 'Abschreibungen') const afaRow = betrieb.find((r: any) => r.row_label === 'Abschreibungen')
const afa = afaRow?.values || emptyMonthly() const afa = afaRow?.values || emptyMonthly()
// Liquidität endstand // Liquidität endstand — match by row_type to handle both 'LIQUIDITÄT' and 'LIQUIDITAET' labels
const liqEndRow = liquid.find((r: any) => r.row_label === 'LIQUIDITAET') const liqEndRow = liquid.find((r: any) => r.row_type === 'kontostand' && r.row_label?.includes('LIQUIDIT'))
const cashBalance = liqEndRow?.values || emptyMonthly() const cashBalance = liqEndRow?.values || emptyMonthly()
// Headcount // Headcount
@@ -120,7 +120,7 @@ export async function finanzplanToFMResults(pool: Pool, scenarioId?: string): Pr
const breakEvenMonth = results.findIndex(r => r.revenue_eur > r.total_costs_eur) const breakEvenMonth = results.findIndex(r => r.revenue_eur > r.total_costs_eur)
return { return {
scenario_id: sid, scenario_id: sid as string,
results, results,
summary: { summary: {
final_arr: lastMonth?.arr_eur || 0, final_arr: lastMonth?.arr_eur || 0,

View File

@@ -6,7 +6,6 @@ const PUBLIC_PATHS = [
'/auth', // investor login pages '/auth', // investor login pages
'/api/auth', // investor auth API '/api/auth', // investor auth API
'/api/health', '/api/health',
'/api/admin/fp-patch',
'/api/admin-auth', // admin login API '/api/admin-auth', // admin login API
'/pitch-admin/login', // admin login page '/pitch-admin/login', // admin login page
'/_next', '/_next',
@@ -47,10 +46,14 @@ export async function middleware(request: NextRequest) {
// ----- Admin-gated routes ----- // ----- Admin-gated routes -----
if (isAdminGatedPath(pathname)) { if (isAdminGatedPath(pathname)) {
// Allow legacy bearer-secret CLI access on /api/admin/* (the API routes themselves // Allow bearer-secret CLI access on /api/admin/* — validate the token here,
// also check this and log as actor='cli'). The bearer header is opaque to the JWT // not just in the route handler, to avoid any unprotected route slipping through.
// path, so we just let it through here and let the route handler enforce.
if (pathname.startsWith('/api/admin') && request.headers.get('authorization')?.startsWith('Bearer ')) { if (pathname.startsWith('/api/admin') && request.headers.get('authorization')?.startsWith('Bearer ')) {
const bearerToken = request.headers.get('authorization')!.slice(7)
const adminSecret = process.env.PITCH_ADMIN_SECRET
if (!adminSecret || bearerToken !== adminSecret) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return NextResponse.next() return NextResponse.next()
} }

View File

@@ -5,9 +5,6 @@ const nextConfig = {
NEXT_PUBLIC_GIT_SHA: process.env.GIT_SHA || 'dev', NEXT_PUBLIC_GIT_SHA: process.env.GIT_SHA || 'dev',
}, },
reactStrictMode: true, reactStrictMode: true,
typescript: {
ignoreBuildErrors: true,
},
serverExternalPackages: ['nodemailer'], serverExternalPackages: ['nodemailer'],
async headers() { async headers() {
return [ return [

View File

@@ -14,7 +14,7 @@
"incremental": true, "incremental": true,
"plugins": [{ "name": "next" }], "plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }, "paths": { "@/*": ["./*"] },
"target": "ES2017" "target": "ES2018"
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]