@delmaredigital/payload-puck 0.8.3 → 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 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'
@@ -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(query);
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(query);
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(query);
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
- // Check if slug already exists
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
- // Fetch versions for this page
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
- // Restore the version
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
  }, {
@@ -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';