From 00f8e4aee744b6536f5182058cb6b9487abcada6 Mon Sep 17 00:00:00 2001 From: AHMED Date: Fri, 9 Oct 2026 15:48:13 +0300 Subject: [PATCH] feat: add deterministic agent publishing contract --- web/docs/agent-publish-api.md | 75 ++++++++ web/package.json | 1 + web/src/app/api/agent/posts/[slug]/route.ts | 86 ++++++--- web/src/app/api/agent/posts/post-contract.ts | 180 +++++++++++++++++++ web/src/app/api/agent/posts/route.ts | 145 +++++++-------- web/tests/agent-post-contract.test.ts | 118 ++++++++++++ 6 files changed, 501 insertions(+), 104 deletions(-) create mode 100644 web/docs/agent-publish-api.md create mode 100644 web/src/app/api/agent/posts/post-contract.ts create mode 100644 web/tests/agent-post-contract.test.ts diff --git a/web/docs/agent-publish-api.md b/web/docs/agent-publish-api.md new file mode 100644 index 0000000..03f82a3 --- /dev/null +++ b/web/docs/agent-publish-api.md @@ -0,0 +1,75 @@ +# Agent publishing API + +Base URL: `https://anwarpy.ai/api/agent/posts` + +Authentication: send the current secret in `x-agent-key`. The key is stored only in the deployment environment and the local secret store; it must not appear in source, logs, prompts, or this document. + +## Create + +`POST /api/agent/posts` + +```json +{ + "title": "دليل عربي", + "slug": "arabic-ai-guide", + "dek": "وصف عملي", + "teaser": "مطلوب لمقال VIP فقط", + "content": { "root": { "type": "root", "children": [] } }, + "category": "أدوات", + "access": "vip", + "readingMinutes": 7, + "publish": true, + "coverImage": { + "filename": "arabic-ai-guide.jpg", + "dataBase64": "" + } +} +``` + +Rules: + +- `slug`: lowercase ASCII kebab-case. +- `category`: one of `وكلاء`, `نماذج`, `استرجاع`, `سلامة`, `أدوات`, `أعمال`. +- `access`: `free` or `vip`. +- VIP requires a non-empty teaser. +- `coverImage` is optional. When provided it must be JPEG and no larger than 5MB. Otherwise the seeded category cover is used. +- `publish` omitted or false creates `status=draft`; true creates `status=published` with a server timestamp. +- Unknown fields, including direct `status`, `publishedAt`, and `level`, return 400. +- An existing or racing duplicate slug returns 409. + +Success: 201. + +## Lookup + +`GET /api/agent/posts/` + +Returns 200 for a draft or published post, 404 when absent. Authentication is required. The response never includes article or teaser content. + +## Promote or republish + +`PUT /api/agent/posts/` + +Draft promotion body: + +```json +{ "publish": true } +``` + +A full republish uses the same body as create, requires `publish=true`, and requires the body slug to match the URL slug. It updates the existing post and never creates a second slug. + +## Response + +```json +{ + "id": 42, + "title": "دليل عربي", + "slug": "arabic-ai-guide", + "status": "published", + "access": "vip", + "category": "أدوات", + "publishedAt": "2026-10-09T12:00:00.000Z", + "url": "https://anwarpy.ai/posts/arabic-ai-guide" +} +``` + +Canonical URLs have no `www` and no trailing slash. diff --git a/web/package.json b/web/package.json index 34ad204..0e4f413 100644 --- a/web/package.json +++ b/web/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "next dev", + "test": "node --experimental-strip-types --test tests/*.test.ts", "build": "payload generate:types && next build", "start": "next start", "generate:types": "payload generate:types", diff --git a/web/src/app/api/agent/posts/[slug]/route.ts b/web/src/app/api/agent/posts/[slug]/route.ts index 09019b7..a68f709 100644 --- a/web/src/app/api/agent/posts/[slug]/route.ts +++ b/web/src/app/api/agent/posts/[slug]/route.ts @@ -2,38 +2,82 @@ import { NextRequest, NextResponse } from 'next/server' import { getPayload } from 'payload' import configPromise from '@payload-config' +import { decodeCoverImage, parsePostPatch, postResponse } from '../post-contract' + export const dynamic = 'force-dynamic' -/** Agent update endpoint: PUT /api/agent/posts/:slug */ -export async function PUT(req: NextRequest, ctx: { params: Promise<{ slug: string }> }) { +function unauthorized() { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +} + +function validKey(req: NextRequest) { const key = process.env.AGENT_API_KEY - if (!key || req.headers.get('x-agent-key') !== key) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + return Boolean(key && req.headers.get('x-agent-key') === key) +} +function badRequest(error: unknown) { + const message = error instanceof Error ? error.message : 'invalid request' + return NextResponse.json({ error: message }, { status: 400 }) +} + +async function findPost(slug: string) { const payload = await getPayload({ config: configPromise }) - const { slug } = await ctx.params - const body = (await req.json().catch(() => null)) as Record | null - if (!body) return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 }) - - const existing = await payload.find({ + const result = await payload.find({ collection: 'posts', where: { slug: { equals: slug } }, limit: 1, + depth: 0, + overrideAccess: true, }) - const post = existing.docs[0] - if (!post) return NextResponse.json({ error: 'not found' }, { status: 404 }) + return { payload, post: result.docs[0] } +} - // Only writable fields; status is admin-only - const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'access', 'readingMinutes', 'cover'] as const - const data: Record = {} - for (const field of allowed) { - if (field in body) data[field] = body[field] +export async function GET(req: NextRequest, ctx: { params: Promise<{ slug: string }> }) { + if (!validKey(req)) return unauthorized() + const { slug } = await ctx.params + const { post } = await findPost(slug) + if (!post) return NextResponse.json({ error: 'not found' }, { status: 404 }) + return NextResponse.json(postResponse(post)) +} + +export async function PUT(req: NextRequest, ctx: { params: Promise<{ slug: string }> }) { + if (!validKey(req)) return unauthorized() + const { slug } = await ctx.params + + let patch + try { + patch = parsePostPatch(await req.json(), slug) + } catch (error) { + return badRequest(error) } - 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, + const { payload, post } = await findPost(slug) + if (!post) return NextResponse.json({ error: 'not found' }, { status: 404 }) + + const { coverImage, publish: _publish, ...fields } = patch + const data: Record = { ...fields } + if (coverImage) { + const file = decodeCoverImage(coverImage) + const media = await payload.create({ + collection: 'media', + data: {}, + file, + depth: 0, + overrideAccess: true, + }) + data.cover = media.id + } + if (patch.publish) { + data.status = 'published' + data.publishedAt = post.publishedAt || new Date().toISOString() + } + + const doc = await payload.update({ + collection: 'posts', + id: post.id, + data: data as never, + depth: 0, + overrideAccess: true, }) + return NextResponse.json(postResponse(doc)) } diff --git a/web/src/app/api/agent/posts/post-contract.ts b/web/src/app/api/agent/posts/post-contract.ts new file mode 100644 index 0000000..ddfbd6c --- /dev/null +++ b/web/src/app/api/agent/posts/post-contract.ts @@ -0,0 +1,180 @@ +import path from 'node:path' + +export const CATEGORIES = ['وكلاء', 'نماذج', 'استرجاع', 'سلامة', 'أدوات', 'أعمال'] as const +export const MAX_COVER_BYTES = 5 * 1024 * 1024 + +const ALLOWED_FIELDS = new Set([ + 'title', + 'slug', + 'dek', + 'teaser', + 'content', + 'category', + 'access', + 'readingMinutes', + 'coverImage', + 'publish', +]) +const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ +const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/ + +type CoverImage = { + dataBase64: string + filename?: string +} + +export type PostBody = { + title: string + slug: string + dek: string + teaser?: string + content: Record + category: (typeof CATEGORIES)[number] + access: 'free' | 'vip' + readingMinutes: number + coverImage?: CoverImage + publish: boolean +} + +export type UploadedCover = { + data: Buffer + mimetype: 'image/jpeg' + name: string + size: number +} + +function nonEmptyString(value: unknown, field: string): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${field} is required`) + } + return value.trim() +} + +export function parsePostBody(input: unknown): PostBody { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('JSON object body is required') + } + + const body = input as Record + for (const field of Object.keys(body)) { + if (!ALLOWED_FIELDS.has(field)) throw new Error(`unsupported field: ${field}`) + } + + const slug = nonEmptyString(body.slug, 'slug') + if (!SLUG_RE.test(slug)) throw new Error('slug must be lowercase ASCII kebab-case') + + const access = body.access + if (access !== 'free' && access !== 'vip') throw new Error('access must be free or vip') + + const category = body.category + if (typeof category !== 'string' || !CATEGORIES.includes(category as PostBody['category'])) { + throw new Error('category is invalid') + } + + if (!body.content || typeof body.content !== 'object' || Array.isArray(body.content)) { + throw new Error('content must be a Lexical object') + } + + const readingMinutes = body.readingMinutes + if (!Number.isInteger(readingMinutes) || Number(readingMinutes) < 1 || Number(readingMinutes) > 120) { + throw new Error('readingMinutes must be an integer from 1 to 120') + } + + const publish = body.publish ?? false + if (typeof publish !== 'boolean') throw new Error('publish must be boolean') + + const teaser = body.teaser + if (teaser !== undefined && typeof teaser !== 'string') throw new Error('teaser must be a string') + if (access === 'vip' && (typeof teaser !== 'string' || teaser.trim() === '')) { + throw new Error('VIP teaser is required') + } + + if (body.coverImage !== undefined) { + if (!body.coverImage || typeof body.coverImage !== 'object' || Array.isArray(body.coverImage)) { + throw new Error('coverImage must be an object') + } + const cover = body.coverImage as Record + if (typeof cover.dataBase64 !== 'string') throw new Error('coverImage.dataBase64 is required') + if (cover.filename !== undefined && typeof cover.filename !== 'string') { + throw new Error('coverImage.filename must be a string') + } + } + + return { + title: nonEmptyString(body.title, 'title'), + slug, + dek: nonEmptyString(body.dek, 'dek'), + ...(teaser === undefined ? {} : { teaser: teaser.trim() }), + content: body.content as Record, + category: category as PostBody['category'], + access, + readingMinutes: Number(readingMinutes), + ...(body.coverImage === undefined ? {} : { coverImage: body.coverImage as CoverImage }), + publish, + } +} + +export function parsePostPatch(input: unknown, expectedSlug: string): Partial { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('JSON object body is required') + } + const body = input as Record + const fields = Object.keys(body) + if (fields.length === 0) throw new Error('at least one field is required') + for (const field of fields) { + if (!ALLOWED_FIELDS.has(field)) throw new Error(`unsupported field: ${field}`) + } + if (fields.length === 1 && fields[0] === 'publish') { + if (body.publish !== true) throw new Error('publish-only update must set publish to true') + return { publish: true } + } + + const parsed = parsePostBody(body) + if (!parsed.publish) throw new Error('republish update must set publish to true') + if (parsed.slug !== expectedSlug) throw new Error('slug must match the URL path') + return parsed +} + +export function decodeCoverImage(cover: CoverImage): UploadedCover { + const base64 = cover.dataBase64.trim() + const maxBase64Length = Math.ceil(MAX_COVER_BYTES / 3) * 4 + if (base64.length > maxBase64Length) throw new Error('coverImage must be 5MB or smaller') + if (!base64 || base64.length % 4 !== 0 || !BASE64_RE.test(base64)) { + throw new Error('coverImage data is invalid base64') + } + + const data = Buffer.from(base64, 'base64') + if (data.length > MAX_COVER_BYTES) throw new Error('coverImage must be 5MB or smaller') + if (data.length < 4 || data[0] !== 0xff || data[1] !== 0xd8 || data.at(-2) !== 0xff || data.at(-1) !== 0xd9) { + throw new Error('coverImage must be JPEG') + } + + const requested = path.basename(cover.filename || 'cover.jpg') + const stem = requested.replace(/\.jpe?g$/i, '').replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'cover' + const name = `${stem}.jpg` + return { data, mimetype: 'image/jpeg', name, size: data.length } +} + +type PostDocument = { + id: unknown + title?: unknown + slug?: unknown + status?: unknown + access?: unknown + category?: unknown + publishedAt?: unknown +} + +export function postResponse(doc: PostDocument) { + const slug = String(doc.slug) + return { + id: doc.id, + title: doc.title, + slug, + status: doc.status, + access: doc.access, + category: doc.category, + publishedAt: doc.publishedAt ?? null, + url: `https://anwarpy.ai/posts/${slug}`, + } +} diff --git a/web/src/app/api/agent/posts/route.ts b/web/src/app/api/agent/posts/route.ts index 02bd79e..510bb0e 100644 --- a/web/src/app/api/agent/posts/route.ts +++ b/web/src/app/api/agent/posts/route.ts @@ -3,9 +3,9 @@ import { getPayload } from 'payload' import type { Payload } from 'payload' import configPromise from '@payload-config' -export const dynamic = 'force-dynamic' +import { decodeCoverImage, parsePostBody, postResponse, type PostBody } from './post-contract' -type Body = Record +export const dynamic = 'force-dynamic' function unauthorized() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -13,19 +13,19 @@ function unauthorized() { function validKey(req: NextRequest) { const key = process.env.AGENT_API_KEY - if (!key) return false - return req.headers.get('x-agent-key') === key + return Boolean(key && req.headers.get('x-agent-key') === key) } -/** Fields the agent is allowed to write; status is NEVER included. */ -function pickFields(body: Body): Body { - const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'access', 'readingMinutes', '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 +function badRequest(error: unknown) { + const message = error instanceof Error ? error.message : 'invalid request' + return NextResponse.json({ error: message }, { status: 400 }) +} + +function isUniqueSlugError(error: unknown) { + if (!error || typeof error !== 'object') return false + const value = error as { code?: unknown; message?: unknown; detail?: unknown } + const text = `${String(value.message ?? '')} ${String(value.detail ?? '')}`.toLowerCase() + return value.code === '23505' && text.includes('slug') } const CATEGORY_COVER_SLUGS: Record = { @@ -37,95 +37,74 @@ const CATEGORY_COVER_SLUGS: Record = { 'أعمال': 'cover-business', } -async function defaultCoverId(payload: Payload, category: unknown) { - const filename = CATEGORY_COVER_SLUGS[String(category)] || 'cover-default' - const res = await payload.find({ +async function defaultCoverId(payload: Payload, category: string) { + const filename = CATEGORY_COVER_SLUGS[category] || 'cover-default' + const result = await payload.find({ collection: 'media', where: { filename: { equals: `${filename}.jpg` } }, limit: 1, + depth: 0, + overrideAccess: true, }) - return res.docs[0]?.id + const id = result.docs[0]?.id + if (!id) throw new Error(`default cover is missing for category: ${category}`) + return id +} + +async function coverId(payload: Payload, body: PostBody) { + if (!body.coverImage) return defaultCoverId(payload, body.category) + const file = decodeCoverImage(body.coverImage) + const media = await payload.create({ + collection: 'media', + data: {}, + file, + depth: 0, + overrideAccess: true, + }) + return media.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 }) + + let body: PostBody + try { + body = parsePostBody(await req.json()) + } catch (error) { + return badRequest(error) } - // Slug conflict → 409 + const payload = await getPayload({ config: configPromise }) const existing = await payload.find({ collection: 'posts', where: { slug: { equals: body.slug } }, limit: 1, + depth: 0, + overrideAccess: true, }) if (existing.docs.length > 0) { return NextResponse.json({ error: 'slug already exists' }, { status: 409 }) } - const data = pickFields(body) - if (!data.cover) data.cover = await defaultCoverId(payload, data.category) - if (!data.content || typeof data.content !== 'object') { - // 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: '…' }] }], - }, + try { + const { coverImage: _coverImage, publish, ...fields } = body + const publishedAt = publish ? new Date().toISOString() : undefined + const doc = await payload.create({ + collection: 'posts', + data: { + ...fields, + cover: await coverId(payload, body), + status: publish ? 'published' : 'draft', + ...(publishedAt ? { publishedAt } : {}), + } as never, + depth: 0, + overrideAccess: true, + }) + return NextResponse.json(postResponse(doc), { status: 201 }) + } catch (error) { + if (isUniqueSlugError(error)) { + return NextResponse.json({ error: 'slug already exists' }, { status: 409 }) } + throw error } - - const doc = await payload.create({ - collection: 'posts', - data: { ...data, status: 'draft' }, - draft: true, - depth: 0, - }) - - return NextResponse.json({ - id: doc.id, - title: doc.title, - slug: doc.slug, - status: doc.status, - access: doc.access, - category: doc.category, - }, { 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 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, - }) } diff --git a/web/tests/agent-post-contract.test.ts b/web/tests/agent-post-contract.test.ts new file mode 100644 index 0000000..afb4b1c --- /dev/null +++ b/web/tests/agent-post-contract.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + MAX_COVER_BYTES, + decodeCoverImage, + parsePostBody, + parsePostPatch, + postResponse, +} from '../src/app/api/agent/posts/post-contract.ts' + +const content = { + root: { + type: 'root', + format: '', + indent: 0, + version: 1, + direction: 'rtl', + children: [{ type: 'paragraph', version: 1, children: [{ type: 'text', version: 1, text: 'محتوى' }] }], + }, +} + +const validBody = { + title: 'دليل عربي', + slug: 'arabic-ai-guide', + dek: 'وصف عملي', + content, + category: 'أدوات', + access: 'free', + readingMinutes: 7, + publish: true, +} + +describe('parsePostBody', () => { + it('accepts the exact publishing contract', () => { + const parsed = parsePostBody(validBody) + assert.equal(parsed.slug, 'arabic-ai-guide') + assert.equal(parsed.publish, true) + }) + + it('rejects direct status and level writes', () => { + assert.throws(() => parsePostBody({ ...validBody, status: 'published' }), /unsupported field: status/) + assert.throws(() => parsePostBody({ ...validBody, level: 'متقدم' }), /unsupported field: level/) + }) + + it('requires a non-empty VIP teaser', () => { + assert.throws(() => parsePostBody({ ...validBody, access: 'vip', teaser: ' ' }), /VIP teaser/) + }) + + it('rejects invalid access, category, slug, and publish values', () => { + assert.throws(() => parsePostBody({ ...validBody, access: 'paid' }), /access/) + assert.throws(() => parsePostBody({ ...validBody, category: 'عام' }), /category/) + assert.throws(() => parsePostBody({ ...validBody, slug: 'عنوان عربي' }), /slug/) + assert.throws(() => parsePostBody({ ...validBody, publish: 'true' }), /publish/) + }) +}) + +describe('parsePostPatch', () => { + it('accepts publish-only draft promotion', () => { + assert.deepEqual(parsePostPatch({ publish: true }, 'arabic-ai-guide'), { publish: true }) + }) + + it('accepts a full republish only for the path slug', () => { + assert.equal(parsePostPatch(validBody, 'arabic-ai-guide').slug, 'arabic-ai-guide') + assert.throws(() => parsePostPatch(validBody, 'different-slug'), /slug must match/) + }) + + it('rejects status writes and empty updates', () => { + assert.throws(() => parsePostPatch({ status: 'published' }, 'arabic-ai-guide'), /unsupported field: status/) + assert.throws(() => parsePostPatch({}, 'arabic-ai-guide'), /at least one field/) + }) +}) + +describe('decodeCoverImage', () => { + it('accepts a small JPEG and normalizes its filename', () => { + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xdb, 0xff, 0xd9]) + const file = decodeCoverImage({ dataBase64: jpeg.toString('base64'), filename: '../cover.jpg' }) + assert.equal(file.mimetype, 'image/jpeg') + assert.equal(file.name, 'cover.jpg') + assert.deepEqual(file.data, jpeg) + }) + + it('rejects non-JPEG data and oversized files', () => { + assert.throws(() => decodeCoverImage({ dataBase64: Buffer.from('png').toString('base64') }), /JPEG/) + const oversized = Buffer.alloc(MAX_COVER_BYTES + 1, 0) + oversized[0] = 0xff + oversized[1] = 0xd8 + oversized[oversized.length - 2] = 0xff + oversized[oversized.length - 1] = 0xd9 + assert.throws(() => decodeCoverImage({ dataBase64: oversized.toString('base64') }), /5MB/) + }) +}) + +describe('postResponse', () => { + it('returns the minimal deterministic response and canonical URL', () => { + assert.deepEqual( + postResponse({ + id: 42, + title: 'دليل عربي', + slug: 'arabic-ai-guide', + status: 'published', + access: 'vip', + category: 'أدوات', + publishedAt: '2026-10-09T12:00:00.000Z', + }), + { + id: 42, + title: 'دليل عربي', + slug: 'arabic-ai-guide', + status: 'published', + access: 'vip', + category: 'أدوات', + publishedAt: '2026-10-09T12:00:00.000Z', + url: 'https://anwarpy.ai/posts/arabic-ai-guide', + }, + ) + }) +})