@delmaredigital/payload-puck 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/ai/plugins/promptApiRoutes.d.ts +8 -0
- package/dist/ai/plugins/promptApiRoutes.js +34 -4
- package/dist/ai/tools/index.js +22 -8
- package/dist/api/createPuckApiRoutes.js +49 -1
- package/dist/api/createPuckApiRoutesVersions.js +25 -2
- package/dist/api/createPuckApiRoutesWithId.js +31 -0
- package/dist/api/index.d.ts +3 -1
- package/dist/api/index.js +2 -0
- package/dist/api/types.d.ts +101 -14
- package/dist/api/utils/access.d.ts +137 -0
- package/dist/api/utils/access.js +180 -0
- package/dist/endpoints/ai.js +17 -1
- package/dist/endpoints/context.js +19 -4
- package/dist/endpoints/index.js +8 -7
- package/dist/endpoints/prompts.js +19 -4
- package/dist/plugin/hooks/isHomepageUnique.js +12 -1
- package/dist/utils/payloadErrors.d.ts +42 -0
- package/dist/utils/payloadErrors.js +69 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +23 -23
package/README.md
CHANGED
|
@@ -10,6 +10,15 @@ A PayloadCMS plugin for integrating [Puck](https://puckeditor.com) visual page b
|
|
|
10
10
|
<a href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fdelmaredigital%2Fdd-starter&project-name=my-payload-site&build-command=pnpm%20run%20ci&env=PAYLOAD_SECRET,BETTER_AUTH_SECRET&stores=%5B%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22productSlug%22%3A%22neon%22%2C%22integrationSlug%22%3A%22neon%22%7D%2C%7B%22type%22%3A%22blob%22%7D%5D"><img src="https://vercel.com/button" alt="Deploy with Vercel" height="32"></a>
|
|
11
11
|
</p>
|
|
12
12
|
|
|
13
|
+
> 🔒 **Upgrading to 0.9? Security release — action required if you use the standalone route factories.**
|
|
14
|
+
>
|
|
15
|
+
> - **`createPuckApiRoutes`, `createPuckApiRoutesWithId`, `createPuckApiRoutesVersions` and `createPromptApiRoutes` now enforce Payload collection access control** ([GHSA-957g-hmmp-rchg](https://github.com/delmaredigital/payload-puck/security/advisories/GHSA-957g-hmmp-rchg)). They previously called Payload's Local API with the default `overrideAccess: true`, so collection and field `access` rules were **never evaluated** — any caller your `authenticate` hook accepted could read drafts and version history, restore versions over live content, publish, create and delete.
|
|
16
|
+
> - **Action:** build `authenticate` on `payload.auth({ headers: request.headers })`. That works for **every** auth system — Better Auth, Clerk, NextAuth, custom strategies — and needs no other change. Do **not** return your auth library's session user, and do **not** map it back by email. Full steps in [Upgrading to 0.9.0](#upgrading-to-090-breaking-security).
|
|
17
|
+
> - **Not affected:** the built-in `/api/puck/*` endpoints registered by `createPuckPlugin()`. Those were fixed in 0.6.23. If you never wired the standalone factories yourself, this release needs nothing from you.
|
|
18
|
+
> - Also fixed: the AI context/prompts endpoints ([GHSA-rrx7-m589-5wfq](https://github.com/delmaredigital/payload-puck/security/advisories/GHSA-rrx7-m589-5wfq)) and the AI tools, which queried Payload unfiltered.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
13
22
|
> 🎨 **Upgrading to 0.8?** The editor stylesheet is now built by your app, not by this plugin.
|
|
14
23
|
>
|
|
15
24
|
> - **`editorStylesheet`, `editorStylesheetCompiled` and `editorStylesheetUrls` are replaced by a single `editorStylesheets: string[]`** — an ordered list of URLs the editor iframe loads. The `/api/puck/styles` endpoint, the `/next` entry point and its `withPuckCSS()` wrapper, and the `postcss` / `postcss-load-config` peer dependencies are all removed.
|
|
@@ -49,10 +58,50 @@ pnpm add @delmaredigital/payload-puck @puckeditor/core
|
|
|
49
58
|
| `next` | >= 15.4.8 (see security note below) |
|
|
50
59
|
| `react` | >= 19.2.1 |
|
|
51
60
|
|
|
61
|
+
> **Using [`@delmaredigital/payload-better-auth`](https://github.com/delmaredigital/payload-better-auth)?** Any published version works — its auth strategy stamps `collection` on the user, which is what 0.9's access resolution needs. **0.9.0 or later is recommended:** it is the first release where the recommended `payload.auth()` wiring, combined with the request headers 0.9 now forwards into Payload, is free of side effects for API-key requests. Current is `0.11.3`, which requires Better Auth `1.7`.
|
|
62
|
+
|
|
52
63
|
> **Note:** Puck 0.21+ moved from `@measured/puck` to `@puckeditor/core`. This plugin requires the new package scope.
|
|
53
64
|
|
|
54
65
|
> **Security:** If your app uses Next.js middleware (or proxy.ts) to protect dynamic routes, use `next` >= 15.5.16 / 16.2.5 to pick up the fix for [CVE-2026-44574](https://github.com/vercel/next.js/security/advisories/GHSA-492v-c6pp-mqqv) (middleware bypass via dynamic route parameter injection). Turbopack users need >= 15.5.18 / 16.2.6.
|
|
55
66
|
|
|
67
|
+
### Upgrading to 0.9.0 (breaking, security)
|
|
68
|
+
|
|
69
|
+
**Only affects apps that wired the standalone route factories from `@delmaredigital/payload-puck/api` or `/ai`.** The built-in `/api/puck/*` endpoints are unchanged.
|
|
70
|
+
|
|
71
|
+
These factories now pass `overrideAccess: false` to Payload, so your collection `access` rules are enforced. To do that they must know **which Payload user** a request acts as. Build `authenticate` on `payload.auth()`:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { getPayload } from 'payload'
|
|
75
|
+
import config from '@payload-config'
|
|
76
|
+
|
|
77
|
+
export const { GET, POST } = createPuckApiRoutes({
|
|
78
|
+
collection: 'pages',
|
|
79
|
+
payloadConfig: config,
|
|
80
|
+
auth: {
|
|
81
|
+
authenticate: async (request) => {
|
|
82
|
+
const payload = await getPayload({ config })
|
|
83
|
+
// Runs whatever auth strategies your Payload config registers.
|
|
84
|
+
const { user } = await payload.auth({ headers: request.headers })
|
|
85
|
+
if (!user) return { authenticated: false }
|
|
86
|
+
return { authenticated: true, user }
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
This is the recommended wiring for **every** auth system, not just Payload's own. If you use Better Auth, keep `strategies: [betterAuthStrategy()]` on your users collection — that plus the snippet above is the whole integration.
|
|
93
|
+
|
|
94
|
+
**Do not** return your auth library's session user (`auth.api.getSession()`, `getServerSession()`, `getServerUser()`, a decoded JWT). Those carry no `collection`, and these routes now fail closed on them with a `500` / `PUCK_ACCESS_MISCONFIGURED` and **no** database operation, with the fix printed to your server log.
|
|
95
|
+
|
|
96
|
+
**Do not** map a session back to a user by email either — that is worse than the 500. A bare collection row silently drops the fields your auth strategy decorates onto the user; with `payload-better-auth` you lose `activeOrganizationId`, `organizationRole`, `apiKeyScopes` and `oauthScopes`, so an API-key caller can be judged as an ordinary session and an org-scoped rule evaluated with no organization at all.
|
|
97
|
+
|
|
98
|
+
Two smaller behaviour changes fall out of this:
|
|
99
|
+
|
|
100
|
+
- **Denials are now `403`,** not `500`. A Payload `Forbidden` previously arrived as a server error, indistinguishable from an outage. `404` and `409` are likewise passed through.
|
|
101
|
+
- **Request headers now reach your access rules.** They are forwarded into every Payload operation, so a rule that inspects them — an API-key scope check, say — sees the same request the REST API would.
|
|
102
|
+
|
|
103
|
+
If you are mid-migration and need to ship, `dangerouslyDisableCollectionAccessControl: true` restores the old behaviour and logs a warning once per factory. It leaves you with the vulnerability; treat it as a rollback, not a setting.
|
|
104
|
+
|
|
56
105
|
### Upgrading to 0.8.0 (breaking)
|
|
57
106
|
|
|
58
107
|
**Editor CSS is now built by your app, not by this plugin.** Three options collapse into one, and `withPuckCSS` is gone.
|
|
@@ -17,6 +17,14 @@ export interface PromptApiRoutesConfig {
|
|
|
17
17
|
* @default 'puck-ai-prompts'
|
|
18
18
|
*/
|
|
19
19
|
collection?: string;
|
|
20
|
+
/**
|
|
21
|
+
* **SECURITY — see `PuckApiRoutesConfig.dangerouslyDisableCollectionAccessControl`.**
|
|
22
|
+
* Restores the pre-0.9.0 behaviour in which Payload collection access rules
|
|
23
|
+
* were not enforced on these routes.
|
|
24
|
+
*
|
|
25
|
+
* @default false
|
|
26
|
+
*/
|
|
27
|
+
dangerouslyDisableCollectionAccessControl?: true;
|
|
20
28
|
}
|
|
21
29
|
/**
|
|
22
30
|
* Creates API route handlers for /api/puck/ai-prompts
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { getPayload } from 'payload';
|
|
2
|
+
import { createAccessResolver, accessMisconfigurationResponse } from '../../api/utils/access.js';
|
|
3
|
+
import { payloadErrorResponse } from '../../utils/payloadErrors.js';
|
|
2
4
|
/**
|
|
3
5
|
* Creates API route handlers for /api/puck/ai-prompts
|
|
4
6
|
*
|
|
@@ -36,12 +38,14 @@ import { getPayload } from 'payload';
|
|
|
36
38
|
* ```
|
|
37
39
|
*/ export function createPromptApiRoutes(config) {
|
|
38
40
|
const { payloadConfig, auth, collection = 'puck-ai-prompts' } = config;
|
|
41
|
+
// Resolves { overrideAccess, user } for every Payload call below.
|
|
42
|
+
const resolveAccess = createAccessResolver(config);
|
|
39
43
|
return {
|
|
40
44
|
GET: async (request)=>{
|
|
41
45
|
try {
|
|
42
46
|
// Authenticate
|
|
43
47
|
const authResult = await auth.authenticate(request);
|
|
44
|
-
if (!authResult.authenticated) {
|
|
48
|
+
if (!authResult.authenticated || !authResult.user) {
|
|
45
49
|
return new Response(JSON.stringify({
|
|
46
50
|
error: 'Unauthorized'
|
|
47
51
|
}), {
|
|
@@ -56,8 +60,10 @@ import { getPayload } from 'payload';
|
|
|
56
60
|
config: await payloadConfig
|
|
57
61
|
});
|
|
58
62
|
// Fetch prompts
|
|
63
|
+
const access = await resolveAccess(authResult, request);
|
|
59
64
|
const result = await payload.find({
|
|
60
65
|
collection,
|
|
66
|
+
...access(),
|
|
61
67
|
sort: 'order',
|
|
62
68
|
limit: 100
|
|
63
69
|
});
|
|
@@ -68,6 +74,10 @@ import { getPayload } from 'payload';
|
|
|
68
74
|
}
|
|
69
75
|
});
|
|
70
76
|
} catch (error) {
|
|
77
|
+
const misconfigured = accessMisconfigurationResponse(error);
|
|
78
|
+
if (misconfigured) return misconfigured;
|
|
79
|
+
const mapped = payloadErrorResponse(error);
|
|
80
|
+
if (mapped) return mapped;
|
|
71
81
|
console.error('[AI Prompts] Error fetching prompts:', error);
|
|
72
82
|
return new Response(JSON.stringify({
|
|
73
83
|
error: 'Failed to fetch prompts'
|
|
@@ -83,7 +93,7 @@ import { getPayload } from 'payload';
|
|
|
83
93
|
try {
|
|
84
94
|
// Authenticate
|
|
85
95
|
const authResult = await auth.authenticate(request);
|
|
86
|
-
if (!authResult.authenticated) {
|
|
96
|
+
if (!authResult.authenticated || !authResult.user) {
|
|
87
97
|
return new Response(JSON.stringify({
|
|
88
98
|
error: 'Unauthorized'
|
|
89
99
|
}), {
|
|
@@ -100,8 +110,10 @@ import { getPayload } from 'payload';
|
|
|
100
110
|
// Parse body
|
|
101
111
|
const body = await request.json();
|
|
102
112
|
// Create prompt
|
|
113
|
+
const access = await resolveAccess(authResult, request);
|
|
103
114
|
const result = await payload.create({
|
|
104
115
|
collection,
|
|
116
|
+
...access(),
|
|
105
117
|
data: {
|
|
106
118
|
label: body.label,
|
|
107
119
|
prompt: body.prompt,
|
|
@@ -118,6 +130,10 @@ import { getPayload } from 'payload';
|
|
|
118
130
|
}
|
|
119
131
|
});
|
|
120
132
|
} catch (error) {
|
|
133
|
+
const misconfigured = accessMisconfigurationResponse(error);
|
|
134
|
+
if (misconfigured) return misconfigured;
|
|
135
|
+
const mapped = payloadErrorResponse(error);
|
|
136
|
+
if (mapped) return mapped;
|
|
121
137
|
console.error('[AI Prompts] Error creating prompt:', error);
|
|
122
138
|
return new Response(JSON.stringify({
|
|
123
139
|
error: 'Failed to create prompt'
|
|
@@ -135,12 +151,14 @@ import { getPayload } from 'payload';
|
|
|
135
151
|
* Creates API route handlers for /api/puck/ai-prompts/[id]
|
|
136
152
|
*/ export function createPromptApiRoutesWithId(config) {
|
|
137
153
|
const { payloadConfig, auth, collection = 'puck-ai-prompts' } = config;
|
|
154
|
+
// Resolves { overrideAccess, user } for every Payload call below.
|
|
155
|
+
const resolveAccess = createAccessResolver(config);
|
|
138
156
|
return {
|
|
139
157
|
PATCH: async (request, context)=>{
|
|
140
158
|
try {
|
|
141
159
|
// Authenticate
|
|
142
160
|
const authResult = await auth.authenticate(request);
|
|
143
|
-
if (!authResult.authenticated) {
|
|
161
|
+
if (!authResult.authenticated || !authResult.user) {
|
|
144
162
|
return new Response(JSON.stringify({
|
|
145
163
|
error: 'Unauthorized'
|
|
146
164
|
}), {
|
|
@@ -160,8 +178,10 @@ import { getPayload } from 'payload';
|
|
|
160
178
|
// Parse body
|
|
161
179
|
const body = await request.json();
|
|
162
180
|
// Update prompt
|
|
181
|
+
const access = await resolveAccess(authResult, request);
|
|
163
182
|
const result = await payload.update({
|
|
164
183
|
collection,
|
|
184
|
+
...access(),
|
|
165
185
|
id,
|
|
166
186
|
data: {
|
|
167
187
|
...body.label !== undefined && {
|
|
@@ -187,6 +207,10 @@ import { getPayload } from 'payload';
|
|
|
187
207
|
}
|
|
188
208
|
});
|
|
189
209
|
} catch (error) {
|
|
210
|
+
const misconfigured = accessMisconfigurationResponse(error);
|
|
211
|
+
if (misconfigured) return misconfigured;
|
|
212
|
+
const mapped = payloadErrorResponse(error);
|
|
213
|
+
if (mapped) return mapped;
|
|
190
214
|
console.error('[AI Prompts] Error updating prompt:', error);
|
|
191
215
|
return new Response(JSON.stringify({
|
|
192
216
|
error: 'Failed to update prompt'
|
|
@@ -202,7 +226,7 @@ import { getPayload } from 'payload';
|
|
|
202
226
|
try {
|
|
203
227
|
// Authenticate
|
|
204
228
|
const authResult = await auth.authenticate(request);
|
|
205
|
-
if (!authResult.authenticated) {
|
|
229
|
+
if (!authResult.authenticated || !authResult.user) {
|
|
206
230
|
return new Response(JSON.stringify({
|
|
207
231
|
error: 'Unauthorized'
|
|
208
232
|
}), {
|
|
@@ -220,8 +244,10 @@ import { getPayload } from 'payload';
|
|
|
220
244
|
config: await payloadConfig
|
|
221
245
|
});
|
|
222
246
|
// Delete prompt
|
|
247
|
+
const access = await resolveAccess(authResult, request);
|
|
223
248
|
await payload.delete({
|
|
224
249
|
collection,
|
|
250
|
+
...access(),
|
|
225
251
|
id
|
|
226
252
|
});
|
|
227
253
|
return new Response(JSON.stringify({
|
|
@@ -233,6 +259,10 @@ import { getPayload } from 'payload';
|
|
|
233
259
|
}
|
|
234
260
|
});
|
|
235
261
|
} catch (error) {
|
|
262
|
+
const misconfigured = accessMisconfigurationResponse(error);
|
|
263
|
+
if (misconfigured) return misconfigured;
|
|
264
|
+
const mapped = payloadErrorResponse(error);
|
|
265
|
+
if (mapped) return mapped;
|
|
236
266
|
console.error('[AI Prompts] Error deleting prompt:', error);
|
|
237
267
|
return new Response(JSON.stringify({
|
|
238
268
|
error: 'Failed to delete prompt'
|
package/dist/ai/tools/index.js
CHANGED
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
if (!context?.payload) {
|
|
68
68
|
throw new Error('Payload instance not available in tool context');
|
|
69
69
|
}
|
|
70
|
-
const { payload } = context;
|
|
70
|
+
const { payload, user } = context;
|
|
71
71
|
const query = {
|
|
72
72
|
collection: slug,
|
|
73
73
|
limit: input.limit || 10
|
|
@@ -97,7 +97,11 @@
|
|
|
97
97
|
]
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
|
-
const result = await payload.find(
|
|
100
|
+
const result = await payload.find({
|
|
101
|
+
...query,
|
|
102
|
+
overrideAccess: false,
|
|
103
|
+
user
|
|
104
|
+
});
|
|
101
105
|
return result.docs;
|
|
102
106
|
}
|
|
103
107
|
};
|
|
@@ -117,7 +121,7 @@
|
|
|
117
121
|
if (!context?.payload) {
|
|
118
122
|
throw new Error('Payload instance not available in tool context');
|
|
119
123
|
}
|
|
120
|
-
const { payload } = context;
|
|
124
|
+
const { payload, user } = context;
|
|
121
125
|
const query = {
|
|
122
126
|
collection: mediaCollection,
|
|
123
127
|
limit: input.limit || 10
|
|
@@ -148,7 +152,11 @@
|
|
|
148
152
|
and: conditions
|
|
149
153
|
};
|
|
150
154
|
}
|
|
151
|
-
const result = await payload.find(
|
|
155
|
+
const result = await payload.find({
|
|
156
|
+
...query,
|
|
157
|
+
overrideAccess: false,
|
|
158
|
+
user
|
|
159
|
+
});
|
|
152
160
|
return result.docs.map((doc)=>({
|
|
153
161
|
id: doc.id,
|
|
154
162
|
url: doc.url,
|
|
@@ -173,7 +181,7 @@
|
|
|
173
181
|
if (!context?.payload) {
|
|
174
182
|
throw new Error('Payload instance not available in tool context');
|
|
175
183
|
}
|
|
176
|
-
const { payload } = context;
|
|
184
|
+
const { payload, user } = context;
|
|
177
185
|
const query = {
|
|
178
186
|
collection: config.pages,
|
|
179
187
|
limit: input.limit || 10
|
|
@@ -194,7 +202,11 @@
|
|
|
194
202
|
]
|
|
195
203
|
};
|
|
196
204
|
}
|
|
197
|
-
const result = await payload.find(
|
|
205
|
+
const result = await payload.find({
|
|
206
|
+
...query,
|
|
207
|
+
overrideAccess: false,
|
|
208
|
+
user
|
|
209
|
+
});
|
|
198
210
|
return result.docs.map((doc)=>({
|
|
199
211
|
id: doc.id,
|
|
200
212
|
title: doc.title,
|
|
@@ -215,9 +227,11 @@
|
|
|
215
227
|
if (!context?.payload) {
|
|
216
228
|
throw new Error('Payload instance not available in tool context');
|
|
217
229
|
}
|
|
218
|
-
const { payload } = context;
|
|
230
|
+
const { payload, user } = context;
|
|
219
231
|
return await payload.findGlobal({
|
|
220
|
-
slug: globalSlug
|
|
232
|
+
slug: globalSlug,
|
|
233
|
+
overrideAccess: false,
|
|
234
|
+
user
|
|
221
235
|
});
|
|
222
236
|
}
|
|
223
237
|
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import { getPayload } from 'payload';
|
|
3
|
+
import { createAccessResolver, accessMisconfigurationResponse } from './utils/access.js';
|
|
4
|
+
import { payloadErrorResponse } from '../utils/payloadErrors.js';
|
|
3
5
|
/**
|
|
4
6
|
* Default Puck data for new pages
|
|
5
7
|
*/ const DEFAULT_PUCK_DATA = {
|
|
@@ -40,6 +42,9 @@ import { getPayload } from 'payload';
|
|
|
40
42
|
* ```
|
|
41
43
|
*/ export function createPuckApiRoutes(routeConfig) {
|
|
42
44
|
const { collection = 'pages', payloadConfig, auth, defaultPuckData = DEFAULT_PUCK_DATA, enableDrafts = true, onError } = routeConfig;
|
|
45
|
+
// Resolves { overrideAccess, user } for every Payload call below. Built once
|
|
46
|
+
// so the opt-out warning is logged per factory, not per request.
|
|
47
|
+
const resolveAccess = createAccessResolver(routeConfig);
|
|
43
48
|
/**
|
|
44
49
|
* GET /api/puck/pages
|
|
45
50
|
* List all pages with optional filtering
|
|
@@ -112,8 +117,12 @@ import { getPayload } from 'payload';
|
|
|
112
117
|
const where = conditions.length > 0 ? conditions.length === 1 ? conditions[0] : {
|
|
113
118
|
and: conditions
|
|
114
119
|
} : undefined;
|
|
120
|
+
const access = await resolveAccess(authResult, request);
|
|
121
|
+
// Access control is evaluated by Payload, so the page list is filtered to
|
|
122
|
+
// documents the caller may actually read.
|
|
115
123
|
const result = await payload.find({
|
|
116
124
|
collection,
|
|
125
|
+
...access(),
|
|
117
126
|
page,
|
|
118
127
|
limit,
|
|
119
128
|
sort,
|
|
@@ -127,7 +136,13 @@ import { getPayload } from 'payload';
|
|
|
127
136
|
request
|
|
128
137
|
});
|
|
129
138
|
}
|
|
139
|
+
const misconfigured = accessMisconfigurationResponse(error);
|
|
140
|
+
if (misconfigured) return misconfigured;
|
|
130
141
|
console.error('Error listing pages:', error);
|
|
142
|
+
// An access denial is a 403, not a server fault. Mapping it keeps the
|
|
143
|
+
// access-control layer visible to clients and out of error monitoring.
|
|
144
|
+
const mapped = payloadErrorResponse(error);
|
|
145
|
+
if (mapped) return mapped;
|
|
131
146
|
return NextResponse.json({
|
|
132
147
|
error: 'Failed to list pages'
|
|
133
148
|
}, {
|
|
@@ -182,9 +197,15 @@ import { getPayload } from 'payload';
|
|
|
182
197
|
const payload = await getPayload({
|
|
183
198
|
config
|
|
184
199
|
});
|
|
185
|
-
|
|
200
|
+
const access = await resolveAccess(authResult, request);
|
|
201
|
+
// Check if slug already exists. This runs under access control, so a
|
|
202
|
+
// caller cannot use it to probe for the existence of documents they may
|
|
203
|
+
// not read. The trade-off is that an unreadable collision is not caught
|
|
204
|
+
// here — Payload's unique constraint catches it on create below, and the
|
|
205
|
+
// ValidationError handler maps it back to the same 409.
|
|
186
206
|
const existing = await payload.find({
|
|
187
207
|
collection,
|
|
208
|
+
...access(),
|
|
188
209
|
where: {
|
|
189
210
|
slug: {
|
|
190
211
|
equals: slug
|
|
@@ -213,6 +234,7 @@ import { getPayload } from 'payload';
|
|
|
213
234
|
// Create the page
|
|
214
235
|
const newPage = await payload.create({
|
|
215
236
|
collection,
|
|
237
|
+
...access(),
|
|
216
238
|
draft: enableDrafts,
|
|
217
239
|
data: {
|
|
218
240
|
title,
|
|
@@ -234,7 +256,33 @@ import { getPayload } from 'payload';
|
|
|
234
256
|
request
|
|
235
257
|
});
|
|
236
258
|
}
|
|
259
|
+
const misconfigured = accessMisconfigurationResponse(error);
|
|
260
|
+
if (misconfigured) return misconfigured;
|
|
237
261
|
console.error('Error creating page:', error);
|
|
262
|
+
// A unique-constraint failure on slug means the page exists but was not
|
|
263
|
+
// visible to the pre-check above under access control. Report the same 409
|
|
264
|
+
// the pre-check would have, rather than a generic 500.
|
|
265
|
+
if (error instanceof Error && error.name === 'ValidationError') {
|
|
266
|
+
const validationError = error;
|
|
267
|
+
const fieldErrors = validationError.data?.errors || [];
|
|
268
|
+
if (fieldErrors.some((e)=>e.field === 'slug')) {
|
|
269
|
+
return NextResponse.json({
|
|
270
|
+
error: 'A page with this slug already exists'
|
|
271
|
+
}, {
|
|
272
|
+
status: 409
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return NextResponse.json({
|
|
276
|
+
error: `Validation failed: ${fieldErrors.map((e)=>e.message || e.field).join(', ')}`,
|
|
277
|
+
details: fieldErrors
|
|
278
|
+
}, {
|
|
279
|
+
status: 400
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
// An access denial is a 403, not a server fault. Mapping it keeps the
|
|
283
|
+
// access-control layer visible to clients and out of error monitoring.
|
|
284
|
+
const mapped = payloadErrorResponse(error);
|
|
285
|
+
if (mapped) return mapped;
|
|
238
286
|
return NextResponse.json({
|
|
239
287
|
error: 'Failed to create page'
|
|
240
288
|
}, {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import { getPayload } from 'payload';
|
|
3
3
|
import { resolveLocaleFromNextRequest } from '../utils/locale.js';
|
|
4
|
+
import { createAccessResolver, accessMisconfigurationResponse } from './utils/access.js';
|
|
5
|
+
import { payloadErrorResponse } from '../utils/payloadErrors.js';
|
|
4
6
|
/**
|
|
5
7
|
* Create API route handlers for /api/puck/pages/[id]/versions
|
|
6
8
|
*
|
|
@@ -27,6 +29,9 @@ import { resolveLocaleFromNextRequest } from '../utils/locale.js';
|
|
|
27
29
|
* ```
|
|
28
30
|
*/ export function createPuckApiRoutesVersions(routeConfig) {
|
|
29
31
|
const { collection = 'pages', payloadConfig, auth, onError } = routeConfig;
|
|
32
|
+
// Resolves { overrideAccess, user } for every Payload call below. Built once
|
|
33
|
+
// so the opt-out warning is logged per factory, not per request.
|
|
34
|
+
const resolveAccess = createAccessResolver(routeConfig);
|
|
30
35
|
/**
|
|
31
36
|
* GET /api/puck/pages/[id]/versions
|
|
32
37
|
* Fetch version history for a page
|
|
@@ -72,9 +77,12 @@ import { resolveLocaleFromNextRequest } from '../utils/locale.js';
|
|
|
72
77
|
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
|
73
78
|
const page = parseInt(url.searchParams.get('page') || '1', 10);
|
|
74
79
|
const locale = resolveLocaleFromNextRequest(request);
|
|
75
|
-
|
|
80
|
+
const access = await resolveAccess(authResult, request);
|
|
81
|
+
// Fetch versions for this page. Access control is evaluated by Payload so
|
|
82
|
+
// a caller only ever sees versions of documents they may read.
|
|
76
83
|
const versions = await payload.findVersions({
|
|
77
84
|
collection,
|
|
85
|
+
...access(),
|
|
78
86
|
where: {
|
|
79
87
|
parent: {
|
|
80
88
|
equals: id
|
|
@@ -105,7 +113,13 @@ import { resolveLocaleFromNextRequest } from '../utils/locale.js';
|
|
|
105
113
|
pageId: params.id
|
|
106
114
|
});
|
|
107
115
|
}
|
|
116
|
+
const misconfigured = accessMisconfigurationResponse(error);
|
|
117
|
+
if (misconfigured) return misconfigured;
|
|
108
118
|
console.error('Error fetching versions:', error);
|
|
119
|
+
// An access denial is a 403, not a server fault. Mapping it keeps the
|
|
120
|
+
// access-control layer visible to clients and out of error monitoring.
|
|
121
|
+
const mapped = payloadErrorResponse(error);
|
|
122
|
+
if (mapped) return mapped;
|
|
109
123
|
return NextResponse.json({
|
|
110
124
|
error: 'Failed to fetch versions'
|
|
111
125
|
}, {
|
|
@@ -166,9 +180,12 @@ import { resolveLocaleFromNextRequest } from '../utils/locale.js';
|
|
|
166
180
|
const payload = await getPayload({
|
|
167
181
|
config
|
|
168
182
|
});
|
|
169
|
-
|
|
183
|
+
const access = await resolveAccess(authResult, request);
|
|
184
|
+
// Restore the version. Payload evaluates `update` access on the parent
|
|
185
|
+
// document, so restoring is gated by the same rules as editing it.
|
|
170
186
|
const restoredDoc = await payload.restoreVersion({
|
|
171
187
|
collection,
|
|
188
|
+
...access(),
|
|
172
189
|
id: versionId
|
|
173
190
|
});
|
|
174
191
|
return NextResponse.json({
|
|
@@ -183,7 +200,13 @@ import { resolveLocaleFromNextRequest } from '../utils/locale.js';
|
|
|
183
200
|
pageId: params.id
|
|
184
201
|
});
|
|
185
202
|
}
|
|
203
|
+
const misconfigured = accessMisconfigurationResponse(error);
|
|
204
|
+
if (misconfigured) return misconfigured;
|
|
186
205
|
console.error('Error restoring version:', error);
|
|
206
|
+
// An access denial is a 403, not a server fault. Mapping it keeps the
|
|
207
|
+
// access-control layer visible to clients and out of error monitoring.
|
|
208
|
+
const mapped = payloadErrorResponse(error);
|
|
209
|
+
if (mapped) return mapped;
|
|
187
210
|
return NextResponse.json({
|
|
188
211
|
error: 'Failed to restore version'
|
|
189
212
|
}, {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import { getPayload } from 'payload';
|
|
3
3
|
import { mapRootPropsToPayloadFields, deepMerge } from './utils/mapRootProps.js';
|
|
4
|
+
import { createAccessResolver, accessMisconfigurationResponse } from './utils/access.js';
|
|
5
|
+
import { payloadErrorResponse } from '../utils/payloadErrors.js';
|
|
4
6
|
/**
|
|
5
7
|
* Create API route handlers for /api/puck/pages/[id]
|
|
6
8
|
*
|
|
@@ -33,6 +35,9 @@ import { mapRootPropsToPayloadFields, deepMerge } from './utils/mapRootProps.js'
|
|
|
33
35
|
* ```
|
|
34
36
|
*/ export function createPuckApiRoutesWithId(routeConfig) {
|
|
35
37
|
const { collection = 'pages', payloadConfig, auth, rootPropsMapping, onError } = routeConfig;
|
|
38
|
+
// Resolves { overrideAccess, user } for every Payload call below. Built once
|
|
39
|
+
// so the opt-out warning is logged per factory, not per request.
|
|
40
|
+
const resolveAccess = createAccessResolver(routeConfig);
|
|
36
41
|
/**
|
|
37
42
|
* GET /api/puck/pages/[id]
|
|
38
43
|
* Fetch a single page by ID
|
|
@@ -77,8 +82,10 @@ import { mapRootPropsToPayloadFields, deepMerge } from './utils/mapRootProps.js'
|
|
|
77
82
|
// Default to draft=true for editor use (load latest draft)
|
|
78
83
|
const url = new URL(request.url);
|
|
79
84
|
const wantsDraft = url.searchParams.get('draft') !== 'false';
|
|
85
|
+
const access = await resolveAccess(authResult, request);
|
|
80
86
|
const page = await payload.findByID({
|
|
81
87
|
collection,
|
|
88
|
+
...access(),
|
|
82
89
|
id,
|
|
83
90
|
draft: wantsDraft
|
|
84
91
|
});
|
|
@@ -101,7 +108,13 @@ import { mapRootPropsToPayloadFields, deepMerge } from './utils/mapRootProps.js'
|
|
|
101
108
|
pageId: params.id
|
|
102
109
|
});
|
|
103
110
|
}
|
|
111
|
+
const misconfigured = accessMisconfigurationResponse(error);
|
|
112
|
+
if (misconfigured) return misconfigured;
|
|
104
113
|
console.error('Error fetching page:', error);
|
|
114
|
+
// An access denial is a 403, not a server fault. Mapping it keeps the
|
|
115
|
+
// access-control layer visible to clients and out of error monitoring.
|
|
116
|
+
const mapped = payloadErrorResponse(error);
|
|
117
|
+
if (mapped) return mapped;
|
|
105
118
|
return NextResponse.json({
|
|
106
119
|
error: 'Failed to fetch page'
|
|
107
120
|
}, {
|
|
@@ -179,9 +192,11 @@ import { mapRootPropsToPayloadFields, deepMerge } from './utils/mapRootProps.js'
|
|
|
179
192
|
});
|
|
180
193
|
// Handle homepage swap - if swapHomepage is true and isHomepage is being set,
|
|
181
194
|
// unset the existing homepage first
|
|
195
|
+
const access = await resolveAccess(authResult, request);
|
|
182
196
|
if (swapHomepage && isHomepage === true) {
|
|
183
197
|
const existingHomepage = await payload.find({
|
|
184
198
|
collection,
|
|
199
|
+
...access(),
|
|
185
200
|
where: {
|
|
186
201
|
and: [
|
|
187
202
|
{
|
|
@@ -202,6 +217,7 @@ import { mapRootPropsToPayloadFields, deepMerge } from './utils/mapRootProps.js'
|
|
|
202
217
|
if (existingHomepage.docs.length > 0) {
|
|
203
218
|
await payload.update({
|
|
204
219
|
collection,
|
|
220
|
+
...access(),
|
|
205
221
|
id: existingHomepage.docs[0].id,
|
|
206
222
|
data: {
|
|
207
223
|
isHomepage: false
|
|
@@ -255,6 +271,7 @@ import { mapRootPropsToPayloadFields, deepMerge } from './utils/mapRootProps.js'
|
|
|
255
271
|
// When draft=false or omitted, updates the main collection
|
|
256
272
|
const updateOptions = {
|
|
257
273
|
collection,
|
|
274
|
+
...access(),
|
|
258
275
|
id,
|
|
259
276
|
data: updateData
|
|
260
277
|
};
|
|
@@ -277,6 +294,8 @@ import { mapRootPropsToPayloadFields, deepMerge } from './utils/mapRootProps.js'
|
|
|
277
294
|
pageId: params.id
|
|
278
295
|
});
|
|
279
296
|
}
|
|
297
|
+
const misconfigured = accessMisconfigurationResponse(error);
|
|
298
|
+
if (misconfigured) return misconfigured;
|
|
280
299
|
console.error('Error updating page:', error);
|
|
281
300
|
// Handle Payload validation errors gracefully
|
|
282
301
|
if (error instanceof Error && error.name === 'ValidationError') {
|
|
@@ -301,6 +320,10 @@ import { mapRootPropsToPayloadFields, deepMerge } from './utils/mapRootProps.js'
|
|
|
301
320
|
status: 400
|
|
302
321
|
});
|
|
303
322
|
}
|
|
323
|
+
// An access denial is a 403, not a server fault. Mapping it keeps the
|
|
324
|
+
// access-control layer visible to clients and out of error monitoring.
|
|
325
|
+
const mapped = payloadErrorResponse(error);
|
|
326
|
+
if (mapped) return mapped;
|
|
304
327
|
return NextResponse.json({
|
|
305
328
|
error: 'Failed to update page'
|
|
306
329
|
}, {
|
|
@@ -348,8 +371,10 @@ import { mapRootPropsToPayloadFields, deepMerge } from './utils/mapRootProps.js'
|
|
|
348
371
|
const payload = await getPayload({
|
|
349
372
|
config
|
|
350
373
|
});
|
|
374
|
+
const access = await resolveAccess(authResult, request);
|
|
351
375
|
await payload.delete({
|
|
352
376
|
collection,
|
|
377
|
+
...access(),
|
|
353
378
|
id
|
|
354
379
|
});
|
|
355
380
|
return NextResponse.json({
|
|
@@ -364,7 +389,13 @@ import { mapRootPropsToPayloadFields, deepMerge } from './utils/mapRootProps.js'
|
|
|
364
389
|
pageId: params.id
|
|
365
390
|
});
|
|
366
391
|
}
|
|
392
|
+
const misconfigured = accessMisconfigurationResponse(error);
|
|
393
|
+
if (misconfigured) return misconfigured;
|
|
367
394
|
console.error('Error deleting page:', error);
|
|
395
|
+
// An access denial is a 403, not a server fault. Mapping it keeps the
|
|
396
|
+
// access-control layer visible to clients and out of error monitoring.
|
|
397
|
+
const mapped = payloadErrorResponse(error);
|
|
398
|
+
if (mapped) return mapped;
|
|
368
399
|
return NextResponse.json({
|
|
369
400
|
error: 'Failed to delete page'
|
|
370
401
|
}, {
|
package/dist/api/index.d.ts
CHANGED
|
@@ -35,5 +35,7 @@
|
|
|
35
35
|
export { createPuckApiRoutes } from './createPuckApiRoutes.js';
|
|
36
36
|
export { createPuckApiRoutesWithId } from './createPuckApiRoutesWithId.js';
|
|
37
37
|
export { createPuckApiRoutesVersions } from './createPuckApiRoutesVersions.js';
|
|
38
|
+
export { isPayloadUser, resolvePayloadUser, createAccessResolver, accessMisconfigurationResponse, PuckApiAccessError, } from './utils/access.js';
|
|
39
|
+
export type { PayloadAccessArgs, AccessResolverConfig } from './utils/access.js';
|
|
38
40
|
export { mapRootPropsToPayloadFields, mapPayloadFieldsToRootProps, DEFAULT_ROOT_PROPS_MAPPINGS, setNestedValue, getNestedValue, mergeMappings, deepMerge, } from './utils/mapRootProps.js';
|
|
39
|
-
export type { AuthenticatedUser, AuthResult, PermissionResult, PuckApiAuthHooks, RootPropsMapping, PuckApiRoutesConfig, ErrorContext, RouteHandler, RouteHandlerWithId, RouteHandlerContext, RouteHandlerWithIdContext, PuckApiRouteHandlers, PuckApiRouteWithIdHandlers, CreatePageBody, UpdatePageBody, ApiResponse, PageVersion, PuckApiVersionsRouteHandlers, } from './types.js';
|
|
41
|
+
export type { AuthenticatedUser, PayloadUser, AuthResult, PermissionResult, PuckApiAuthHooks, RootPropsMapping, PuckApiRoutesConfig, ErrorContext, RouteHandler, RouteHandlerWithId, RouteHandlerContext, RouteHandlerWithIdContext, PuckApiRouteHandlers, PuckApiRouteWithIdHandlers, CreatePageBody, UpdatePageBody, ApiResponse, PageVersion, PuckApiVersionsRouteHandlers, } from './types.js';
|
package/dist/api/index.js
CHANGED
|
@@ -35,5 +35,7 @@
|
|
|
35
35
|
export { createPuckApiRoutes } from './createPuckApiRoutes.js';
|
|
36
36
|
export { createPuckApiRoutesWithId } from './createPuckApiRoutesWithId.js';
|
|
37
37
|
export { createPuckApiRoutesVersions } from './createPuckApiRoutesVersions.js';
|
|
38
|
+
// Access control
|
|
39
|
+
export { isPayloadUser, resolvePayloadUser, createAccessResolver, accessMisconfigurationResponse, PuckApiAccessError } from './utils/access.js';
|
|
38
40
|
// Utilities
|
|
39
41
|
export { mapRootPropsToPayloadFields, mapPayloadFieldsToRootProps, DEFAULT_ROOT_PROPS_MAPPINGS, setNestedValue, getNestedValue, mergeMappings, deepMerge } from './utils/mapRootProps.js';
|