diff --git a/automation/anwarpy/runtime.py b/automation/anwarpy/runtime.py index 9b5d050..2ffe3f3 100644 --- a/automation/anwarpy/runtime.py +++ b/automation/anwarpy/runtime.py @@ -126,7 +126,7 @@ class AgentApi: except HTTPError as error: return error.code, error.read(), error.headers.get("content-type", "") except (TimeoutError, URLError) as error: - if method in ("POST", "PUT"): + if method in ("POST", "PATCH"): raise AmbiguousWrite(f"agent API {method} outcome is unknown") from error raise @@ -176,7 +176,7 @@ class AgentApi: probe = self._probe(body) if probe: self.body_probes[slug] = probe - status, raw, _ = self._request("PUT", f"/api/agent/posts/{quote(slug)}", body) + status, raw, _ = self._request("PATCH", f"/api/agent/posts/{quote(slug)}", body) if status != 200: raise RuntimeError(f"agent API update failed ({status})") return self._decode(status, raw) diff --git a/automation/anwarpy/test_publish_one.py b/automation/anwarpy/test_publish_one.py index 0a2d19f..45d8a27 100644 --- a/automation/anwarpy/test_publish_one.py +++ b/automation/anwarpy/test_publish_one.py @@ -193,7 +193,7 @@ class PublisherTests(unittest.TestCase): self.assertEqual(api.create_calls, []) self.assertEqual(len(api.update_calls), 1) - def test_republish_puts_existing_slug_and_never_posts(self): + def test_republish_updates_existing_slug_and_never_posts(self): record = { "id": 7, "title": "Old", "slug": "existing-guide", "status": "published", "access": "vip", "category": "أدوات", "publishedAt": "2026-10-09T09:00:00Z", diff --git a/automation/anwarpy/test_runtime.py b/automation/anwarpy/test_runtime.py index a45f3b1..0eeb893 100644 --- a/automation/anwarpy/test_runtime.py +++ b/automation/anwarpy/test_runtime.py @@ -42,10 +42,12 @@ class FakeAgentApi(AgentApi): def __init__(self): super().__init__("test-key") self.responses = [] + self.requests = [] self.public = (200, b"page") self.rsc = (200, b"rsc") def _request(self, method, path, body=None, *, anonymous=False): + self.requests.append((method, path, body, anonymous)) if self.responses: return self.responses.pop(0) return 200, b"{}", "application/json" @@ -80,6 +82,12 @@ class RuntimeTests(unittest.TestCase): self.assertEqual(api.create(body)["slug"], "guide") self.assertEqual(api.body_probes["guide"], "distinctive protected body sentence") + def test_agent_api_updates_with_patch(self): + api = FakeAgentApi() + api.responses = [(200, json.dumps({"slug": "guide"}).encode(), "application/json")] + self.assertEqual(api.update("guide", {"publish": True})["slug"], "guide") + self.assertEqual(api.requests[0][0:2], ("PATCH", "/api/agent/posts/guide")) + @patch("runtime.time.sleep", return_value=None) def test_vip_verification_rejects_body_leak(self, _sleep): api = FakeAgentApi() diff --git a/web/README.md b/web/README.md index 5156da3..88cd2d3 100644 --- a/web/README.md +++ b/web/README.md @@ -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. diff --git a/web/docs/agent-publish-api.md b/web/docs/agent-publish-api.md index 03f82a3..8292f32 100644 --- a/web/docs/agent-publish-api.md +++ b/web/docs/agent-publish-api.md @@ -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/` +`PATCH /api/agent/posts/` Draft promotion body: diff --git a/web/src/app/(frontend)/posts/[slug]/page.tsx b/web/src/app/(frontend)/posts/[slug]/page.tsx index b922f76..1f6ad27 100644 --- a/web/src/app/(frontend)/posts/[slug]/page.tsx +++ b/web/src/app/(frontend)/posts/[slug]/page.tsx @@ -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') diff --git a/web/src/app/api/agent/posts/[slug]/route.ts b/web/src/app/api/agent/posts/[slug]/route.ts index a68f709..2d1f32f 100644 --- a/web/src/app/api/agent/posts/[slug]/route.ts +++ b/web/src/app/api/agent/posts/[slug]/route.ts @@ -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 diff --git a/web/tests/agent-update-method.test.ts b/web/tests/agent-update-method.test.ts new file mode 100644 index 0000000..a9c3f65 --- /dev/null +++ b/web/tests/agent-update-method.test.ts @@ -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\(/) + }) +}) diff --git a/web/tests/vip-content-leak.test.ts b/web/tests/vip-content-leak.test.ts new file mode 100644 index 0000000..471a417 --- /dev/null +++ b/web/tests/vip-content-leak.test.ts @@ -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]* { + 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) + }) +})