Files
accounted/app/api/agent/composer/route.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

104 lines
3.5 KiB
TypeScript

import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent'
import { composeAgentProfile } from '@/lib/agent/composer'
import { guardSandbox } from '@/lib/sandbox/guard'
import { requireCapability } from '@/lib/entitlements/has-capability'
import { CAPABILITY } from '@/lib/entitlements/keys'
const BodySchema = z.object({
// Optional override; if absent we use the user's active_company_id.
company_id: z.string().uuid().optional(),
// dry_run=true returns the composed profile without writing to agent_profiles.
dry_run: z.boolean().optional(),
})
// POST /api/agent/composer
//
// Runs the specialized accountant composer pipeline for a company:
// 1. Gathers TIC snapshot, optional SIE summary, optional banking summary.
// 2. Calls Opus 4.7 to select horizontal/vertical/modifier atoms.
// 3. Calls Sonnet 4.6 to write the Swedish profile_summary.
// 4. Persists to agent_profiles (skipped on dry_run).
// 5. Fires fire-and-forget cache pre-warm.
//
// Auth: must be a non-viewer member of the target company (it rewrites the
// company's agent_profile unless dry_run).
//
// Plan ref: dev_docs/specialized-agent-plan.md §6.
export const POST = withRouteContext('agent.composer.run', async (request, ctx) => {
const { supabase, companyId: activeCompanyId, user } = ctx
const rate = await checkAgentRateLimit(supabase, user.id)
if (!rate.ok) {
return NextResponse.json(agentRateLimitResponseBody(rate), {
status: 429,
headers: rate.retryAfterSec ? { 'Retry-After': String(rate.retryAfterSec) } : undefined,
})
}
// Tolerant parse: ops callers POST with an empty body, which is valid here.
const raw = await request.json().catch(() => ({}))
const parsed = BodySchema.safeParse(raw)
if (!parsed.success) {
return NextResponse.json(
{
error: 'Validation failed',
type: 'validation_error',
errors: parsed.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code,
})),
},
{ status: 400 },
)
}
const body = parsed.data
const companyId = body.company_id ?? activeCompanyId
// Defense in depth alongside RLS: confirm membership before composing, and
// require a non-viewer role: the composer rewrites agent_profiles.
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', user.id)
.maybeSingle()
if (!membership) {
return NextResponse.json(
{
error: {
code: 'NOT_COMPANY_MEMBER',
message: 'Du är inte medlem i detta företag.',
message_en: 'Not a member of this company.',
},
},
{ status: 403 },
)
}
if (membership.role === 'viewer') {
return NextResponse.json(
{
error: {
code: 'WRITE_PERMISSION_REQUIRED',
message: 'Du har endast läsbehörighet i detta företag.',
message_en: 'You only have read access in this company.',
},
},
{ status: 403 },
)
}
const blocked = await guardSandbox(supabase, companyId)
if (blocked) return blocked
const capBlocked = await requireCapability(supabase, companyId, CAPABILITY.ai)
if (capBlocked) return capBlocked
const composed = await composeAgentProfile(supabase, companyId, { dryRun: body.dry_run })
return NextResponse.json({ data: composed })
})