import { NextRequest, NextResponse } from 'next/server' import { getPayload } from 'payload' import type { Payload } from 'payload' import type { Media } from '@payload-types' import configPromise from '@payload-config' import { resolveCoverImage } from '@/lib/agent-media' import { resolveAttachments, deleteFileDocs } from '@/lib/agent-files' import { scanAgentPayload } from '@/lib/secret-scan' import { lexicalToText } from '@/lib/lexical' export const dynamic = 'force-dynamic' type Body = Record function unauthorized() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } function validKey(req: NextRequest) { const key = process.env.AGENT_API_KEY if (!key) return false return req.headers.get('x-agent-key') === key } /** Fields the agent is allowed to write; status is NEVER included. * `access`/`readingMinutes` are admin-only (tier changes and reading time * are admin/agent-policy decisions — the hook computes honest minutes). */ function pickFields(body: Body): Body { const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'cover'] as const const out: Body = {} for (const field of allowed) { if (field in body) out[field] = body[field] } // cover omitted → per-category default assigned below (seeded covers) return out } const CATEGORY_COVER_SLUGS: Record = { 'وكلاء': 'cover-agents', 'نماذج': 'cover-models', 'استرجاع': 'cover-retrieval', 'سلامة': 'cover-safety', 'أدوات': 'cover-tools', 'أعمال': 'cover-business', } async function defaultCoverId(payload: Payload, category: unknown) { const filename = CATEGORY_COVER_SLUGS[String(category)] || 'cover-default' const res = await payload.find({ collection: 'media', where: { filename: { equals: `${filename}.jpg` } }, limit: 1, }) return res.docs[0]?.id } export async function POST(req: NextRequest) { if (!validKey(req)) return unauthorized() const payload = await getPayload({ config: configPromise }) const body = (await req.json().catch(() => null)) as Body | null if (!body || typeof body.slug !== 'string' || !body.slug) { return NextResponse.json({ error: 'slug is required' }, { status: 400 }) } // L-10 dedup first: a retried ingest with the same sourceId surfaces the // existing post (200) instead of a misleading slug 409. if (typeof body.sourceId === 'string' && body.sourceId.trim() !== '') { const dupe = await payload.find({ collection: 'posts', where: { sourceId: { equals: body.sourceId } }, limit: 1, overrideAccess: true, }) const dup = dupe.docs[0] as { id: number; slug: string; status: string } | undefined if (dup) { return NextResponse.json({ id: dup.id, slug: dup.slug, status: dup.status, deduplicated: true }, { status: 200 }) } } // Slug conflict → 409 const existing = await payload.find({ collection: 'posts', where: { slug: { equals: body.slug } }, limit: 1, }) if (existing.docs.length > 0) { return NextResponse.json({ error: 'slug already exists' }, { status: 409 }) } const data = pickFields(body) if (typeof body.sourceId === 'string' && body.sourceId.trim() !== '') data.sourceId = body.sourceId // L-05: pattern names only — matched values are never stored or echoed. // A string content (wrong shape) is scanned raw too — lexicalToText of a // string returns '' and would silently skip the scan. const secretPatterns = scanAgentPayload([ data.title as string, data.dek as string, data.teaser as string, typeof data.content === 'string' ? data.content : lexicalToText(data.content), ]) if (secretPatterns.length > 0) { return NextResponse.json({ error: 'content rejected: potential secrets', patterns: secretPatterns }, { status: 422 }) } // Per-post work files (inline base64) → stored as gated `files` documents. // Like coverImage, `attachments` is an input transform, not a raw field. let attachments: Awaited> = [] if ('attachments' in body && body.attachments != null) { try { attachments = await resolveAttachments(payload, body.attachments) data.attachments = attachments.map(a => a.id) } catch (err) { return NextResponse.json({ error: (err as Error).message }, { status: 400 }) } } // Explicit image payload (inline base64 or remote URL) → stored as a media document. let coverUrl: string | null = null if ('coverImage' in body && body.coverImage != null) { try { const cover = await resolveCoverImage(payload, body.coverImage) data.cover = cover.id coverUrl = cover.url } catch (err) { return NextResponse.json({ error: (err as Error).message }, { status: 400 }) } } if (!data.cover) data.cover = await defaultCoverId(payload, data.category) // `publish` is an explicit opt-in flag — the agent still cannot pass `status` // directly (unpublished content requires a deliberate admin action otherwise). const publish = body.publish === true if (publish && !data.publishedAt) data.publishedAt = new Date().toISOString() if (typeof data.content === 'string') { // Wrong shape — never store it and never silently drop the agent's text. return NextResponse.json( { error: 'content must be Lexical JSON ({ root: { children: [...] } }), not a markdown/plain string' }, { status: 400 }, ) } if (!data.content) { // minimal valid Lexical doc (empty root is rejected by validation); agent PUTs full content later data.content = { root: { type: 'root', format: '', indent: 0, version: 1, direction: 'rtl', children: [{ type: 'paragraph', version: 1, children: [{ type: 'text', version: 1, text: '…' }] }], }, } } let doc try { doc = await payload.create({ collection: 'posts', data: { ...data, status: publish ? 'published' : 'draft' }, draft: true, depth: 0, }) } catch (err) { // The post write failed — don't orphan the file docs created above. if (attachments.length) await deleteFileDocs(payload, attachments.map(a => a.id)) return NextResponse.json({ error: (err as Error).message }, { status: 400 }) } if (!coverUrl && data.cover) { const coverDoc: Media = await payload.findByID({ collection: 'media', id: data.cover as number }) coverUrl = coverDoc.url ?? null } return NextResponse.json({ id: doc.id, title: doc.title, slug: doc.slug, status: doc.status, access: doc.access, category: doc.category, cover: { id: data.cover, url: coverUrl }, attachments: attachments.map(a => ({ id: a.id, filename: a.filename, access: a.access, url: `/api/files/${a.id}` })), }, { status: 201 }) } export async function PUT(req: NextRequest) { if (!validKey(req)) return unauthorized() const payload = await getPayload({ config: configPromise }) const slug = req.nextUrl.pathname.split('/').filter(Boolean).pop() as string const body = (await req.json().catch(() => null)) as Body | null if (!body) return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 }) const existing = await payload.find({ collection: 'posts', where: { slug: { equals: slug } }, limit: 1, overrideAccess: true, }) const post = existing.docs[0] if (!post) return NextResponse.json({ error: 'not found' }, { status: 404 }) const data = pickFields(body) delete (data as Body).status // agent can never publish/unpublish const secretPatterns = scanAgentPayload([ data.title as string, data.dek as string, data.teaser as string, typeof data.content === 'string' ? data.content : lexicalToText(data.content), ]) if (secretPatterns.length > 0) { return NextResponse.json({ error: 'content rejected: potential secrets', patterns: secretPatterns }, { status: 422 }) } const doc = await payload.update({ collection: 'posts', id: post.id, data, depth: 0, }) return NextResponse.json({ id: doc.id, title: doc.title, slug: doc.slug, status: doc.status, access: doc.access, category: doc.category, }) }