fix: enforce VIP content boundary and PATCH updates

This commit is contained in:
AHMED
2026-10-09 16:27:49 +03:00
parent 61aea8d83e
commit 87e653cc96
9 changed files with 103 additions and 8 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ curl -X POST http://localhost:3000/api/agent/posts \
-d '{"title":"...","slug":"my-post","dek":"...","category":"وكلاء","access":"vip","teaser":"...","readingMinutes":8,"content":{"root":{"type":"root","children":[],"format":"","indent":0,"version":1}}}'
# update (cannot change status)
curl -X PUT http://localhost:3000/api/agent/posts/my-post -H "x-agent-key: $AGENT_API_KEY" ...
curl -X PATCH http://localhost:3000/api/agent/posts/my-post -H "x-agent-key: $AGENT_API_KEY" ...
```
Omitting `cover` assigns the per-category default cover.
+1 -1
View File
@@ -47,7 +47,7 @@ Returns 200 for a draft or published post, 404 when absent. Authentication is re
## Promote or republish
`PUT /api/agent/posts/<slug>`
`PATCH /api/agent/posts/<slug>`
Draft promotion body:
+12 -2
View File
@@ -98,12 +98,12 @@ export default async function PostPage({ params }: Args) {
where: { and: [{ slug: { equals: slug } }, { status: { equals: 'published' } }] },
depth: 1,
limit: 1,
// content selected only for entitled viewers — never reaches non-VIP clients
// VIP content is deliberately excluded here. It is fetched only after
// the viewer's entitlement has been established below.
select: {
title: true, slug: true, dek: true, access: true, category: true, level: true,
views: true, likes: true,
readingMinutes: true, publishedAt: true, teaser: true, cover: true,
content: true,
},
})
const post = res.docs[0] as unknown as PostDoc | undefined
@@ -111,6 +111,16 @@ export default async function PostPage({ params }: Args) {
const role = await getViewerRole()
const unlocked = post.access === 'free' || role === 'vip' || role === 'admin'
if (unlocked) {
const entitled = await payload.findByID({
collection: 'posts',
id: post.id,
depth: 0,
overrideAccess: true,
select: { content: true },
})
post.content = entitled.content
}
const accessChip = post.access === 'free' ? 'مقال مجاني · وصول كامل' : 'VIP · وصول كامل'
const viewerLevel = await getViewerField('level')
const viewerId = await getViewerField('id')
+1 -1
View File
@@ -40,7 +40,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ slug: strin
return NextResponse.json(postResponse(post))
}
export async function PUT(req: NextRequest, ctx: { params: Promise<{ slug: string }> }) {
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ slug: string }> }) {
if (!validKey(req)) return unauthorized()
const { slug } = await ctx.params
+15
View File
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { describe, it } from 'node:test'
const routeSource = readFileSync(
new URL('../src/app/api/agent/posts/[slug]/route.ts', import.meta.url),
'utf8',
)
describe('agent post update HTTP contract', () => {
it('exports PATCH and does not export PUT', () => {
assert.match(routeSource, /export async function PATCH\(/)
assert.doesNotMatch(routeSource, /export async function PUT\(/)
})
})
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { describe, it } from 'node:test'
const pageSource = readFileSync(
new URL('../src/app/(frontend)/posts/[slug]/page.tsx', import.meta.url),
'utf8',
)
const slug = process.env.ANWARPY_VIP_TEST_SLUG
const marker = process.env.ANWARPY_VIP_BODY_MARKER
const cookie = process.env.ANWARPY_VIP_COOKIE
const baseUrl = (process.env.ANWARPY_TEST_BASE_URL || 'http://127.0.0.1:3000').replace(/\/$/, '')
async function body(url, headers = {}) {
const response = await fetch(url, { headers, redirect: 'manual' })
return { status: response.status, text: await response.text() }
}
describe('VIP content query boundary', () => {
it('does not load content before entitlement is established', () => {
const pageStart = pageSource.indexOf('export default async function PostPage')
const roleCheck = pageSource.indexOf('const role = await getViewerRole()', pageStart)
const beforeRole = pageSource.slice(pageStart, roleCheck)
assert.doesNotMatch(beforeRole, /content\s*:\s*true/)
})
it('loads content only inside the unlocked branch', () => {
assert.match(
pageSource,
/const unlocked[\s\S]*if \(unlocked\) \{[\s\S]*findByID\([\s\S]*select:\s*\{\s*content:\s*true\s*\}/,
)
assert.match(pageSource, /\{unlocked \? \([\s\S]*<RichText data=\{post\.content\}/)
})
})
describe('VIP transport acceptance', () => {
it('does not expose the protected marker to anonymous HTML, RSC, or REST', {
skip: !slug || !marker,
}, async () => {
const encoded = encodeURIComponent(slug)
const [html, rsc, rest] = await Promise.all([
body(`${baseUrl}/posts/${encoded}`),
body(`${baseUrl}/posts/${encoded}?__rsc=vip-test`, { RSC: '1', Accept: 'text/x-component' }),
body(`${baseUrl}/api/posts?where%5Bslug%5D%5Bequals%5D=${encoded}&depth=0`, { Accept: 'application/json' }),
])
assert.equal(html.status, 200)
assert.ok([200, 204].includes(rsc.status))
assert.ok([200, 401, 403].includes(rest.status))
for (const response of [html, rsc, rest]) {
assert.equal(response.text.includes(marker), false)
}
})
it('exposes the protected marker to an entitled VIP session', {
skip: !slug || !marker || !cookie,
}, async () => {
const response = await body(`${baseUrl}/posts/${encodeURIComponent(slug)}`, { Cookie: cookie })
assert.equal(response.status, 200)
assert.equal(response.text.includes(marker), true)
})
})