@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.
@@ -2,17 +2,54 @@ import type { NextRequest } from 'next/server';
2
2
  import type { Data as PuckData } from '@puckeditor/core';
3
3
  /**
4
4
  * Authenticated user from the auth system
5
+ *
6
+ * This is deliberately loose: `authenticate` may return a Better Auth session
7
+ * user, a NextAuth user, a decoded JWT payload, or a Payload user. It is used
8
+ * for the `canX` route-gating hooks.
9
+ *
10
+ * It is **not** what Payload evaluates access control against — see
11
+ * {@link PayloadUser}.
5
12
  */
6
13
  export interface AuthenticatedUser {
7
14
  id: string;
8
15
  [key: string]: unknown;
9
16
  }
17
+ /**
18
+ * A Payload user document, as Payload's access-control functions expect to
19
+ * receive it on `req.user`.
20
+ *
21
+ * The `collection` property is what distinguishes a real Payload user from an
22
+ * arbitrary session object — Payload stamps it onto the user during
23
+ * authentication (`BaseUser` in `payload/auth/types`). Access rules such as
24
+ * `({ req }) => req.user?.role === 'admin'` are evaluated against this document.
25
+ */
26
+ export interface PayloadUser {
27
+ id: string | number;
28
+ collection: string;
29
+ [key: string]: unknown;
30
+ }
10
31
  /**
11
32
  * Result of an authentication check
12
33
  */
13
34
  export interface AuthResult {
14
35
  authenticated: boolean;
15
36
  user?: AuthenticatedUser;
37
+ /**
38
+ * The Payload user document this request should act as when Payload evaluates
39
+ * collection and field access control.
40
+ *
41
+ * Set this when `authenticate` already knows the Payload user — it saves the
42
+ * route factory a second lookup via {@link PuckApiAuthHooks.toPayloadUser}.
43
+ *
44
+ * - A user document → access control is evaluated as that user.
45
+ * - `null` → access control is evaluated as an anonymous/public request.
46
+ * - Omitted (`undefined`) → the factory falls back to `toPayloadUser`, then to
47
+ * `user` if it is structurally a Payload user, and otherwise fails closed.
48
+ *
49
+ * If `authenticate` returns a Payload user as `user` (i.e. the result of
50
+ * `payload.auth()`), you do not need to set this at all.
51
+ */
52
+ payloadUser?: PayloadUser | null;
16
53
  error?: string;
17
54
  }
18
55
  /**
@@ -30,53 +67,86 @@ export interface PermissionResult {
30
67
  *
31
68
  * @example
32
69
  * ```typescript
33
- * // Example with Better Auth
70
+ * // The recommended wiring for EVERY auth system, including Better Auth,
71
+ * // NextAuth and Clerk. `payload.auth()` runs whatever auth strategies your
72
+ * // Payload config registers and returns a real Payload user — already carrying
73
+ * // `collection` plus any fields the strategy decorates onto it.
34
74
  * const authHooks: PuckApiAuthHooks = {
35
75
  * authenticate: async (request) => {
36
- * const session = await auth.api.getSession({ headers: await headers() })
37
- * if (!session?.user) return { authenticated: false }
38
- * return { authenticated: true, user: session.user }
39
- * },
40
- * canEdit: async (user, pageId) => {
41
- * return { allowed: hasRole(user, 'editor') }
76
+ * const payload = await getPayload({ config })
77
+ * const { user } = await payload.auth({ headers: request.headers })
78
+ * if (!user) return { authenticated: false }
79
+ * return { authenticated: true, user }
42
80
  * },
81
+ * canPublish: async (user) => ({ allowed: user.role === 'admin' }),
43
82
  * }
44
83
  * ```
45
- */
84
+ *
85
+ * Do **not** call your auth library directly and return its session user
86
+ * (`auth.api.getSession()`, `getServerSession()`, a decoded JWT). Those objects
87
+ * are not Payload users, and mapping one back by email returns the bare row —
88
+ * silently dropping the tenant and scope context your access rules read. With
89
+ * Better Auth that means losing `activeOrganizationId`, `organizationRole`,
90
+ * `apiKeyScopes` and `oauthScopes`, and an API-key caller can then be judged as
91
+ * an ordinary session.
92
+ */
46
93
  export interface PuckApiAuthHooks {
47
94
  /**
48
95
  * Authenticate the incoming request
49
96
  * Should return the authenticated user or authentication failure
50
97
  */
51
98
  authenticate: (request: NextRequest) => Promise<AuthResult>;
99
+ /**
100
+ * Escape hatch for callers that have **no** corresponding Payload user.
101
+ *
102
+ * Prefer building `authenticate` on `payload.auth({ headers })`, which returns
103
+ * a Payload user directly and needs none of this. Reach for `toPayloadUser`
104
+ * only when your caller genuinely cannot be resolved that way.
105
+ *
106
+ * Whatever you return *is* the principal Payload evaluates access control
107
+ * against, so it must carry every field your access rules read. Looking the
108
+ * user up by email and returning the bare collection row is the common
109
+ * mistake: it drops the fields an auth strategy decorates onto the user, and
110
+ * access rules that read them will reach the wrong decision.
111
+ *
112
+ * Return `null` to deliberately evaluate access control as an anonymous
113
+ * (public) request.
114
+ */
115
+ toPayloadUser?: (user: AuthenticatedUser, request: NextRequest) => Promise<PayloadUser | null> | PayloadUser | null;
52
116
  /**
53
117
  * Check if user can list pages
54
- * @default Always allowed for authenticated users
118
+ *
119
+ * This is coarse route gating layered *on top of* Payload's collection access
120
+ * rules, which are always enforced (unless explicitly disabled via
121
+ * `dangerouslyDisableCollectionAccessControl`). Omitting it does not grant
122
+ * access Payload itself would deny.
123
+ *
124
+ * @default No additional restriction beyond Payload collection access
55
125
  */
56
126
  canList?: (user: AuthenticatedUser) => Promise<PermissionResult> | PermissionResult;
57
127
  /**
58
128
  * Check if user can view a specific page
59
- * @default Always allowed for authenticated users
129
+ * @default No additional restriction beyond Payload collection access
60
130
  */
61
131
  canView?: (user: AuthenticatedUser, pageId: string) => Promise<PermissionResult> | PermissionResult;
62
132
  /**
63
133
  * Check if user can create new pages
64
- * @default Always allowed for authenticated users
134
+ * @default No additional restriction beyond Payload collection access
65
135
  */
66
136
  canCreate?: (user: AuthenticatedUser) => Promise<PermissionResult> | PermissionResult;
67
137
  /**
68
138
  * Check if user can edit a specific page
69
- * @default Always allowed for authenticated users
139
+ * @default No additional restriction beyond Payload collection access
70
140
  */
71
141
  canEdit?: (user: AuthenticatedUser, pageId: string) => Promise<PermissionResult> | PermissionResult;
72
142
  /**
73
143
  * Check if user can publish a specific page (change status to published)
74
- * @default Same as canEdit
144
+ * @default Falls back to canEdit, then to Payload collection access
75
145
  */
76
146
  canPublish?: (user: AuthenticatedUser, pageId: string) => Promise<PermissionResult> | PermissionResult;
77
147
  /**
78
148
  * Check if user can delete a specific page
79
- * @default Always allowed for authenticated users
149
+ * @default No additional restriction beyond Payload collection access
80
150
  */
81
151
  canDelete?: (user: AuthenticatedUser, pageId: string) => Promise<PermissionResult> | PermissionResult;
82
152
  }
@@ -187,6 +257,23 @@ export interface PuckApiRoutesConfig {
187
257
  * Custom error handler for logging/monitoring
188
258
  */
189
259
  onError?: (error: unknown, context: ErrorContext) => void;
260
+ /**
261
+ * **SECURITY — do not enable without understanding the consequence.**
262
+ *
263
+ * Restores the pre-0.9.0 behaviour in which these routes called Payload's
264
+ * Local API with `overrideAccess: true`, so collection `access` rules and
265
+ * field-level access are **not** evaluated. Your `canX` hooks become the only
266
+ * authorization in front of read, create, update, publish, delete and version
267
+ * restore.
268
+ *
269
+ * This was the vulnerability described in GHSA-957g-hmmp-rchg. The only
270
+ * legitimate reason to set it is an emergency rollback while you wire up
271
+ * {@link PuckApiAuthHooks.toPayloadUser}. Setting it logs a warning once per
272
+ * route factory.
273
+ *
274
+ * @default false
275
+ */
276
+ dangerouslyDisableCollectionAccessControl?: true;
190
277
  }
191
278
  /**
192
279
  * Context passed to Next.js App Router route handlers
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Access-control resolution for the standalone Next.js route factories.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Payload's Local API defaults to `overrideAccess: true`, which skips collection
7
+ * and field access control entirely. The route factories in `src/api/` call the
8
+ * Local API directly (they hold a `Payload` instance, not a `PayloadRequest`), so
9
+ * without an explicit opt-in every operation ran unauthorized. `authenticate` and
10
+ * the `canX` hooks are *authentication* plus coarse route gating — they are not a
11
+ * substitute for the collection's own `access` rules.
12
+ *
13
+ * This module is the single place that decides what `{ overrideAccess, user }` a
14
+ * Local API call receives. Every sink in every factory spreads its result, so the
15
+ * invariant is enforced in one place rather than at eleven call sites.
16
+ *
17
+ * ## The problem it has to solve
18
+ *
19
+ * `PuckApiAuthHooks.authenticate` is deliberately bring-your-own: it may return a
20
+ * Better Auth session user, a NextAuth user, or a decoded JWT payload. Payload's
21
+ * access functions expect a Payload user document. Handing them a foreign-shaped
22
+ * object produces silently wrong authorization, which is worse than none.
23
+ *
24
+ * So the resolution is explicit and fails closed — see {@link resolvePayloadUser}.
25
+ */
26
+ import type { NextRequest } from 'next/server';
27
+ import type { AuthResult, PayloadUser, PuckApiAuthHooks } from '../types.js';
28
+ /**
29
+ * The slice of a route config the resolver needs. Kept structural so any factory
30
+ * carrying auth hooks can use it without depending on one config interface.
31
+ */
32
+ export interface AccessResolverConfig {
33
+ auth: PuckApiAuthHooks;
34
+ dangerouslyDisableCollectionAccessControl?: true;
35
+ }
36
+ /**
37
+ * Arguments spread into every Payload Local API call made by the route factories.
38
+ *
39
+ * `user` is intentionally always present (possibly `null`) when access control is
40
+ * on: `null` means "evaluate as an anonymous request", which is a deliberate
41
+ * state, not a missing value.
42
+ *
43
+ * `req` carries the *original request headers*. Access rules routinely read them
44
+ * — an API-key scope check cannot see its own key otherwise — and `createLocalReq`
45
+ * substitutes an empty `Headers` when no `req` is passed, which makes such a rule
46
+ * silently misjudge the request. For an API-key caller that can fail **open**.
47
+ */
48
+ export type PayloadAccessArgs = {
49
+ overrideAccess: false;
50
+ user: PayloadUser | null;
51
+ req: {
52
+ headers: Headers;
53
+ };
54
+ } | {
55
+ overrideAccess: true;
56
+ };
57
+ /**
58
+ * Mints the arguments for one Local API call.
59
+ *
60
+ * This is a factory rather than a value on purpose. `createLocalReq` **mutates**
61
+ * the `req` it is given (assigning `locale`, `context`, `payload`, `user`, a
62
+ * dataloader) and returns it. Sharing one object across the several sinks in a
63
+ * handler would let those assignments leak between operations. Each call gets a
64
+ * fresh object.
65
+ */
66
+ export type AccessArgsFactory = () => PayloadAccessArgs;
67
+ /**
68
+ * Thrown when the route factory cannot determine which Payload user to evaluate
69
+ * access control against.
70
+ *
71
+ * This is a configuration fault, not a request fault. It is surfaced as a 500
72
+ * with an actionable message rather than being papered over, because both of the
73
+ * alternatives are bugs: evaluating as anonymous silently downgrades every
74
+ * request, and skipping access control reintroduces the vulnerability.
75
+ */
76
+ export declare class PuckApiAccessError extends Error {
77
+ readonly name = "PuckApiAccessError";
78
+ constructor(message: string);
79
+ }
80
+ /**
81
+ * Structural check for "this is a Payload user document".
82
+ *
83
+ * Payload stamps `collection` onto the authenticated user (`BaseUser` in
84
+ * `payload/auth/types`), and nothing else in the auth pipeline does. Combined
85
+ * with an `id`, it is a reliable discriminator between a Payload user and an
86
+ * arbitrary session object, and it is what lets the recommended wiring —
87
+ * `authenticate` built on `payload.auth()` — work with no extra configuration.
88
+ *
89
+ * Deliberately *stricter* than Payload itself, which tolerates a missing
90
+ * `collection` by silently defaulting it. That tolerance is the footgun;
91
+ * refusing the input is the point.
92
+ */
93
+ export declare function isPayloadUser(value: unknown): value is PayloadUser;
94
+ /**
95
+ * Resolve the Payload user that access control should be evaluated against.
96
+ *
97
+ * Resolution order — first match wins, and every branch is an explicit signal
98
+ * from the integrator rather than a guess:
99
+ *
100
+ * 1. `auth.toPayloadUser` — the explicit mapping hook. Its return value is used
101
+ * verbatim, including `null`, which means "evaluate as anonymous".
102
+ * 2. `authResult.payloadUser` — set by `authenticate` itself when it already did
103
+ * the lookup. `null` is honoured the same way.
104
+ * 3. `authResult.user`, when it structurally *is* a Payload user. This is the
105
+ * zero-config path for Payload-auth integrators.
106
+ * 4. Otherwise: throw. We cannot tell whether the session maps to a privileged
107
+ * user or to nobody, and guessing either way is a security bug.
108
+ *
109
+ * There is deliberately **no** automatic `payload.auth()` fallback at step 4.
110
+ * It would re-run the auth pipeline on every request: for an API-key caller that
111
+ * double-decrements the key's remaining quota and rate-limit budget (and Better
112
+ * Auth deletes keys on exhaustion), and it can resolve a *different* principal
113
+ * than the one the `canX` hooks already gated — a confused deputy. The zero-cost
114
+ * path is for `authenticate` itself to call `payload.auth()`, which step 3 then
115
+ * accepts.
116
+ *
117
+ * @throws {PuckApiAccessError} when no branch matches.
118
+ */
119
+ export declare function resolvePayloadUser(auth: PuckApiAuthHooks, authResult: AuthResult, request: NextRequest): Promise<PayloadUser | null>;
120
+ /**
121
+ * Build the access resolver for one route factory.
122
+ *
123
+ * Returns a function that resolves the acting user once per request and hands
124
+ * back an {@link AccessArgsFactory} to mint per-sink arguments. The closure holds
125
+ * the "already warned" flag, so the opt-out warning is emitted once per factory
126
+ * rather than once per request.
127
+ */
128
+ export declare function createAccessResolver(routeConfig: AccessResolverConfig): (authResult: AuthResult, request: NextRequest) => Promise<AccessArgsFactory>;
129
+ /**
130
+ * Map a thrown {@link PuckApiAccessError} onto a response.
131
+ *
132
+ * Returns `null` for every other error so callers can fall through to their
133
+ * existing handling. Without this, the factories' generic `catch` blocks would
134
+ * flatten a misconfiguration into "Failed to list pages", which is the hardest
135
+ * possible thing to debug.
136
+ */
137
+ export declare function accessMisconfigurationResponse(error: unknown): Response | null;
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Access-control resolution for the standalone Next.js route factories.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Payload's Local API defaults to `overrideAccess: true`, which skips collection
7
+ * and field access control entirely. The route factories in `src/api/` call the
8
+ * Local API directly (they hold a `Payload` instance, not a `PayloadRequest`), so
9
+ * without an explicit opt-in every operation ran unauthorized. `authenticate` and
10
+ * the `canX` hooks are *authentication* plus coarse route gating — they are not a
11
+ * substitute for the collection's own `access` rules.
12
+ *
13
+ * This module is the single place that decides what `{ overrideAccess, user }` a
14
+ * Local API call receives. Every sink in every factory spreads its result, so the
15
+ * invariant is enforced in one place rather than at eleven call sites.
16
+ *
17
+ * ## The problem it has to solve
18
+ *
19
+ * `PuckApiAuthHooks.authenticate` is deliberately bring-your-own: it may return a
20
+ * Better Auth session user, a NextAuth user, or a decoded JWT payload. Payload's
21
+ * access functions expect a Payload user document. Handing them a foreign-shaped
22
+ * object produces silently wrong authorization, which is worse than none.
23
+ *
24
+ * So the resolution is explicit and fails closed — see {@link resolvePayloadUser}.
25
+ */ /**
26
+ * Thrown when the route factory cannot determine which Payload user to evaluate
27
+ * access control against.
28
+ *
29
+ * This is a configuration fault, not a request fault. It is surfaced as a 500
30
+ * with an actionable message rather than being papered over, because both of the
31
+ * alternatives are bugs: evaluating as anonymous silently downgrades every
32
+ * request, and skipping access control reintroduces the vulnerability.
33
+ */ export class PuckApiAccessError extends Error {
34
+ name = 'PuckApiAccessError';
35
+ constructor(message){
36
+ super(message);
37
+ // Preserve the prototype chain when compiled down to ES5-era output.
38
+ Object.setPrototypeOf(this, PuckApiAccessError.prototype);
39
+ }
40
+ }
41
+ const MISCONFIGURED_MESSAGE = [
42
+ '[payload-puck] Cannot resolve a Payload user for this request, so collection',
43
+ 'access control cannot be evaluated. The route factory refuses to run the',
44
+ 'operation rather than bypass authorization.',
45
+ '',
46
+ 'THE FIX, for almost every case: build `authenticate` on `payload.auth()`',
47
+ 'rather than on your auth library directly. It runs whatever auth strategies',
48
+ 'your Payload config registers — Better Auth, Clerk, custom strategies — and',
49
+ 'returns a real Payload user:',
50
+ '',
51
+ ' authenticate: async (request) => {',
52
+ ' const payload = await getPayload({ config })',
53
+ ' const { user } = await payload.auth({ headers: request.headers })',
54
+ ' if (!user) return { authenticated: false }',
55
+ ' return { authenticated: true, user }',
56
+ ' }',
57
+ '',
58
+ 'This is the recommended wiring even when your session comes from Better Auth',
59
+ 'or NextAuth. Do NOT look the user up by email and return the bare row: that',
60
+ 'silently drops the fields your auth strategy decorates onto the user (for',
61
+ 'Better Auth: activeOrganizationId, organizationRole, apiKeyScopes,',
62
+ 'oauthScopes), and access rules that read them then reach the wrong decision —',
63
+ 'in the API-key case, potentially a permissive one.',
64
+ '',
65
+ 'Only if your caller genuinely has no corresponding Payload user:',
66
+ '',
67
+ ' - Return `payloadUser` from `authenticate`, or implement `toPayloadUser`, if',
68
+ ' you can construct the equivalent Payload user yourself — including every',
69
+ ' field your access rules read.',
70
+ ' - Use `toPayloadUser: () => null` to deliberately evaluate access control as',
71
+ ' an anonymous (public) request.',
72
+ '',
73
+ 'As a last resort you may set `dangerouslyDisableCollectionAccessControl: true`',
74
+ 'on the route config. That restores the pre-fix behaviour in which Payload',
75
+ 'collection and field access rules are NOT enforced on these routes, leaving the',
76
+ '`canX` hooks as your only authorization. See GHSA-957g-hmmp-rchg.'
77
+ ].join('\n');
78
+ /**
79
+ * Structural check for "this is a Payload user document".
80
+ *
81
+ * Payload stamps `collection` onto the authenticated user (`BaseUser` in
82
+ * `payload/auth/types`), and nothing else in the auth pipeline does. Combined
83
+ * with an `id`, it is a reliable discriminator between a Payload user and an
84
+ * arbitrary session object, and it is what lets the recommended wiring —
85
+ * `authenticate` built on `payload.auth()` — work with no extra configuration.
86
+ *
87
+ * Deliberately *stricter* than Payload itself, which tolerates a missing
88
+ * `collection` by silently defaulting it. That tolerance is the footgun;
89
+ * refusing the input is the point.
90
+ */ export function isPayloadUser(value) {
91
+ if (typeof value !== 'object' || value === null) return false;
92
+ const candidate = value;
93
+ const hasId = typeof candidate.id === 'string' || typeof candidate.id === 'number';
94
+ return hasId && typeof candidate.collection === 'string';
95
+ }
96
+ /**
97
+ * Resolve the Payload user that access control should be evaluated against.
98
+ *
99
+ * Resolution order — first match wins, and every branch is an explicit signal
100
+ * from the integrator rather than a guess:
101
+ *
102
+ * 1. `auth.toPayloadUser` — the explicit mapping hook. Its return value is used
103
+ * verbatim, including `null`, which means "evaluate as anonymous".
104
+ * 2. `authResult.payloadUser` — set by `authenticate` itself when it already did
105
+ * the lookup. `null` is honoured the same way.
106
+ * 3. `authResult.user`, when it structurally *is* a Payload user. This is the
107
+ * zero-config path for Payload-auth integrators.
108
+ * 4. Otherwise: throw. We cannot tell whether the session maps to a privileged
109
+ * user or to nobody, and guessing either way is a security bug.
110
+ *
111
+ * There is deliberately **no** automatic `payload.auth()` fallback at step 4.
112
+ * It would re-run the auth pipeline on every request: for an API-key caller that
113
+ * double-decrements the key's remaining quota and rate-limit budget (and Better
114
+ * Auth deletes keys on exhaustion), and it can resolve a *different* principal
115
+ * than the one the `canX` hooks already gated — a confused deputy. The zero-cost
116
+ * path is for `authenticate` itself to call `payload.auth()`, which step 3 then
117
+ * accepts.
118
+ *
119
+ * @throws {PuckApiAccessError} when no branch matches.
120
+ */ export async function resolvePayloadUser(auth, authResult, request) {
121
+ if (auth.toPayloadUser) {
122
+ const mapped = await auth.toPayloadUser(authResult.user, request);
123
+ return mapped ?? null;
124
+ }
125
+ if (authResult.payloadUser !== undefined) {
126
+ return authResult.payloadUser ?? null;
127
+ }
128
+ if (isPayloadUser(authResult.user)) {
129
+ return authResult.user;
130
+ }
131
+ throw new PuckApiAccessError(MISCONFIGURED_MESSAGE);
132
+ }
133
+ /**
134
+ * Build the access resolver for one route factory.
135
+ *
136
+ * Returns a function that resolves the acting user once per request and hands
137
+ * back an {@link AccessArgsFactory} to mint per-sink arguments. The closure holds
138
+ * the "already warned" flag, so the opt-out warning is emitted once per factory
139
+ * rather than once per request.
140
+ */ export function createAccessResolver(routeConfig) {
141
+ const { auth, dangerouslyDisableCollectionAccessControl } = routeConfig;
142
+ let warned = false;
143
+ return async function resolveAccess(authResult, request) {
144
+ if (dangerouslyDisableCollectionAccessControl === true) {
145
+ if (!warned) {
146
+ warned = true;
147
+ console.warn('[payload-puck] SECURITY: `dangerouslyDisableCollectionAccessControl` is ' + 'enabled on a Puck API route. Payload collection and field access rules ' + 'are NOT enforced on these routes; the `canX` hooks are the only ' + 'authorization in effect. See GHSA-957g-hmmp-rchg.');
148
+ }
149
+ return ()=>({
150
+ overrideAccess: true
151
+ });
152
+ }
153
+ const user = await resolvePayloadUser(auth, authResult, request);
154
+ return ()=>({
155
+ overrideAccess: false,
156
+ user,
157
+ // Fresh per call — createLocalReq mutates whatever it is given.
158
+ req: {
159
+ headers: request.headers
160
+ }
161
+ });
162
+ };
163
+ }
164
+ /**
165
+ * Map a thrown {@link PuckApiAccessError} onto a response.
166
+ *
167
+ * Returns `null` for every other error so callers can fall through to their
168
+ * existing handling. Without this, the factories' generic `catch` blocks would
169
+ * flatten a misconfiguration into "Failed to list pages", which is the hardest
170
+ * possible thing to debug.
171
+ */ export function accessMisconfigurationResponse(error) {
172
+ if (!(error instanceof PuckApiAccessError)) return null;
173
+ console.error(error.message);
174
+ return Response.json({
175
+ error: 'Server misconfiguration: Puck API routes cannot resolve a Payload user ' + 'for access control. See the server logs for the fix.',
176
+ code: 'PUCK_ACCESS_MISCONFIGURED'
177
+ }, {
178
+ status: 500
179
+ });
180
+ }
@@ -15,9 +15,21 @@ import { pagePatternSystemContext } from '../ai/presets/index.js';
15
15
  if (!hasContextCollection) {
16
16
  return undefined;
17
17
  }
18
- // Fetch enabled context entries, sorted by order
18
+ // Fetch enabled context entries, sorted by order.
19
+ //
20
+ // `overrideAccess: true` is deliberate here, and is the one sink in the
21
+ // request path that keeps it. This is a trusted server-side read that builds
22
+ // the AI system prompt; it is never returned to the caller as data. The
23
+ // prompt must be identical for every operator allowed to invoke generation,
24
+ // otherwise output would silently vary with each user's read permissions on
25
+ // the context collection. Authorization for *using* AI generation is enforced
26
+ // by the endpoint's own `req.user` gate, above this call.
27
+ //
28
+ // Contrast with endpoints/context.ts, where the same collection is exposed
29
+ // as user-facing CRUD and is access-controlled (GHSA-rrx7-m589-5wfq).
19
30
  const result = await req.payload.find({
20
31
  collection: AI_CONTEXT_COLLECTION,
32
+ overrideAccess: true,
21
33
  where: {
22
34
  enabled: {
23
35
  equals: true
@@ -192,6 +204,10 @@ import { pagePatternSystemContext } from '../ai/presets/index.js';
192
204
  if (options.tools && Object.keys(options.tools).length > 0) {
193
205
  const toolContext = {
194
206
  payload: req.payload,
207
+ // Tools read on behalf of the operator, so they must be evaluated
208
+ // against that user's access rules — otherwise a low-privilege editor
209
+ // can have the model read documents they cannot, and echo the content
210
+ // back through generated output.
195
211
  user: req.user
196
212
  };
197
213
  // Create wrapped tools that inject the context
@@ -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 context
3
10
  * Matches the auto-generated collection from createPuckPlugin
4
11
  */ const COLLECTION = 'puck-ai-context';
@@ -22,6 +29,8 @@
22
29
  const includeAll = req.query?.all === 'true';
23
30
  const result = await req.payload.find({
24
31
  collection: COLLECTION,
32
+ req,
33
+ overrideAccess: false,
25
34
  sort: 'order',
26
35
  limit: 100,
27
36
  where: includeAll ? {} : {
@@ -36,7 +45,7 @@
36
45
  return Response.json({
37
46
  error: e instanceof Error ? e.message : 'Failed to list context'
38
47
  }, {
39
- status: 500
48
+ status: payloadErrorStatus(e) ?? 500
40
49
  });
41
50
  }
42
51
  };
@@ -66,6 +75,8 @@
66
75
  }
67
76
  const doc = await req.payload.create({
68
77
  collection: COLLECTION,
78
+ req,
79
+ overrideAccess: false,
69
80
  data
70
81
  });
71
82
  return Response.json(doc);
@@ -74,7 +85,7 @@
74
85
  return Response.json({
75
86
  error: e instanceof Error ? e.message : 'Failed to create context'
76
87
  }, {
77
- status: 500
88
+ status: payloadErrorStatus(e) ?? 500
78
89
  });
79
90
  }
80
91
  };
@@ -112,6 +123,8 @@
112
123
  }
113
124
  const doc = await req.payload.update({
114
125
  collection: COLLECTION,
126
+ req,
127
+ overrideAccess: false,
115
128
  id,
116
129
  data
117
130
  });
@@ -121,7 +134,7 @@
121
134
  return Response.json({
122
135
  error: e instanceof Error ? e.message : 'Failed to update context'
123
136
  }, {
124
- status: 500
137
+ status: payloadErrorStatus(e) ?? 500
125
138
  });
126
139
  }
127
140
  };
@@ -150,6 +163,8 @@
150
163
  try {
151
164
  await req.payload.delete({
152
165
  collection: COLLECTION,
166
+ req,
167
+ overrideAccess: false,
153
168
  id
154
169
  });
155
170
  return Response.json({
@@ -160,7 +175,7 @@
160
175
  return Response.json({
161
176
  error: e instanceof Error ? e.message : 'Failed to delete context'
162
177
  }, {
163
- status: 500
178
+ status: payloadErrorStatus(e) ?? 500
164
179
  });
165
180
  }
166
181
  };