f325299d-c1a0-4376-98ae-ddb747d23da5
This commit is contained in:
1 parent
9c58e29472
commit
be34f88822
22 files changed
+1361
-1
No files matched your search
@@ -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 })
|
||||
}
|
||||
@@ -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.',
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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_<random>`. 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=<hex>',
|
||||
signatureBase: '<timestamp>.<body>',
|
||||
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' },
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user