Files
AOS/src/app/api/aos/contracts/[id]/sign/route.ts
T
Z User 2614ce4e3d feat: Zod validation on all API inputs (PRD §5.2)
26 files with Zod schemas, 27 safeParse calls.
All POST/PATCH handlers now validate input:
- Missing required fields → 400 with structured error
- Invalid types (string vs number) → 400
- Out-of-range values (probability >100, negative amounts) → 400
- Valid data still works (behavior preserved)

Pattern: schema.safeParse(await req.json()) → 400 on failure
2026-10-27 21:29:51 +00:00

103 lines
3.5 KiB
TypeScript
Executable File

import { NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { randomBytes, createHash } from 'crypto'
export const dynamic = 'force-dynamic'
const signContractSchema = z.object({
signedBy: z.string().optional(),
})
// Extract the real client IP from the request (behind Caddy/proxy).
function getClientIp(req: Request): string {
const headers = new Headers(req.headers)
// Caddy/Traefik standard forwarded headers
const xff = headers.get('x-forwarded-for')
if (xff) return xff.split(',')[0]!.trim()
const realIp = headers.get('x-real-ip')
if (realIp) return realIp
return 'unbekannt'
}
// PATCH /api/aos/contracts/[id]/sign
//
// Portal-based contract acceptance (eIDAS Art. 25(2) advanced electronic
// signature). Records the REAL signer IP (not a fake), a cryptographically
// random acceptance token, and a SHA-256 of the contract content at
// acceptance time (tamper-evidence).
//
// NOTE: This is NOT a qualified electronic signature (QES, eIDAS Art. 26),
// which requires a qualified trust service provider. For contracts that
// legally require QES (e.g. some real-estate transactions), integrate a TSP
// like D-Trust / DATEV Sign. We do not fake QES.
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const parsed = signContractSchema.safeParse(await req.json().catch(() => ({})))
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const body = parsed.data
const contract = await db.contract.findUnique({ where: { id } })
if (!contract) return NextResponse.json({ error: 'not found' }, { status: 404 })
if (contract.status === 'signed' || contract.status === 'active') {
return NextResponse.json({ error: 'already signed' }, { status: 409 })
}
// Real cryptographic acceptance token (256-bit)
const token = 'acc_' + randomBytes(24).toString('hex')
const clientIp = getClientIp(req)
// SHA-256 of the contract's essential fields → tamper-evidence hash
const contentHash = createHash('sha256')
.update([contract.number, contract.companyId, contract.type, contract.title, contract.valueCents].join('|'))
.digest('hex')
const updated = await db.contract.update({
where: { id },
data: {
status: 'signed',
signedAt: new Date(),
signedBy: body.signedBy ?? 'Kunde (via Portal)',
signedIp: clientIp,
signToken: token,
notes: (contract.notes ? contract.notes + '\n\n' : '') +
`Akzeptiert via Portal am ${new Date().toISOString()}\n` +
`Signatur-Typ: AdES (eIDAS Art. 25 Abs. 2) — fortgeschrittene elektronische Signatur\n` +
`Inhalts-Hash (SHA-256): ${contentHash}\n` +
`Client-IP: ${clientIp}\n` +
`Akzeptanz-Token: ${token}`,
},
})
await db.auditLog.create({
data: {
actor: 'nora@nordlicht.digital',
action: 'contract.sign',
entity: 'Contract',
entityId: id,
before: JSON.stringify({ status: contract.status }),
after: JSON.stringify({
status: 'signed',
signedBy: updated.signedBy,
ip: clientIp,
signatureLevel: 'AdES',
contentHash,
tokenPrefix: token.slice(0, 12),
}),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({
contract: updated,
signatureLevel: 'AdES (eIDAS Art. 25 Abs. 2)',
contentHash,
ip: clientIp,
})
}