diff --git a/README.md b/README.md index 62f9ed8..d5dfe5f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ view and layout adapters, server functions); `@payloadcms/ui` is used unmodified A complete demo app lives in [`demo/`](./demo): SQLite, Lexical, drafts and autosave, page blocks with live preview, uploads, two locales, a global, and a website that reads content -through the Local API. A 35-test Playwright bench runs on it in CI against both the production +through the Local API. A 36-test Playwright bench runs on it in CI against both the production build and `vite dev`. Scenarios and results are in [`docs/COMPAT.md`](./docs/COMPAT.md). ```bash diff --git a/demo/tests/e2e/14-server-function-access.spec.ts b/demo/tests/e2e/14-server-function-access.spec.ts new file mode 100644 index 0000000..00e35df --- /dev/null +++ b/demo/tests/e2e/14-server-function-access.spec.ts @@ -0,0 +1,46 @@ +import { expect, type APIRequestContext, test } from '@playwright/test' + +import { gotoAdmin, login, uniqueSlug } from './helpers' + +// In React Router's RSC mode, a server function is an endpoint that any POST can call, on any +// route: the route middleware and loaders do not protect it. Payload must refuse each call +// without a session by itself. These calls target a public website route on purpose. +const dataFunctions = (collectionSlug: string, id: number) => + [ + ['render-document', { collectionSlug, docID: id, redirectAfterCreate: false }], + ['render-list', { collectionSlug, disableBulkDelete: true, query: { draft: true } }], + ['table-state', { collectionSlug, columns: [], query: { draft: 'true' }, renderRowTypes: true }], + ['form-state', { collectionSlug, id, operation: 'update', renderAllFields: true, schemaPath: collectionSlug }], + ['copy-data-from-locale', { collectionSlug, docID: id, fromLocale: 'en', overrideData: true, toLocale: 'fr' }], + ] as const + +const callServerFunction = (request: APIRequestContext, actionId: string, name: string, args: object) => + request.post('/', { + headers: { accept: 'text/x-component', 'rsc-action-id': actionId }, + multipart: { '_1_$SKIP_REVALIDATION': '1', 0: JSON.stringify([{ args, name }]) }, + }) + +test('server functions return no data without a session, even from a public route', async ({ page, request }) => { + await login(page) + const secret = `SECRET-${uniqueSlug('draft')}` + const created = await page.request.post('/api/pages?draft=true', { + data: { _status: 'draft', layout: [{ blockType: 'hero', heading: secret }], slug: uniqueSlug('secret'), title: secret }, + }) + const { doc } = (await created.json()) as { doc: { id: number } } + + // The action id comes from a real call that the admin makes. + const actionRequest = page.waitForRequest((r) => Boolean(r.headers()['rsc-action-id']?.endsWith('#serverFunction'))) + await gotoAdmin(page, `/admin/collections/pages/${doc.id}`) + await page.fill('#field-title', `${secret} edited`) + const actionId = (await actionRequest).headers()['rsc-action-id']! + + for (const [name, args] of dataFunctions('pages', doc.id)) { + const asAdmin = await (await callServerFunction(page.request, actionId, name, args)).text() + const anonymous = await (await callServerFunction(request, actionId, name, args)).text() + // The admin call proves that the request is well formed and reaches the data. + if (name !== 'form-state') { + expect(asAdmin, `${name} as admin`).toContain(secret) + } + expect(anonymous, `${name} without a session`).not.toContain(secret) + } +}) diff --git a/docs/COMPAT.md b/docs/COMPAT.md index fa48547..3408eda 100644 --- a/docs/COMPAT.md +++ b/docs/COMPAT.md @@ -7,7 +7,7 @@ Payload `4.0.0-canary.39` · React Router `8.4.0` (RSC Framework Mode) · `@vite | Bench | Where | Status | | --- | --- | --- | -| **Adapter bench**: 35 Playwright tests on [`demo/`](../demo) | this repo, CI job `e2e` | ✅ run, both modes | +| **Adapter bench**: 36 Playwright tests on [`demo/`](../demo) | this repo, CI job `e2e` | ✅ run, both modes | | Adapter unit tests: 15 `node:test` cases, `test/` | this repo, CI job `checks` | ✅ run | | **Payload's own e2e suites** (`test/*/e2e.spec.ts`, as the TanStack adapter runs them) | Payload monorepo only | ❌ **not run** | @@ -22,8 +22,8 @@ measured**. The tables below compare scenarios, not suite pass rates. | Mode | Command | Result | Duration | | --- | --- | --- | --- | -| production | `react-router build` + `react-router-serve` | **35 / 35** | 19 s | -| development | `vite dev` | **35 / 35** | 35 s | +| production | `react-router build` + `react-router-serve` | **36 / 36** | 19 s | +| development | `vite dev` | **36 / 36** | 36 s | Each run starts on an empty SQLite database. In production the schema comes from the committed migrations (`prodMigrations`); in development it is pushed. @@ -56,7 +56,8 @@ committed migrations (`prodMigrations`); in development it is pushed. | 24 | validation | call-to-action links: site path or http(s) only (`javascript:`, `//host`, `/\host` refused) | ✅ | ✅ | | 25 | validation | page slugs: kebab-case, not `admin`, `api` or `posts` | ✅ | ✅ | | 26 | website | nav lists every published page, past Payload's default limit of 10 | ✅ | ✅ | -| 27–35 | REST / GraphQL | paginated find; login `Set-Cookie` (HttpOnly) + `/me` + logout; access control for anonymous reads; create/update/delete; 400 validation; 404 unknown route; globals + `?locale`; GraphQL query; CORS preflight | ✅ | ✅ | +| 27 | security (P9) | data server functions called from a public route without a session return no data; the same call with a session does | ✅ | ✅ | +| 28–36 | REST / GraphQL | paginated find; login `Set-Cookie` (HttpOnly) + `/me` + logout; access control for anonymous reads; create/update/delete; 400 validation; 404 unknown route; globals + `?locale`; GraphQL query; CORS preflight | ✅ | ✅ | The REST/GraphQL assertions check Payload's own response contract (`handleEndpoints` and the GraphQL handler are the same code Next.js and TanStack call). There is no Next app in this @@ -67,6 +68,27 @@ One difference between modes is expected and asserted: under `vite dev`, Vite's middleware answers `OPTIONS` (204) before React Router sees it; in production Payload answers (200). +## Server functions are public endpoints (NOTES P9) + +In React Router's RSC mode, a `'use server'` function is an endpoint that a POST to **any** +route can call with its `rsc-action-id`. Route middleware and loaders do not run before it, so +the adapter cannot gate it, and should not: `switch-language` and the create-first-user form +state must work without a session. Next.js server actions are public in the same way. + +Checked on 2026-10-09 against the production build: each of Payload's 15 server functions was +called with a session and without one, on an admin route, on the website home `/` and on a +404 route, with a draft page that holds a secret. + +| Function | Without a session | +| --- | --- | +| `form-state`, `table-state` | refused by Payload: "Unauthorized, you must be logged in" | +| `render-document`, `render-document-slots`, `render-list`, `render-field`, `render-tab`, `render-widget`, `get-dashboard-documents`, `get-default-layout`, `get-upcoming-scheduled-publish`, `copy-data-from-locale`, `schedule-publish`, `slugify` | throws before it returns data | +| `switch-language` | runs, by design: it sets the `payload-lng` cookie for the login page | + +With a session, `render-document`, `render-list`, `table-state` and `copy-data-from-locale` +returned the draft from `/`, which proves that the calls were well formed. No call without a +session returned data or wrote to the database. Scenario 27 keeps this checked in CI. + ## Not covered | Area (brief §3) | Why |