@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.
@@ -9,6 +9,7 @@
9
9
  */ import { APIError } from 'payload';
10
10
  import { unsetHomepage, HomepageConflictError } from '../plugin/hooks/isHomepageUnique.js';
11
11
  import { resolveLocale } from '../utils/locale.js';
12
+ import { payloadErrorStatus } from '../utils/payloadErrors.js';
12
13
  import { mapRootPropsToPayloadFields, deepMerge } from '../api/utils/mapRootProps.js';
13
14
  /**
14
15
  * Merge a Puck save payload's `data` with the Payload fields derived from its
@@ -61,7 +62,7 @@ import { mapRootPropsToPayloadFields, deepMerge } from '../api/utils/mapRootProp
61
62
  return Response.json({
62
63
  error: error instanceof Error ? error.message : 'List failed'
63
64
  }, {
64
- status: 500
65
+ status: payloadErrorStatus(error) ?? 500
65
66
  });
66
67
  }
67
68
  };
@@ -103,7 +104,7 @@ import { mapRootPropsToPayloadFields, deepMerge } from '../api/utils/mapRootProp
103
104
  return Response.json({
104
105
  error: error instanceof Error ? error.message : 'Create failed'
105
106
  }, {
106
- status: 500
107
+ status: payloadErrorStatus(error) ?? 500
107
108
  });
108
109
  }
109
110
  };
@@ -144,7 +145,7 @@ import { mapRootPropsToPayloadFields, deepMerge } from '../api/utils/mapRootProp
144
145
  return Response.json({
145
146
  error: error instanceof Error ? error.message : 'Get failed'
146
147
  }, {
147
- status: 500
148
+ status: payloadErrorStatus(error) ?? 500
148
149
  });
149
150
  }
150
151
  };
@@ -262,7 +263,7 @@ import { mapRootPropsToPayloadFields, deepMerge } from '../api/utils/mapRootProp
262
263
  return Response.json({
263
264
  error: error instanceof Error ? error.message : 'Update failed'
264
265
  }, {
265
- status: 500
266
+ status: payloadErrorStatus(error) ?? 500
266
267
  });
267
268
  }
268
269
  };
@@ -297,7 +298,7 @@ import { mapRootPropsToPayloadFields, deepMerge } from '../api/utils/mapRootProp
297
298
  return Response.json({
298
299
  error: error instanceof Error ? error.message : 'Delete failed'
299
300
  }, {
300
- status: 500
301
+ status: payloadErrorStatus(error) ?? 500
301
302
  });
302
303
  }
303
304
  };
@@ -343,7 +344,7 @@ import { mapRootPropsToPayloadFields, deepMerge } from '../api/utils/mapRootProp
343
344
  return Response.json({
344
345
  error: error instanceof Error ? error.message : 'Versions failed'
345
346
  }, {
346
- status: 500
347
+ status: payloadErrorStatus(error) ?? 500
347
348
  });
348
349
  }
349
350
  };
@@ -390,7 +391,7 @@ import { mapRootPropsToPayloadFields, deepMerge } from '../api/utils/mapRootProp
390
391
  return Response.json({
391
392
  error: error instanceof Error ? error.message : 'Restore failed'
392
393
  }, {
393
- status: 500
394
+ status: payloadErrorStatus(error) ?? 500
394
395
  });
395
396
  }
396
397
  };
@@ -1,4 +1,11 @@
1
+ import { payloadErrorStatus } from '../utils/payloadErrors.js';
1
2
  /**
3
+ * Access control: every handler passes `overrideAccess: false` and `req` to
4
+ * Payload's local API, so the collection's own `access` rules and field-level
5
+ * access are enforced against the calling user. The `if (!req.user)` gate is
6
+ * authentication only — it is not a substitute for authorization, and relying on
7
+ * it alone was the bug in GHSA-rrx7-m589-5wfq.
8
+ */ /**
2
9
  * Collection slug for AI prompts
3
10
  * Matches the auto-generated collection from createPuckPlugin
4
11
  */ const COLLECTION = 'puck-ai-prompts';
@@ -18,6 +25,8 @@
18
25
  try {
19
26
  const result = await req.payload.find({
20
27
  collection: COLLECTION,
28
+ req,
29
+ overrideAccess: false,
21
30
  sort: 'order',
22
31
  limit: 100
23
32
  });
@@ -27,7 +36,7 @@
27
36
  return Response.json({
28
37
  error: e instanceof Error ? e.message : 'Failed to list prompts'
29
38
  }, {
30
- status: 500
39
+ status: payloadErrorStatus(e) ?? 500
31
40
  });
32
41
  }
33
42
  };
@@ -57,6 +66,8 @@
57
66
  }
58
67
  const doc = await req.payload.create({
59
68
  collection: COLLECTION,
69
+ req,
70
+ overrideAccess: false,
60
71
  data
61
72
  });
62
73
  return Response.json(doc);
@@ -65,7 +76,7 @@
65
76
  return Response.json({
66
77
  error: e instanceof Error ? e.message : 'Failed to create prompt'
67
78
  }, {
68
- status: 500
79
+ status: payloadErrorStatus(e) ?? 500
69
80
  });
70
81
  }
71
82
  };
@@ -103,6 +114,8 @@
103
114
  }
104
115
  const doc = await req.payload.update({
105
116
  collection: COLLECTION,
117
+ req,
118
+ overrideAccess: false,
106
119
  id,
107
120
  data
108
121
  });
@@ -112,7 +125,7 @@
112
125
  return Response.json({
113
126
  error: e instanceof Error ? e.message : 'Failed to update prompt'
114
127
  }, {
115
- status: 500
128
+ status: payloadErrorStatus(e) ?? 500
116
129
  });
117
130
  }
118
131
  };
@@ -141,6 +154,8 @@
141
154
  try {
142
155
  await req.payload.delete({
143
156
  collection: COLLECTION,
157
+ req,
158
+ overrideAccess: false,
144
159
  id
145
160
  });
146
161
  return Response.json({
@@ -151,7 +166,7 @@
151
166
  return Response.json({
152
167
  error: e instanceof Error ? e.message : 'Failed to delete prompt'
153
168
  }, {
154
- status: 500
169
+ status: payloadErrorStatus(e) ?? 500
155
170
  });
156
171
  }
157
172
  };
@@ -50,9 +50,16 @@ import { APIError } from 'payload';
50
50
  const collectionSlug = options.collectionSlug || collection.slug;
51
51
  // Use locale from context (passed by endpoint handler) or fall back to req.locale
52
52
  const locale = context?.locale || req.locale;
53
- // Query for existing homepage (excluding current document)
53
+ // Query for existing homepage (excluding current document).
54
+ //
55
+ // `overrideAccess: true` is deliberate. This hook runs *inside* an operation
56
+ // whose access Payload already checked, and it enforces a global invariant:
57
+ // only one page may be the homepage. Evaluating it against the editing user's
58
+ // read rules would let someone create a second homepage merely because they
59
+ // cannot see the first — a correctness bug, not a safeguard.
54
60
  const existingHomepage = await req.payload.find({
55
61
  collection: collectionSlug,
62
+ overrideAccess: true,
56
63
  ...locale ? {
57
64
  locale: locale.toString()
58
65
  } : {},
@@ -91,8 +98,12 @@ import { APIError } from 'payload';
91
98
  * Unsets isHomepage on the specified page.
92
99
  * Used when swapping homepages.
93
100
  */ export async function unsetHomepage(payload, collectionSlug, pageId, locale) {
101
+ // Deliberate, for the same reason as the uniqueness query above: unsetting the
102
+ // previous homepage is invariant maintenance on behalf of an already-authorized
103
+ // edit, not a user-initiated write to that document.
94
104
  await payload.update({
95
105
  collection: collectionSlug,
106
+ overrideAccess: true,
96
107
  id: pageId,
97
108
  data: {
98
109
  isHomepage: false
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Mapping Payload errors onto HTTP status codes.
3
+ *
4
+ * Payload's `APIError` (and its subclasses `Forbidden`, `NotFound`,
5
+ * `ValidationError`, ...) carries the status the response should use. Handlers
6
+ * that flatten everything to 500 turn an authorization *denial* into a server
7
+ * *error*, which hides the access-control layer working correctly, breaks client
8
+ * error handling, and pollutes error monitoring. That matters much more now that
9
+ * access control is actually enforced on these routes.
10
+ */
11
+ /**
12
+ * Extract the HTTP status Payload intends for an error.
13
+ *
14
+ * The `instanceof` check is the fast path. The structural fallback exists
15
+ * because `payload` can legitimately be resolved more than once in a monorepo or
16
+ * under pnpm's linking, and a duplicated class identity would silently defeat
17
+ * `instanceof` — producing exactly the kind of environment-dependent flakiness
18
+ * this mapping is meant to remove.
19
+ *
20
+ * @returns the status code, or `null` if this is not a Payload API error.
21
+ */
22
+ export declare function payloadErrorStatus(error: unknown): number | null;
23
+ /**
24
+ * True when Payload rejected the operation because access control denied it.
25
+ *
26
+ * Used to keep denial responses free of internal detail — a 403 body should not
27
+ * describe which rule failed.
28
+ */
29
+ export declare function isForbiddenError(error: unknown): boolean;
30
+ /**
31
+ * Turn a Payload client error (4xx) into the response it should have produced.
32
+ *
33
+ * Returns `null` for anything that is not a Payload 4xx — including genuine 5xx
34
+ * faults — so callers fall through to their existing generic handling. Call it
35
+ * *after* any bespoke handling (`ValidationError`, `HomepageConflictError`) so
36
+ * those keep precedence.
37
+ *
38
+ * Payload marks user-facing errors with `isPublic`. Messages from errors that
39
+ * are not public are replaced with a generic one, so internal detail is never
40
+ * echoed back to the caller.
41
+ */
42
+ export declare function payloadErrorResponse(error: unknown): Response | null;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Mapping Payload errors onto HTTP status codes.
3
+ *
4
+ * Payload's `APIError` (and its subclasses `Forbidden`, `NotFound`,
5
+ * `ValidationError`, ...) carries the status the response should use. Handlers
6
+ * that flatten everything to 500 turn an authorization *denial* into a server
7
+ * *error*, which hides the access-control layer working correctly, breaks client
8
+ * error handling, and pollutes error monitoring. That matters much more now that
9
+ * access control is actually enforced on these routes.
10
+ */ import { APIError } from 'payload';
11
+ /**
12
+ * Extract the HTTP status Payload intends for an error.
13
+ *
14
+ * The `instanceof` check is the fast path. The structural fallback exists
15
+ * because `payload` can legitimately be resolved more than once in a monorepo or
16
+ * under pnpm's linking, and a duplicated class identity would silently defeat
17
+ * `instanceof` — producing exactly the kind of environment-dependent flakiness
18
+ * this mapping is meant to remove.
19
+ *
20
+ * @returns the status code, or `null` if this is not a Payload API error.
21
+ */ export function payloadErrorStatus(error) {
22
+ if (error instanceof APIError && typeof error.status === 'number') {
23
+ return error.status;
24
+ }
25
+ if (error instanceof Error) {
26
+ const candidate = error;
27
+ if (typeof candidate.status === 'number' && candidate.status >= 400 && candidate.status <= 599 && typeof candidate.isOperational === 'boolean') {
28
+ return candidate.status;
29
+ }
30
+ }
31
+ return null;
32
+ }
33
+ /**
34
+ * True when Payload rejected the operation because access control denied it.
35
+ *
36
+ * Used to keep denial responses free of internal detail — a 403 body should not
37
+ * describe which rule failed.
38
+ */ export function isForbiddenError(error) {
39
+ return payloadErrorStatus(error) === 403;
40
+ }
41
+ const CLIENT_ERROR_MESSAGES = {
42
+ 400: 'Bad request',
43
+ 401: 'Unauthorized',
44
+ 403: 'Forbidden',
45
+ 404: 'Not found',
46
+ 409: 'Conflict'
47
+ };
48
+ /**
49
+ * Turn a Payload client error (4xx) into the response it should have produced.
50
+ *
51
+ * Returns `null` for anything that is not a Payload 4xx — including genuine 5xx
52
+ * faults — so callers fall through to their existing generic handling. Call it
53
+ * *after* any bespoke handling (`ValidationError`, `HomepageConflictError`) so
54
+ * those keep precedence.
55
+ *
56
+ * Payload marks user-facing errors with `isPublic`. Messages from errors that
57
+ * are not public are replaced with a generic one, so internal detail is never
58
+ * echoed back to the caller.
59
+ */ export function payloadErrorResponse(error) {
60
+ const status = payloadErrorStatus(error);
61
+ if (status === null || status < 400 || status >= 500) return null;
62
+ const isPublic = error instanceof Error && error.isPublic === true;
63
+ const message = isPublic && error.message ? error.message : CLIENT_ERROR_MESSAGES[status] ?? 'Request failed';
64
+ return Response.json({
65
+ error: message
66
+ }, {
67
+ status
68
+ });
69
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.8.2";
1
+ export declare const VERSION = "0.9.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/generate-version.js - do not edit manually
2
- export const VERSION = '0.8.2';
2
+ export const VERSION = '0.9.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delmaredigital/payload-puck",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "Puck visual page builder plugin for Payload CMS",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -145,37 +145,37 @@
145
145
  "@puckeditor/plugin-ai": "^0.8.2",
146
146
  "@puckeditor/plugin-heading-analyzer": "^0.23.0",
147
147
  "@radix-ui/react-popover": "^1.1.15",
148
- "@tiptap/core": "^3.20.1",
149
- "@tiptap/extension-color": "^3.20.1",
150
- "@tiptap/extension-highlight": "^3.20.1",
151
- "@tiptap/extension-link": "^3.20.1",
152
- "@tiptap/extension-subscript": "^3.20.1",
153
- "@tiptap/extension-superscript": "^3.20.1",
154
- "@tiptap/extension-text-align": "^3.20.1",
155
- "@tiptap/extension-text-style": "^3.20.1",
156
- "@tiptap/extension-underline": "^3.20.1",
157
- "@tiptap/react": "^3.20.1",
158
- "@tiptap/starter-kit": "^3.20.1",
159
- "html-react-parser": "^5.2.17",
160
- "lucide-react": "^0.469.0"
148
+ "@tiptap/core": "^3.29.2",
149
+ "@tiptap/extension-color": "^3.29.2",
150
+ "@tiptap/extension-highlight": "^3.29.2",
151
+ "@tiptap/extension-link": "^3.29.2",
152
+ "@tiptap/extension-subscript": "^3.29.2",
153
+ "@tiptap/extension-superscript": "^3.29.2",
154
+ "@tiptap/extension-text-align": "^3.29.2",
155
+ "@tiptap/extension-text-style": "^3.29.2",
156
+ "@tiptap/extension-underline": "^3.29.2",
157
+ "@tiptap/react": "^3.29.2",
158
+ "@tiptap/starter-kit": "^3.29.2",
159
+ "html-react-parser": "^6.1.5",
160
+ "lucide-react": "^1.30.0"
161
161
  },
162
162
  "devDependencies": {
163
163
  "@payloadcms/next": "^3.87.1",
164
164
  "@payloadcms/ui": "^3.87.1",
165
165
  "@puckeditor/core": "^0.23.0",
166
- "@swc/cli": "^0.6.0",
167
- "@swc/core": "^1.15.18",
168
- "@types/node": "^24.12.0",
169
- "@types/react": "^19.2.14",
170
- "@types/react-dom": "^19.2.3",
171
- "@vitest/coverage-v8": "^3.2.7",
166
+ "@swc/cli": "^0.8.1",
167
+ "@swc/core": "^1.15.47",
168
+ "@types/node": "^26.1.2",
169
+ "@types/react": "^19.2.18",
170
+ "@types/react-dom": "^19.2.4",
171
+ "@vitest/coverage-v8": "^4.1.10",
172
172
  "copyfiles": "^2.4.1",
173
173
  "next": "^16.3.0",
174
174
  "payload": "^3.87.1",
175
- "react": "^19.2.4",
176
- "react-dom": "^19.2.4",
175
+ "react": "^19.2.8",
176
+ "react-dom": "^19.2.8",
177
177
  "typescript": "^5.9.3",
178
- "vitest": "^3.2.7"
178
+ "vitest": "^4.1.10"
179
179
  },
180
180
  "keywords": [
181
181
  "payload",