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
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:
@@ -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 })
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 [
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user