feat: add deterministic agent publishing contract

This commit is contained in:
AHMED
2026-10-09 15:48:13 +03:00
parent 690c2deed7
commit 00f8e4aee7
6 changed files with 501 additions and 104 deletions
+75
View File
@@ -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": "<raw JPEG base64>"
}
}
```
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/<slug>`
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/<slug>`
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.
+1
View File
@@ -5,6 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"test": "node --experimental-strip-types --test tests/*.test.ts",
"build": "payload generate:types && next build", "build": "payload generate:types && next build",
"start": "next start", "start": "next start",
"generate:types": "payload generate:types", "generate:types": "payload generate:types",
+65 -21
View File
@@ -2,38 +2,82 @@ import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import configPromise from '@payload-config' import configPromise from '@payload-config'
import { decodeCoverImage, parsePostPatch, postResponse } from '../post-contract'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
/** Agent update endpoint: PUT /api/agent/posts/:slug */ function unauthorized() {
export async function PUT(req: NextRequest, ctx: { params: Promise<{ slug: string }> }) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
function validKey(req: NextRequest) {
const key = process.env.AGENT_API_KEY const key = process.env.AGENT_API_KEY
if (!key || req.headers.get('x-agent-key') !== key) { return Boolean(key && req.headers.get('x-agent-key') === key)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }
}
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 payload = await getPayload({ config: configPromise })
const { slug } = await ctx.params const result = await payload.find({
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null
if (!body) return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 })
const existing = await payload.find({
collection: 'posts', collection: 'posts',
where: { slug: { equals: slug } }, where: { slug: { equals: slug } },
limit: 1, limit: 1,
depth: 0,
overrideAccess: true,
}) })
const post = existing.docs[0] return { payload, post: result.docs[0] }
if (!post) return NextResponse.json({ error: 'not found' }, { status: 404 }) }
// Only writable fields; status is admin-only export async function GET(req: NextRequest, ctx: { params: Promise<{ slug: string }> }) {
const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'access', 'readingMinutes', 'cover'] as const if (!validKey(req)) return unauthorized()
const data: Record<string, unknown> = {} const { slug } = await ctx.params
for (const field of allowed) { const { post } = await findPost(slug)
if (field in body) data[field] = body[field] 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 }) const { payload, post } = await findPost(slug)
return NextResponse.json({ if (!post) return NextResponse.json({ error: 'not found' }, { status: 404 })
id: doc.id, title: doc.title, slug: doc.slug,
status: doc.status, access: doc.access, category: doc.category, const { coverImage, publish: _publish, ...fields } = patch
const data: Record<string, unknown> = { ...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))
} }
@@ -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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>,
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<PostBody> {
if (!input || typeof input !== 'object' || Array.isArray(input)) {
throw new Error('JSON object body is required')
}
const body = input as Record<string, unknown>
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}`,
}
}
+62 -83
View File
@@ -3,9 +3,9 @@ import { getPayload } from 'payload'
import type { Payload } from 'payload' import type { Payload } from 'payload'
import configPromise from '@payload-config' import configPromise from '@payload-config'
export const dynamic = 'force-dynamic' import { decodeCoverImage, parsePostBody, postResponse, type PostBody } from './post-contract'
type Body = Record<string, unknown> export const dynamic = 'force-dynamic'
function unauthorized() { function unauthorized() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -13,19 +13,19 @@ function unauthorized() {
function validKey(req: NextRequest) { function validKey(req: NextRequest) {
const key = process.env.AGENT_API_KEY const key = process.env.AGENT_API_KEY
if (!key) return false return Boolean(key && req.headers.get('x-agent-key') === key)
return req.headers.get('x-agent-key') === key
} }
/** Fields the agent is allowed to write; status is NEVER included. */ function badRequest(error: unknown) {
function pickFields(body: Body): Body { const message = error instanceof Error ? error.message : 'invalid request'
const allowed = ['title', 'slug', 'dek', 'teaser', 'content', 'category', 'access', 'readingMinutes', 'cover'] as const return NextResponse.json({ error: message }, { status: 400 })
const out: Body = {} }
for (const field of allowed) {
if (field in body) out[field] = body[field] function isUniqueSlugError(error: unknown) {
} if (!error || typeof error !== 'object') return false
// cover omitted → per-category default assigned below (seeded covers) const value = error as { code?: unknown; message?: unknown; detail?: unknown }
return out const text = `${String(value.message ?? '')} ${String(value.detail ?? '')}`.toLowerCase()
return value.code === '23505' && text.includes('slug')
} }
const CATEGORY_COVER_SLUGS: Record<string, string> = { const CATEGORY_COVER_SLUGS: Record<string, string> = {
@@ -37,95 +37,74 @@ const CATEGORY_COVER_SLUGS: Record<string, string> = {
'أعمال': 'cover-business', 'أعمال': 'cover-business',
} }
async function defaultCoverId(payload: Payload, category: unknown) { async function defaultCoverId(payload: Payload, category: string) {
const filename = CATEGORY_COVER_SLUGS[String(category)] || 'cover-default' const filename = CATEGORY_COVER_SLUGS[category] || 'cover-default'
const res = await payload.find({ const result = await payload.find({
collection: 'media', collection: 'media',
where: { filename: { equals: `${filename}.jpg` } }, where: { filename: { equals: `${filename}.jpg` } },
limit: 1, 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) { export async function POST(req: NextRequest) {
if (!validKey(req)) return unauthorized() if (!validKey(req)) return unauthorized()
const payload = await getPayload({ config: configPromise })
const body = (await req.json().catch(() => null)) as Body | null let body: PostBody
if (!body || typeof body.slug !== 'string' || !body.slug) { try {
return NextResponse.json({ error: 'slug is required' }, { status: 400 }) body = parsePostBody(await req.json())
} catch (error) {
return badRequest(error)
} }
// Slug conflict → 409 const payload = await getPayload({ config: configPromise })
const existing = await payload.find({ const existing = await payload.find({
collection: 'posts', collection: 'posts',
where: { slug: { equals: body.slug } }, where: { slug: { equals: body.slug } },
limit: 1, limit: 1,
depth: 0,
overrideAccess: true,
}) })
if (existing.docs.length > 0) { if (existing.docs.length > 0) {
return NextResponse.json({ error: 'slug already exists' }, { status: 409 }) return NextResponse.json({ error: 'slug already exists' }, { status: 409 })
} }
const data = pickFields(body) try {
if (!data.cover) data.cover = await defaultCoverId(payload, data.category) const { coverImage: _coverImage, publish, ...fields } = body
if (!data.content || typeof data.content !== 'object') { const publishedAt = publish ? new Date().toISOString() : undefined
// minimal valid Lexical doc (empty root is rejected by validation); agent PUTs full content later const doc = await payload.create({
data.content = { collection: 'posts',
root: { data: {
type: 'root', format: '', indent: 0, version: 1, direction: 'rtl', ...fields,
children: [{ type: 'paragraph', version: 1, children: [{ type: 'text', version: 1, text: '…' }] }], 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,
})
} }
+118
View File
@@ -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',
},
)
})
})