@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/dist/api/types.d.ts
CHANGED
|
@@ -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
|
-
* //
|
|
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
|
|
37
|
-
*
|
|
38
|
-
* return { authenticated:
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/dist/endpoints/ai.js
CHANGED
|
@@ -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
|
};
|