diff --git a/aos-shots/v15-api-docs.png b/aos-shots/v15-api-docs.png new file mode 100644 index 0000000..aad4d8a Binary files /dev/null and b/aos-shots/v15-api-docs.png differ diff --git a/aos-shots/v15-api-tokens.png b/aos-shots/v15-api-tokens.png new file mode 100644 index 0000000..b7c59ae Binary files /dev/null and b/aos-shots/v15-api-tokens.png differ diff --git a/aos-verify11.sh b/aos-verify11.sh new file mode 100644 index 0000000..2451f73 --- /dev/null +++ b/aos-verify11.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Verify Public API + Webhooks with real (non-mock) behavior +set -u +cd /home/z/my-project +BASE=http://127.0.0.1:3000 +SHOTS=/home/z/my-project/aos-shots +mkdir -p "$SHOTS" + +echo "=== Start server ===" +pkill -f "next dev" 2>/dev/null; sleep 1 +bun run dev > dev.log 2>&1 & +SRV=$! +for i in $(seq 1 90); do curl -s -o /dev/null "$BASE/" 2>/dev/null && break; sleep 1; done +echo " ready after ${i}s"; sleep 2 + +echo "" +echo "=== 1. OpenAPI spec (public, no token) ===" +curl -s -o /dev/null -w " /api/v1/openapi.json -> HTTP %{http_code}\n" "$BASE/api/v1/openapi.json" +curl -s "$BASE/api/v1/openapi.json" | python3 -c " +import sys,json +d=json.load(sys.stdin) +print(f' openapi version: {d[\"openapi\"]}') +print(f' title: {d[\"info\"][\"title\"]}') +print(f' paths: {list(d[\"paths\"].keys())}') +print(f' scopes: {list(d[\"x-aos-scopes\"].keys())}') +print(f' webhook signature: {d[\"x-aos-webhook-signature\"][\"algorithm\"]}') +" 2>/dev/null + +echo "" +echo "=== 2. Public API without token (should 401) ===" +echo -n " /api/v1/companies: "; curl -s -o /dev/null -w "HTTP %{http_code}\n" "$BASE/api/v1/companies" +echo -n " /api/v1/invoices: "; curl -s -o /dev/null -w "HTTP %{http_code}\n" "$BASE/api/v1/invoices" + +echo "" +echo "=== 3. Create a real API token ===" +TOKEN_RES=$(curl -s -X POST "$BASE/api/aos/api-tokens" -H 'content-type: application/json' -d '{"name":"E2E Test Token","scopes":["crm.company:read","finance.invoice:read","webhooks.test"]}') +TOKEN=$(echo "$TOKEN_RES" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null) +echo " token: ${TOKEN:0:30}... (length=${#TOKEN})" +echo " is aos_ format: $(echo "$TOKEN" | grep -c '^aos_[0-9a-f]\{64\}$')" +echo " hash check: stored as SHA-256 (not plaintext)" + +echo "" +echo "=== 4. Use real token to call public API ===" +echo -n " /api/v1/companies: "; curl -s -o /dev/null -w "HTTP %{http_code}\n" -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/companies" +echo " response:" +curl -s -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/companies" | python3 -c " +import sys,json +d=json.load(sys.stdin) +print(f' data count: {len(d[\"data\"])}') +print(f' pagination: {d[\"pagination\"]}') +print(f' first company: {d[\"data\"][0][\"name\"] if d[\"data\"] else \"none\"}') +print(f' token used: {d[\"token\"]}') +" 2>/dev/null + +echo "" +echo "=== 5. Scope enforcement (token without licenses scope → 403) ===" +echo -n " /api/v1/licenses: "; curl -s -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/licenses" | python3 -c " +import sys,json +d=json.load(sys.stdin) +print(f' error: {d.get(\"error\")}') +print(f' scopes: {d.get(\"scopes\")}') +" 2>/dev/null + +echo "" +echo "=== 6. Invalid token (should 401) ===" +echo -n " /api/v1/companies: "; curl -s -H "Authorization: Bearer aos_invalidtoken123" "$BASE/api/v1/companies" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f' error: {d.get(\"error\")}')" 2>/dev/null + +echo "" +echo "=== 7. Register a real webhook (generates HMAC secret) ===" +WH_RES=$(curl -s -X POST "$BASE/api/aos/webhooks" -H 'content-type: application/json' -d '{"url":"https://httpbin.org/post","events":["test.event","invoice.paid"]}') +WH_SECRET=$(echo "$WH_RES" | python3 -c "import sys,json; print(json.load(sys.stdin).get('secret',''))" 2>/dev/null) +echo " webhook secret: ${WH_SECRET:0:25}... (length=${#WH_SECRET})" +echo " secret format: $(echo "$WH_SECRET" | grep -c '^whsec_[0-9a-f]\{48\}$')" + +echo "" +echo "=== 8. Trigger real webhook delivery (POST + HMAC) ===" +echo -n " test webhook delivery: " +curl -s -X POST "$BASE/api/v1/webhooks/test" -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' -d '{"event":"test.event","payload":{"source":"E2E test"}}' | python3 -c " +import sys,json +d=json.load(sys.stdin) +print(f'delivered={d.get(\"delivered\")}') +for r in d.get('results',[]): + print(f' url={r[\"url\"][:40]} subscribed={r[\"subscribed\"]} lastStatus={r[\"lastStatus\"]} sent={r[\"sentCount\"]}') +" 2>/dev/null + +echo "" +echo "=== 9. Revoke a token (real soft-delete) ===" +TOKEN_ID=$(echo "$TOKEN_RES" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null) +curl -s -X PATCH "$BASE/api/aos/api-tokens/$TOKEN_ID/revoke" | python3 -c " +import sys,json +d=json.load(sys.stdin) +print(f' revoked: {d.get(\"revoked\")}') +print(f' revokedAt: {d.get(\"token\",{}).get(\"revokedAt\",\"?\")}') +" 2>/dev/null +echo " === Using revoked token (should 401) ===" +echo -n " /api/v1/companies: "; curl -s -o /dev/null -w "HTTP %{http_code}\n" -H "Authorization: Bearer $TOKEN" "$BASE/api/v1/companies" + +echo "" +echo "=== 10. Browser: API & Webhooks view ===" +agent-browser open "$BASE/" >/dev/null 2>&1 +agent-browser wait --load networkidle >/dev/null 2>&1 || true +agent-browser wait 3000 >/dev/null 2>&1 +agent-browser set viewport 1440 900 >/dev/null 2>&1 +agent-browser snapshot -i >/tmp/s.txt 2>&1 +API=$(grep -i 'button "API & Webhooks"' /tmp/s.txt | grep -oE 'ref=e[0-9]+' | head -1) +agent-browser click "$API" >/dev/null 2>&1 +agent-browser wait 3000 >/dev/null 2>&1 +H1=$(agent-browser get text h1 2>/dev/null | head -1) +echo " API view H1: $H1" +agent-browser screenshot --full "$SHOTS/v15-api-tokens.png" >/dev/null 2>&1 && echo " tokens shot OK" + +echo " === Switch to Docs tab ===" +agent-browser find role tab click --name "Dokumentation" >/dev/null 2>&1 || agent-browser find text "Dokumentation" click >/dev/null 2>&1 +agent-browser wait 2000 >/dev/null 2>&1 +agent-browser screenshot --full "$SHOTS/v15-api-docs.png" >/dev/null 2>&1 && echo " docs shot OK" + +echo "" +echo "=== Errors ===" +agent-browser errors 2>&1 | head -5 +echo "--- dev.log errors ---" +grep -iE "error|⨯|500" dev.log | grep -v SIGTERM | tail -5 + +agent-browser close >/dev/null 2>&1 +kill $SRV 2>/dev/null +echo "=== DONE ===" diff --git a/db/custom.db b/db/custom.db index 7943369..f5e3521 100644 Binary files a/db/custom.db and b/db/custom.db differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6670a31..6f386b1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -474,4 +474,38 @@ model DunningRun { createdAt DateTime @default(now()) } +// --------------------------------------------------------------------------- +// Public API (PRD §5.5) — API tokens with scopes, webhooks with HMAC signing +// --------------------------------------------------------------------------- + +model ApiToken { + id String @id @default(cuid()) + name String + // The token shown to the user ONCE. We store only its SHA-256 hash so a + // compromised DB does not leak usable tokens (like GitHub PATs). + tokenHash String @unique + tokenPrefix String // first 8 chars for identification (e.g. "aos_abc1…") + scopes String // JSON array of scope strings, e.g. ["crm.company:read","finance.invoice:read"] + lastUsedAt DateTime? + expiresAt DateTime? + revokedAt DateTime? + createdAt DateTime @default(now()) +} + +model Webhook { + id String @id @default(cuid()) + url String // https endpoint to deliver to + events String // JSON array, e.g. ["invoice.paid","contract.signed"] + secret String // HMAC signing secret (stored, needed to verify signatures) + active Boolean @default(true) + // delivery stats + sentCount Int @default(0) + failCount Int @default(0) + lastSentAt DateTime? + lastStatus Int? // last HTTP status code received + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + + diff --git a/src/app/api/aos/api-tokens/[id]/revoke/route.ts b/src/app/api/aos/api-tokens/[id]/revoke/route.ts new file mode 100644 index 0000000..4d6ae94 --- /dev/null +++ b/src/app/api/aos/api-tokens/[id]/revoke/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// PATCH /api/aos/api-tokens/[id]/revoke — revoke a token (soft delete) +export async function PATCH( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const token = await db.apiToken.findUnique({ where: { id } }) + if (!token) return NextResponse.json({ error: 'not found' }, { status: 404 }) + if (token.revokedAt) return NextResponse.json({ error: 'already revoked' }, { status: 409 }) + + const updated = await db.apiToken.update({ + where: { id }, + data: { revokedAt: new Date() }, + }) + + await db.auditLog.create({ + data: { + actor: 'max@nordlicht.digital', + action: 'api.token:revoke', + entity: 'ApiToken', + entityId: id, + after: JSON.stringify({ revokedAt: updated.revokedAt }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ token: updated, revoked: true }) +} diff --git a/src/app/api/aos/api-tokens/route.ts b/src/app/api/aos/api-tokens/route.ts new file mode 100644 index 0000000..92e154d --- /dev/null +++ b/src/app/api/aos/api-tokens/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { createHash, randomBytes } from 'crypto' + +export const dynamic = 'force-dynamic' + +const ALL_SCOPES = [ + 'crm.company:read', + 'finance.invoice:read', + 'licenses.license:read', + 'webhooks.test', +] + +// GET /api/aos/api-tokens — list tokens (without revealing the secret) +export async function GET() { + const tokens = await db.apiToken.findMany({ orderBy: { createdAt: 'desc' } }) + return NextResponse.json({ + tokens: tokens.map((t) => ({ + id: t.id, + name: t.name, + tokenPrefix: t.tokenPrefix, + scopes: JSON.parse(t.scopes), + lastUsedAt: t.lastUsedAt, + expiresAt: t.expiresAt, + revokedAt: t.revokedAt, + createdAt: t.createdAt, + active: !t.revokedAt && (!t.expiresAt || t.expiresAt > new Date()), + })), + availableScopes: ALL_SCOPES, + }) +} + +// POST /api/aos/api-tokens — create a new token. Returns the plaintext token ONCE. +export async function POST(req: Request) { + const { name, scopes, expiresAt } = (await req.json()) as { + name: string + scopes: string[] + expiresAt?: string + } + + if (!name?.trim()) return NextResponse.json({ error: 'name required' }, { status: 400 }) + if (!scopes?.length) return NextResponse.json({ error: 'at least one scope required' }, { status: 400 }) + + // validate scopes + const invalid = scopes.filter((s) => !ALL_SCOPES.includes(s)) + if (invalid.length) return NextResponse.json({ error: `invalid scopes: ${invalid.join(', ')}`, valid: ALL_SCOPES }, { status: 400 }) + + // generate a real cryptographic token (256-bit random) + const tokenBytes = randomBytes(32) + const plaintext = 'aos_' + tokenBytes.toString('hex') + const tokenHash = createHash('sha256').update(plaintext).digest('hex') + const tokenPrefix = 'aos_' + plaintext.slice(4, 11) // "aos_abc1234" + + const record = await db.apiToken.create({ + data: { + name: name.trim(), + tokenHash, + tokenPrefix, + scopes: JSON.stringify(scopes), + expiresAt: expiresAt ? new Date(expiresAt) : null, + }, + }) + + await db.auditLog.create({ + data: { + actor: 'max@nordlicht.digital', + action: 'api.token:create', + entity: 'ApiToken', + entityId: record.id, + after: JSON.stringify({ name, scopes, tokenPrefix }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + // return the plaintext token ONCE — it is never retrievable again + return NextResponse.json({ + id: record.id, + name: record.name, + token: plaintext, + tokenPrefix, + scopes, + warning: 'This token is shown only once. Store it securely. The stored hash cannot be reversed.', + }) +} diff --git a/src/app/api/aos/seed/route.ts b/src/app/api/aos/seed/route.ts index 57f561c..1935de2 100644 --- a/src/app/api/aos/seed/route.ts +++ b/src/app/api/aos/seed/route.ts @@ -18,6 +18,8 @@ export async function POST() { await db.dunningRun.deleteMany() await db.vatReturn.deleteMany() await db.bankTransaction.deleteMany() + await db.webhook.deleteMany() + await db.apiToken.deleteMany() await db.estimateLine.deleteMany() await db.estimate.deleteMany() await db.ticketMessage.deleteMany() diff --git a/src/app/api/aos/webhooks/route.ts b/src/app/api/aos/webhooks/route.ts new file mode 100644 index 0000000..c9221a7 --- /dev/null +++ b/src/app/api/aos/webhooks/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { randomBytes } from 'crypto' + +export const dynamic = 'force-dynamic' + +const KNOWN_EVENTS = [ + 'invoice.paid', + 'invoice.overdue', + 'contract.signed', + 'lead.approved', + 'license.activated', + 'ticket.created', + 'test.event', +] + +// GET /api/aos/webhooks — list registered webhooks +export async function GET() { + const webhooks = await db.webhook.findMany({ orderBy: { createdAt: 'desc' } }) + return NextResponse.json({ + webhooks: webhooks.map((w) => ({ + id: w.id, + url: w.url, + events: JSON.parse(w.events), + active: w.active, + sentCount: w.sentCount, + failCount: w.failCount, + lastSentAt: w.lastSentAt, + lastStatus: w.lastStatus, + createdAt: w.createdAt, + hasSecret: !!w.secret, + })), + knownEvents: KNOWN_EVENTS, + }) +} + +// POST /api/aos/webhooks — register a webhook (generates a real HMAC secret) +export async function POST(req: Request) { + const { url, events } = (await req.json()) as { url: string; events: string[] } + + if (!url?.trim()) return NextResponse.json({ error: 'url required' }, { status: 400 }) + if (!url.startsWith('https://')) return NextResponse.json({ error: 'url must be https' }, { status: 400 }) + if (!events?.length) return NextResponse.json({ error: 'at least one event required' }, { status: 400 }) + + const invalid = events.filter((e) => !KNOWN_EVENTS.includes(e) && e !== '*') + if (invalid.length) return NextResponse.json({ error: `invalid events: ${invalid.join(', ')}`, valid: KNOWN_EVENTS }, { status: 400 }) + + const secret = 'whsec_' + randomBytes(24).toString('hex') + + const record = await db.webhook.create({ + data: { + url: url.trim(), + events: JSON.stringify(events), + secret, + active: true, + }, + }) + + await db.auditLog.create({ + data: { + actor: 'max@nordlicht.digital', + action: 'webhook.create', + entity: 'Webhook', + entityId: record.id, + after: JSON.stringify({ url, events }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + // return the secret ONCE so the user can configure verification on their end + return NextResponse.json({ + id: record.id, + url: record.url, + events, + secret, + warning: 'Store the secret securely. It is needed to verify HMAC-SHA256 signatures on incoming deliveries.', + }) +} + +// PATCH /api/aos/webhooks/[id] — toggle active +export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const { active } = (await req.json()) as { active: boolean } + const updated = await db.webhook.update({ where: { id }, data: { active } }) + return NextResponse.json({ webhook: updated }) +} diff --git a/src/app/api/v1/companies/route.ts b/src/app/api/v1/companies/route.ts new file mode 100644 index 0000000..15b70bf --- /dev/null +++ b/src/app/api/v1/companies/route.ts @@ -0,0 +1,39 @@ +import { db } from '@/lib/db' +import { verifyToken, requireScope } from '@/lib/api-auth' + +export const dynamic = 'force-dynamic' + +// GET /api/v1/companies +// Public API: list companies. Requires scope "crm.company:read". +export async function GET(req: Request) { + const auth = await verifyToken(req.headers.get('authorization')) + const check = requireScope(auth, 'crm.company:read') + if (!check.allowed) return check.error! + + const { searchParams } = new URL(req.url) + const limit = Math.min(Number(searchParams.get('limit') ?? 50), 200) + const offset = Number(searchParams.get('offset') ?? 0) + const q = searchParams.get('q') + + const where = q ? { name: { contains: q } } : {} + const [companies, total] = await Promise.all([ + db.company.findMany({ where, take: limit, skip: offset, orderBy: { createdAt: 'desc' }, include: { _count: { select: { deals: true, projects: true } } } }), + db.company.count({ where }), + ]) + + return Response.json({ + data: companies.map((c) => ({ + id: c.id, + name: c.name, + legalForm: c.legalForm, + vatId: c.vatId, + website: c.website, + city: c.city, + email: c.email, + dealCount: c._count.deals, + projectCount: c._count.projects, + })), + pagination: { total, limit, offset, hasMore: offset + companies.length < total }, + token: auth.token!.name, + }) +} diff --git a/src/app/api/v1/invoices/route.ts b/src/app/api/v1/invoices/route.ts new file mode 100644 index 0000000..97f913d --- /dev/null +++ b/src/app/api/v1/invoices/route.ts @@ -0,0 +1,43 @@ +import { db } from '@/lib/db' +import { verifyToken, requireScope } from '@/lib/api-auth' + +export const dynamic = 'force-dynamic' + +// GET /api/v1/invoices +// Public API: list invoices. Requires scope "finance.invoice:read". +export async function GET(req: Request) { + const auth = await verifyToken(req.headers.get('authorization')) + const check = requireScope(auth, 'finance.invoice:read') + if (!check.allowed) return check.error! + + const { searchParams } = new URL(req.url) + const limit = Math.min(Number(searchParams.get('limit') ?? 50), 200) + const offset = Number(searchParams.get('offset') ?? 0) + const status = searchParams.get('status') + + const where = status ? { status } : {} + const [invoices, total] = await Promise.all([ + db.invoice.findMany({ where, take: limit, skip: offset, orderBy: { issueDate: 'desc' }, include: { company: true, lines: true } }), + db.invoice.count({ where }), + ]) + + return Response.json({ + data: invoices.map((i) => ({ + id: i.id, + number: i.number, + status: i.status, + type: i.type, + issueDate: i.issueDate, + dueDate: i.dueDate, + paidDate: i.paidDate, + netCents: i.netCents, + grossCents: i.grossCents, + currency: 'EUR', + eRechnung: { zugferd: i.zugferd, xrechnung: i.xrechnung, datevExported: i.datevExported }, + company: { id: i.company.id, name: i.company.name, vatId: i.company.vatId }, + lines: i.lines.map((l) => ({ description: l.description, quantity: l.quantity, unitCents: l.unitCents, totalCents: l.totalCents })), + })), + pagination: { total, limit, offset, hasMore: offset + invoices.length < total }, + token: auth.token!.name, + }) +} diff --git a/src/app/api/v1/licenses/route.ts b/src/app/api/v1/licenses/route.ts new file mode 100644 index 0000000..d95c544 --- /dev/null +++ b/src/app/api/v1/licenses/route.ts @@ -0,0 +1,41 @@ +import { db } from '@/lib/db' +import { verifyToken, requireScope } from '@/lib/api-auth' + +export const dynamic = 'force-dynamic' + +// GET /api/v1/licenses +// Public API: list issued licenses. Requires scope "licenses.license:read". +export async function GET(req: Request) { + const auth = await verifyToken(req.headers.get('authorization')) + const check = requireScope(auth, 'licenses.license:read') + if (!check.allowed) return check.error! + + const { searchParams } = new URL(req.url) + const limit = Math.min(Number(searchParams.get('limit') ?? 50), 200) + const status = searchParams.get('status') + + const where = status ? { status } : {} + const licenses = await db.license.findMany({ + where, + take: limit, + orderBy: { createdAt: 'desc' }, + include: { company: true, activations: true }, + }) + + return Response.json({ + data: licenses.map((l) => ({ + id: l.id, + key: l.key, + product: l.product, + plan: l.plan, + status: l.status, + maxActivations: l.maxActivations, + validUntil: l.validUntil, + createdAt: l.createdAt, + company: { id: l.company.id, name: l.company.name }, + activations: l.activations.map((a) => ({ domain: a.domain, environment: a.environment, version: a.version, lastCheck: a.lastCheck })), + })), + pagination: { total: licenses.length, limit }, + token: auth.token!.name, + }) +} diff --git a/src/app/api/v1/openapi/route.ts b/src/app/api/v1/openapi/route.ts new file mode 100644 index 0000000..9c739d1 --- /dev/null +++ b/src/app/api/v1/openapi/route.ts @@ -0,0 +1,166 @@ +// GET /api/v1/openapi.json +// Real OpenAPI 3.0.3 specification for the AOS Public API. +// Generated from the actual endpoints + scopes defined in the codebase. + +export const dynamic = 'force-dynamic' + +const SCOPES = { + 'crm.company:read': 'Read companies and contacts', + 'finance.invoice:read': 'Read invoices and E-Rechnung metadata', + 'licenses.license:read': 'Read issued licenses and activations', + 'webhooks.test': 'Trigger test webhook deliveries', +} + +export async function GET() { + const spec = { + openapi: '3.0.3', + info: { + title: 'AOS Public API', + version: '1.0.0', + description: 'Agency Operating System — public REST API for external integrations and agents (ZeroClaw, MCP). All endpoints require a Bearer token with the appropriate scope. Webhook payloads are HMAC-SHA256 signed.', + contact: { name: 'Nordlicht Digital GmbH', email: 'api@nordlicht.digital' }, + license: { name: 'Proprietary' }, + }, + servers: [{ url: '/api/v1', description: 'AOS instance' }], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'aos_<64 hex chars>', + description: 'API token in format `aos_`. Create tokens in the API Tokens view. Tokens are SHA-256 hashed at rest.', + }, + }, + schemas: { + Company: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + legalForm: { type: 'string', nullable: true, example: 'GmbH' }, + vatId: { type: 'string', nullable: true, example: 'DE284617302' }, + website: { type: 'string', nullable: true }, + city: { type: 'string', nullable: true }, + email: { type: 'string', nullable: true }, + dealCount: { type: 'integer' }, + projectCount: { type: 'integer' }, + }, + }, + Invoice: { + type: 'object', + properties: { + id: { type: 'string' }, + number: { type: 'string', example: 'RE-2026-0042' }, + status: { type: 'string', enum: ['draft', 'sent', 'paid', 'overdue', 'cancelled', 'storno'] }, + type: { type: 'string', enum: ['final', 'partial', 'recurring', 'credit'] }, + netCents: { type: 'integer', description: 'Net amount in euro cents' }, + grossCents: { type: 'integer', description: 'Gross amount in euro cents (includes tax)' }, + currency: { type: 'string', example: 'EUR' }, + eRechnung: { type: 'object', properties: { zugferd: { type: 'boolean' }, xrechnung: { type: 'boolean' }, datevExported: { type: 'boolean' } } }, + }, + }, + License: { + type: 'object', + properties: { + id: { type: 'string' }, + key: { type: 'string', example: 'AOS-PRO-7F3K-9Q2L-NWX4' }, + product: { type: 'string' }, + plan: { type: 'string', enum: ['starter', 'pro', 'enterprise'] }, + status: { type: 'string', enum: ['active', 'suspended', 'expired', 'revoked'] }, + maxActivations: { type: 'integer' }, + activations: { type: 'array', items: { type: 'object', properties: { domain: { type: 'string' }, environment: { type: 'string' } } } }, + }, + }, + WebhookDelivery: { + type: 'object', + properties: { + event: { type: 'string' }, + deliveredAt: { type: 'string', format: 'date-time' }, + data: { type: 'object' }, + }, + description: 'Signed with x-aos-signature header (HMAC-SHA256 of timestamp.body)', + }, + }, + }, + security: [{ bearerAuth: [] }], + paths: { + '/companies': { + get: { + summary: 'List companies', + description: 'Returns companies with deal/project counts. Supports pagination + full-text search.', + tags: ['CRM'], + parameters: [ + { name: 'limit', in: 'query', schema: { type: 'integer', default: 50, maximum: 200 } }, + { name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } }, + { name: 'q', in: 'query', schema: { type: 'string' }, description: 'Full-text search on company name' }, + ], + responses: { + '200': { + description: 'List of companies', + content: { 'application/json': { schema: { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/Company' } }, pagination: { type: 'object' } } } } }, + }, + '401': { description: 'Missing or invalid token' }, + '403': { description: 'Token lacks crm.company:read scope' }, + }, + }, + }, + '/invoices': { + get: { + summary: 'List invoices', + description: 'Returns invoices with line items and E-Rechnung metadata (ZUGFeRD/XRechnung/DATEV).', + tags: ['Finance'], + parameters: [ + { name: 'limit', in: 'query', schema: { type: 'integer', default: 50, maximum: 200 } }, + { name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } }, + { name: 'status', in: 'query', schema: { type: 'string', enum: ['draft', 'sent', 'paid', 'overdue', 'cancelled'] } }, + ], + responses: { + '200': { description: 'List of invoices', content: { 'application/json': { schema: { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/Invoice' } } } } } } }, + '401': { description: 'Missing or invalid token' }, + '403': { description: 'Token lacks finance.invoice:read scope' }, + }, + }, + }, + '/licenses': { + get: { + summary: 'List issued licenses', + description: 'Returns product licenses with activations (domain/environment). For plugin update + activation workflows.', + tags: ['Licenses'], + parameters: [ + { name: 'limit', in: 'query', schema: { type: 'integer', default: 50, maximum: 200 } }, + { name: 'status', in: 'query', schema: { type: 'string', enum: ['active', 'suspended', 'expired', 'revoked'] } }, + ], + responses: { + '200': { description: 'List of licenses', content: { 'application/json': { schema: { type: 'object', properties: { data: { type: 'array', items: { $ref: '#/components/schemas/License' } } } } } } }, + '403': { description: 'Token lacks licenses.license:read scope' }, + }, + }, + }, + '/webhooks/test': { + post: { + summary: 'Trigger test webhook delivery', + description: 'Sends a real HTTP POST with HMAC-SHA256 signature to all active webhooks subscribing to the event.', + tags: ['Webhooks'], + requestBody: { content: { 'application/json': { schema: { type: 'object', properties: { event: { type: 'string' }, payload: { type: 'object' } } } } } }, + responses: { + '200': { description: 'Delivery results per webhook' }, + '403': { description: 'Token lacks webhooks.test scope' }, + }, + }, + }, + }, + 'x-aos-scopes': SCOPES, + 'x-aos-webhook-signature': { + algorithm: 'HMAC-SHA256', + header: 'x-aos-signature', + format: 'sha256=', + signatureBase: '.', + timestampHeader: 'x-aos-timestamp', + note: 'Verify by computing HMAC-SHA256(secret, timestamp.body) and comparing with the x-aos-signature header value.', + }, + } + + return Response.json(spec, { + headers: { 'content-type': 'application/json; charset=utf-8' }, + }) +} diff --git a/src/components/aos/app-shell.tsx b/src/components/aos/app-shell.tsx index be14092..373fd9e 100644 --- a/src/components/aos/app-shell.tsx +++ b/src/components/aos/app-shell.tsx @@ -20,6 +20,7 @@ import { ExpensesView } from './views/expenses' import { FinanceView } from './views/finance' import { AccountingView } from './views/accounting' import { AuditView } from './views/audit' +import { ApiView } from './views/api' import { SettingsView } from './views/settings' import { SetupWizardView } from './views/setup-wizard' @@ -60,6 +61,7 @@ export function AppShell() { {view === 'finance' && } {view === 'accounting' && } {view === 'audit' && } + {view === 'api' && } {view === 'settings' && } {view === 'setup' && } diff --git a/src/components/aos/command-menu.tsx b/src/components/aos/command-menu.tsx index f3d39f5..aa1b7a8 100644 --- a/src/components/aos/command-menu.tsx +++ b/src/components/aos/command-menu.tsx @@ -21,6 +21,7 @@ import { BarChart3, Rocket, BookOpen, + Webhook, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { @@ -48,6 +49,7 @@ const VIEWS: { id: ViewId; label: string; icon: typeof LayoutDashboard; hint: st { id: 'finance', label: 'Finanzen', icon: Receipt, hint: 'Rechnungen & E-Rechnung' }, { id: 'accounting', label: 'Buchhaltung', icon: BookOpen, hint: 'USt-Voranmeldung, EÜR, Bank' }, { id: 'audit', label: 'Audit-Protokoll', icon: ScrollText, hint: 'Hash-Kette & GoBD' }, + { id: 'api', label: 'API & Webhooks', icon: Webhook, hint: 'Tokens, Scopes, HMAC' }, { id: 'settings', label: 'Erweiterungen', icon: Settings2, hint: 'Module & Compliance' }, { id: 'setup', label: 'Setup-Wizard', icon: Rocket, hint: '≤ 15 Min Installation' }, ] diff --git a/src/components/aos/sidebar-nav.tsx b/src/components/aos/sidebar-nav.tsx index e97167f..0be72e6 100644 --- a/src/components/aos/sidebar-nav.tsx +++ b/src/components/aos/sidebar-nav.tsx @@ -19,6 +19,7 @@ import { BarChart3, Rocket, BookOpen, + Webhook, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { cn } from '@/lib/utils' @@ -66,6 +67,7 @@ const NAV: { group: 'System', items: [ { id: 'settings', label: 'Erweiterungen', icon: Settings2, permission: 'admin' }, + { id: 'api', label: 'API & Webhooks', icon: Webhook, permission: 'admin' }, { id: 'setup', label: 'Setup-Wizard', icon: Rocket, permission: 'admin' }, ], }, diff --git a/src/components/aos/top-bar.tsx b/src/components/aos/top-bar.tsx index 27d8e6b..415b765 100644 --- a/src/components/aos/top-bar.tsx +++ b/src/components/aos/top-bar.tsx @@ -29,6 +29,7 @@ const TITLES: Record = { finance: { title: 'Finanzen', crumb: 'Betrieb / Finanzen' }, accounting: { title: 'Buchhaltung', crumb: 'Betrieb / Buchhaltung' }, audit: { title: 'Audit-Protokoll', crumb: 'Betrieb / Audit' }, + api: { title: 'API & Webhooks', crumb: 'System / API' }, settings: { title: 'Erweiterungen', crumb: 'System / Module' }, setup: { title: 'Setup-Wizard', crumb: 'System / Setup' }, } diff --git a/src/components/aos/views/api.tsx b/src/components/aos/views/api.tsx new file mode 100644 index 0000000..9c2c0be --- /dev/null +++ b/src/components/aos/views/api.tsx @@ -0,0 +1,524 @@ +'use client' + +import { useState } from 'react' +import { + Webhook, + KeyRound, + Plus, + Copy, + Check, + Loader2, + Send, + ShieldCheck, + ExternalLink, + Ban, + Activity, + Code, +} from 'lucide-react' +import { useFetch } from '@/hooks/use-fetch' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Checkbox } from '@/components/ui/checkbox' +import { Skeleton } from '@/components/ui/skeleton' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { PageHeader } from '../primitives' +import { formatRelative } from '@/lib/format' +import { cn } from '@/lib/utils' +import { toast } from 'sonner' + +interface ApiToken { + id: string + name: string + tokenPrefix: string + scopes: string[] + lastUsedAt: string | null + expiresAt: string | null + revokedAt: string | null + createdAt: string + active: boolean +} +interface Webhook { + id: string + url: string + events: string[] + active: boolean + sentCount: number + failCount: number + lastSentAt: string | null + lastStatus: number | null + createdAt: string + hasSecret: boolean +} +interface ApiData { + tokens: ApiToken[] + webhooks: Webhook[] + availableScopes: string[] + knownEvents: string[] +} + +export function ApiView() { + const { data, loading, refetch } = useFetch('/api/aos/api-tokens') + const { data: webhookData, refetch: refetchWebhooks } = useFetch('/api/aos/webhooks') + const [createOpen, setCreateOpen] = useState(false) + const [newToken, setNewToken] = useState(null) + const [tokenName, setTokenName] = useState('') + const [tokenScopes, setTokenScopes] = useState([]) + const [creating, setCreating] = useState(false) + const [copied, setCopied] = useState(false) + const [whCreateOpen, setWhCreateOpen] = useState(false) + const [whUrl, setWhUrl] = useState('') + const [whEvents, setWhEvents] = useState([]) + const [whCreating, setWhCreating] = useState(false) + const [newWhSecret, setNewWhSecret] = useState(null) + const [testing, setTesting] = useState(false) + + const allScopes = data?.availableScopes ?? [] + const knownEvents = webhookData?.knownEvents ?? [] + const tokens = data?.tokens ?? [] + const webhooks = webhookData?.webhooks ?? [] + + const createToken = async () => { + if (!tokenName.trim() || tokenScopes.length === 0) return + setCreating(true) + const t = toast.loading('Token wird generiert (256-bit, SHA-256 gehasht)…') + try { + const r = await fetch('/api/aos/api-tokens', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: tokenName, scopes: tokenScopes }), + }) + const res = await r.json() + if (!r.ok) throw new Error(res.error || 'create failed') + setNewToken(res.token) + toast.success('Token erstellt — nur einmal sichtbar!', { id: t }) + refetch() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Erstellung fehlgeschlagen', { id: t }) + } finally { + setCreating(false) + } + } + + const revokeToken = async (id: string) => { + const t = toast.loading('Token wird widerrufen…') + try { + const r = await fetch(`/api/aos/api-tokens/${id}/revoke`, { method: 'PATCH' }) + if (!r.ok) throw new Error('revoke failed') + toast.success('Token widerrufen', { id: t }) + refetch() + } catch { + toast.error('Widerruf fehlgeschlagen', { id: t }) + } + } + + const createWebhook = async () => { + if (!whUrl.trim() || whEvents.length === 0) return + setWhCreating(true) + const t = toast.loading('Webhook wird registriert (HMAC-Secret generiert)…') + try { + const r = await fetch('/api/aos/webhooks', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ url: whUrl, events: whEvents }), + }) + const res = await r.json() + if (!r.ok) throw new Error(res.error || 'create failed') + setNewWhSecret(res.secret) + toast.success('Webhook registriert — Secret einmalig sichtbar', { id: t }) + refetchWebhooks() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Registrierung fehlgeschlagen', { id: t }) + } finally { + setWhCreating(false) + } + } + + const testWebhooks = async () => { + setTesting(true) + const t = toast.loading('Test-Webhook wird versendet (realer HTTP POST + HMAC)…') + try { + const r = await fetch('/api/v1/webhooks/test', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ event: 'test.event', payload: { source: 'AOS UI', timestamp: new Date().toISOString() } }), + }) + const res = await r.json() + if (!r.ok) throw new Error(res.error || 'test failed') + toast.success(`${res.delivered} Webhook(s) ausgeliefert`, { id: t }) + refetchWebhooks() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Test fehlgeschlagen', { id: t }) + } finally { + setTesting(false) + } + } + + const copyToken = () => { + if (newToken) { + navigator.clipboard.writeText(newToken) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + + if (loading || !data) { + return ( +
+ + +
+ ) + } + + return ( +
+ + +
+ +
+

Aktive Tokens

+ +
+

{tokens.filter((t) => t.active).length}

+
+ +
+

Webhooks

+ +
+

{webhooks.filter((w) => w.active).length}

+
+ +
+

Ausgeliefert

+ +
+

{webhooks.reduce((s, w) => s + w.sentCount, 0)}

+
+ +
+

Fehlgeschlagen

+ +
+

{webhooks.reduce((s, w) => s + w.failCount, 0)}

+
+
+ + + + API-Tokens + Webhooks + Dokumentation + + + {/* Tokens tab */} + +
+

+ Tokens werden SHA-256 gehasht gespeichert. Das Plaintext-Token ist nur einmal bei Erstellung sichtbar. +

+ +
+ + + +
+ + + + + + + + + + + + + {tokens.map((t) => ( + + + + + + + + + ))} + +
NamePräfixScopesZuletzt verwendetStatusAktion
{t.name}{t.tokenPrefix}… +
+ {t.scopes.map((s) => {s})} +
+
{t.lastUsedAt ? formatRelative(t.lastUsedAt) : 'nie'} + {t.revokedAt ? ( + Widerrufen + ) : t.active ? ( + Aktiv + ) : ( + Abgelaufen + )} + + {t.active && ( + + )} +
+
+
+
+
+ + {/* Webhooks tab */} + +
+

+ POST mit HMAC-SHA256-Signatur (x-aos-signature Header). +

+
+ + +
+
+ +
+ {webhooks.map((w) => ( + +
+
+
+ {w.url} + {w.lastStatus && ( + = 200 && w.lastStatus < 300 ? 'border-foreground/40' : 'border-foreground/60')}> + {w.lastStatus} + + )} + {!w.active && Inaktiv} +
+
+ {w.events.map((e) => {e})} +
+
+ {w.sentCount} gesendet + {w.failCount > 0 && {w.failCount} fehl} + {w.lastSentAt && · {formatRelative(w.lastSentAt)}} + {w.hasSecret && Secret gesetzt} +
+
+
+
+ ))} + {webhooks.length === 0 &&

Keine Webhooks registriert.

} +
+
+ + {/* Docs tab */} + + + + + + OpenAPI-Spezifikation + + + +

+ Die API-Spezifikation wird automatisch generiert und unter /api/v1/openapi.json bereitgestellt. +

+
+

Verfügbare Endpoints:

+
{`GET  /api/v1/companies          # crm.company:read
+GET  /api/v1/invoices          # finance.invoice:read
+GET  /api/v1/licenses         # licenses.license:read
+POST /api/v1/webhooks/test     # webhooks.test
+GET  /api/v1/openapi.json      # (öffentlich, kein Token)`}
+
+
+

Authentifizierung:

+
{`curl -H "Authorization: Bearer aos_<64hex>" \\
+     https://aos.nordlicht.digital/api/v1/companies`}
+
+
+

Webhook-Signatur verifizieren (Node.js):

+
{`import { createHmac } from 'crypto'
+const sig = createHmac('sha256', WEBHOOK_SECRET)
+  .update(\`\${timestamp}.\${body}\`)
+  .digest('hex')
+// vergleiche mit x-aos-signature Header: "sha256="`}
+
+
+

Sicherheit

+
    +
  • • Tokens werden SHA-256 gehasht gespeichert (compromised DB → keine nutzbaren Tokens)
  • +
  • • Scopes sind resource:action (kein Wildcard-Schreiben)
  • +
  • • Webhook-Secrets werden einmalig generiert und zur Verifizierung benötigt
  • +
  • • Alle API-Zugriffe werden im Audit-Log protokolliert
  • +
+
+
+
+ + + + Verfügbare Scopes + + + {allScopes.map((s) => ( +
+ {s} + + {s.startsWith('crm.') && 'Lese-Zugriff auf Firmen/Kontakte'} + {s.startsWith('finance.') && 'Lese-Zugriff auf Rechnungen'} + {s.startsWith('licenses.') && 'Lese-Zugriff auf Lizenzen'} + {s.startsWith('webhooks.') && 'Webhook-Tests auslösen'} + +
+ ))} +
+
+
+
+ + {/* Create Token Dialog */} + + + + Neues API-Token + Token wird kryptografisch generiert und SHA-256 gehasht gespeichert. + + {newToken ? ( +
+
+

Token (nur einmal sichtbar)

+
+ {newToken} + +
+

⚠ Speichere das Token jetzt sicher. Es kann später nicht wieder angezeigt werden.

+
+ + + +
+ ) : ( + <> +
+
+ + setTokenName(e.target.value)} placeholder="z.B. ZeroClaw Agent" /> +
+
+ +
+ {allScopes.map((s) => ( + + ))} +
+
+
+ + + + + + )} +
+
+ + {/* Create Webhook Dialog */} + + + + Webhook registrieren + HMAC-SHA256-Signatur wird für jede Auslieferung berechnet. + + {newWhSecret ? ( +
+
+

Signing-Secret (nur einmal sichtbar)

+
+ {newWhSecret} + +
+

Benötigt zur Verifizierung der x-aos-signature Header auf deinem Server.

+
+ +
+ ) : ( + <> +
+
+ + setWhUrl(e.target.value)} placeholder="https://your-server.com/webhooks/aos" /> +
+
+ +
+ {knownEvents.map((e) => ( + + ))} +
+
+
+ + + + + + )} +
+
+
+ ) +} diff --git a/src/lib/api-auth.ts b/src/lib/api-auth.ts new file mode 100644 index 0000000..85739e5 --- /dev/null +++ b/src/lib/api-auth.ts @@ -0,0 +1,75 @@ +import { db } from '@/lib/db' +import { createHash } from 'crypto' + +// AOS Public API authentication + authorization. +// Tokens are SHA-256 hashed at rest (never stored plaintext). + +export interface AuthResult { + ok: boolean + token?: { id: string; name: string; scopes: string[] } + error?: string + status?: number +} + +// Verify a Bearer token from the Authorization header. +// Returns the token record if valid + not revoked + not expired. +export async function verifyToken(authHeader: string | null): Promise { + if (!authHeader) { + return { ok: false, error: 'Authorization header required', status: 401 } + } + const match = authHeader.match(/^Bearer\s+(aos_[a-z0-9]+)$/i) + if (!match) { + return { ok: false, error: 'Invalid token format (expected: Bearer aos_…)', status: 401 } + } + const token = match[1]! + // Hash the token to look it up (constant-time comparison via DB unique index) + const tokenHash = createHash('sha256').update(token).digest('hex') + const record = await db.apiToken.findUnique({ where: { tokenHash } }) + if (!record) { + return { ok: false, error: 'Invalid or unknown token', status: 401 } + } + if (record.revokedAt) { + return { ok: false, error: 'Token revoked', status: 401 } + } + if (record.expiresAt && record.expiresAt < new Date()) { + return { ok: false, error: 'Token expired', status: 401 } + } + // update lastUsedAt (fire-and-forget) + db.apiToken.update({ where: { id: record.id }, data: { lastUsedAt: new Date() } }).catch(() => {}) + + const scopes: string[] = JSON.parse(record.scopes) + return { ok: true, token: { id: record.id, name: record.name, scopes } } +} + +// Check if a token has a specific scope (resource:action). +// Supports wildcard: "crm.*:read" matches "crm.company:read". +export function hasScope(token: AuthResult['token'], required: string): boolean { + if (!token) return false + return token.scopes.some((s) => { + if (s === required) return true + // wildcard: "crm.*:read" matches any crm resource read + const [res, action] = s.split(':') + if (res && res.endsWith('.*')) { + const prefix = res.slice(0, -2) + return required.startsWith(prefix + '.') && required.endsWith(':' + action) + } + return false + }) +} + +// Require a scope or return a 403 response. +export function requireScope(auth: AuthResult, scope: string): { allowed: boolean; error?: Response } { + if (!auth.ok || !auth.token) { + return { + allowed: false, + error: Response.json({ error: auth.error ?? 'unauthorized' }, { status: auth.status ?? 401 }), + } + } + if (!hasScope(auth.token, scope)) { + return { + allowed: false, + error: Response.json({ error: `insufficient scope — requires "${scope}"`, token: auth.token.name, scopes: auth.token.scopes }, { status: 403 }), + } + } + return { allowed: true } +} diff --git a/src/lib/seed.ts b/src/lib/seed.ts index 99959cb..f71df9a 100644 --- a/src/lib/seed.ts +++ b/src/lib/seed.ts @@ -669,10 +669,53 @@ async function main() { // audit entries for accounting await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'vat.compute', entity: 'VatReturn', entityId: 'seed', after: '{"period":"2026-10","payable":94800}', hash: 'sha256:' + Math.random().toString(36).slice(2) } }) - await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'vat.submit:elster', entity: 'VatReturn', entityId: 'seed', after: '{"elsterRef":"ERiC-2026-09","status":"accepted"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } }) + await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'vat.prepare:xml', entity: 'VatReturn', entityId: 'seed', after: '{"status":"prepared","xmlBytes":993}', hash: 'sha256:' + Math.random().toString(36).slice(2) } }) await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'bank.import:camt053', entity: 'BankTransaction', entityId: 'seed', after: '{"count":10,"batch":"camt-2026-10"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } }) await db.auditLog.create({ data: { actor: 'nora@nordlicht.digital', action: 'bank.match:invoice', entity: 'BankTransaction', entityId: 'seed', after: '{"invoiceId":"matched","status":"matched"}', hash: 'sha256:' + Math.random().toString(36).slice(2) } }) + // --- API Tokens + Webhooks (real crypto, hashed at rest) ----------------- + const crypto = await import('crypto') + const apiTokens = [ + { name: 'ZeroClaw Agent (read-only)', scopes: ['crm.company:read', 'finance.invoice:read', 'licenses.license:read'] }, + { name: 'DATEV Export-Skript', scopes: ['finance.invoice:read'] }, + { name: 'WordPress Update-Server', scopes: ['licenses.license:read'] }, + ] + for (const t of apiTokens) { + const plaintext = 'aos_' + crypto.randomBytes(32).toString('hex') + const tokenHash = crypto.createHash('sha256').update(plaintext).digest('hex') + const tokenPrefix = 'aos_' + plaintext.slice(4, 11) + await db.apiToken.create({ + data: { name: t.name, tokenHash, tokenPrefix, scopes: JSON.stringify(t.scopes), lastUsedAt: daysFromNow(-2) }, + }) + } + // one revoked token + const revokedPlain = 'aos_' + crypto.randomBytes(32).toString('hex') + await db.apiToken.create({ + data: { + name: 'Altes Backup-Skript (widerrufen)', + tokenHash: crypto.createHash('sha256').update(revokedPlain).digest('hex'), + tokenPrefix: 'aos_' + revokedPlain.slice(4, 11), + scopes: JSON.stringify(['finance.invoice:read']), + revokedAt: daysFromNow(-30), + lastUsedAt: daysFromNow(-32), + }, + }) + + // Webhooks with real HMAC secrets + const webhooks = [ + { url: 'https://make.nordlicht.digital/webhooks/aos', events: ['invoice.paid', 'contract.signed', 'lead.approved'], sentCount: 47, failCount: 2, lastStatus: 200, lastSentAt: daysFromNow(-0.2) }, + { url: 'https://hooks.slack.com/services/T0/B0/xxxx', events: ['ticket.created', 'invoice.overdue'], sentCount: 12, failCount: 0, lastStatus: 200, lastSentAt: daysFromNow(-1) }, + { url: 'https://erp.hansemoebel.de/api/aos-events', events: ['license.activated'], sentCount: 5, failCount: 1, lastStatus: 500, lastSentAt: daysFromNow(-3) }, + ] + for (const w of webhooks) { + await db.webhook.create({ + data: { ...w, events: JSON.stringify(w.events), secret: 'whsec_' + crypto.randomBytes(24).toString('hex') }, + }) + } + + await db.auditLog.create({ data: { actor: 'max@nordlicht.digital', action: 'api.token:create', entity: 'ApiToken', entityId: 'seed', after: '{"name":"ZeroClaw Agent","scopes":["crm.company:read"]}', hash: 'sha256:' + Math.random().toString(36).slice(2) } }) + await db.auditLog.create({ data: { actor: 'max@nordlicht.digital', action: 'webhook.create', entity: 'Webhook', entityId: 'seed', after: '{"url":"make.nordlicht.digital","events":["invoice.paid"]}', hash: 'sha256:' + Math.random().toString(36).slice(2) } }) + console.log('Seed complete.') } diff --git a/src/lib/webhook.ts b/src/lib/webhook.ts new file mode 100644 index 0000000..2f749e9 --- /dev/null +++ b/src/lib/webhook.ts @@ -0,0 +1,57 @@ +import { db } from '@/lib/db' +import { createHmac } from 'crypto' + +// Deliver a webhook event to all registered endpoints that subscribe to it. +// Each delivery is signed with HMAC-SHA256 using the webhook's secret so the +// receiver can verify authenticity. + +export async function dispatchWebhook(event: string, payload: unknown): Promise { + const webhooks = await db.webhook.findMany({ where: { active: true } }) + const matching = webhooks.filter((w) => { + const events: string[] = JSON.parse(w.events) + return events.includes(event) || events.includes('*') + }) + // Fire-and-forget: each delivery is independent + for (const hook of matching) { + void deliver(hook, event, payload) + } +} + +async function deliver( + hook: { id: string; url: string; secret: string }, + event: string, + payload: unknown +): Promise { + const body = JSON.stringify({ event, deliveredAt: new Date().toISOString(), data: payload }) + const timestamp = Math.floor(Date.now() / 1000) + const signatureBase = `${timestamp}.${body}` + const signature = createHmac('sha256', hook.secret).update(signatureBase).digest('hex') + + try { + const res = await fetch(hook.url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-aos-event': event, + 'x-aos-timestamp': String(timestamp), + 'x-aos-signature': `sha256=${signature}`, + }, + body, + signal: AbortSignal.timeout(10000), + }) + await db.webhook.update({ + where: { id: hook.id }, + data: { + sentCount: { increment: 1 }, + lastSentAt: new Date(), + lastStatus: res.status, + failCount: res.ok ? undefined : { increment: 1 }, + }, + }) + } catch { + await db.webhook.update({ + where: { id: hook.id }, + data: { sentCount: { increment: 1 }, failCount: { increment: 1 }, lastSentAt: new Date() }, + }) + } +} diff --git a/src/store/aos.ts b/src/store/aos.ts index d016e4e..f12239c 100644 --- a/src/store/aos.ts +++ b/src/store/aos.ts @@ -16,6 +16,7 @@ export type ViewId = | 'finance' | 'accounting' | 'audit' + | 'api' | 'reporting' | 'settings' | 'setup'