@payload-onpage/plugin 0.2.1 → 0.3.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.
Files changed (51) hide show
  1. package/dist/access.d.ts +7 -4
  2. package/dist/access.d.ts.map +1 -1
  3. package/dist/access.js +23 -20
  4. package/dist/access.js.map +1 -1
  5. package/dist/admin/OnPageAdminControls.d.ts +3 -2
  6. package/dist/admin/OnPageAdminControls.d.ts.map +1 -1
  7. package/dist/admin/OnPageAdminControls.js +12 -8
  8. package/dist/admin/OnPageAdminControls.js.map +1 -1
  9. package/dist/endpoints.d.ts +22 -6
  10. package/dist/endpoints.d.ts.map +1 -1
  11. package/dist/endpoints.js +87 -80
  12. package/dist/endpoints.js.map +1 -1
  13. package/dist/hooks.d.ts +11 -5
  14. package/dist/hooks.d.ts.map +1 -1
  15. package/dist/hooks.js +2 -2
  16. package/dist/hooks.js.map +1 -1
  17. package/dist/http.d.ts +18 -4
  18. package/dist/http.d.ts.map +1 -1
  19. package/dist/http.js +30 -16
  20. package/dist/http.js.map +1 -1
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +61 -50
  24. package/dist/index.js.map +1 -1
  25. package/dist/options.d.ts +22 -11
  26. package/dist/options.d.ts.map +1 -1
  27. package/dist/options.js +11 -3
  28. package/dist/options.js.map +1 -1
  29. package/dist/paths.d.ts +2 -2
  30. package/dist/paths.d.ts.map +1 -1
  31. package/dist/paths.js +11 -24
  32. package/dist/paths.js.map +1 -1
  33. package/dist/schema.d.ts +6 -4
  34. package/dist/schema.d.ts.map +1 -1
  35. package/dist/schema.js +115 -62
  36. package/dist/schema.js.map +1 -1
  37. package/dist/translations.d.ts +12 -1
  38. package/dist/translations.d.ts.map +1 -1
  39. package/dist/translations.js +10 -9
  40. package/dist/translations.js.map +1 -1
  41. package/package.json +2 -2
  42. package/src/access.ts +26 -19
  43. package/src/admin/OnPageAdminControls.tsx +16 -16
  44. package/src/endpoints.ts +105 -98
  45. package/src/hooks.ts +14 -5
  46. package/src/http.ts +51 -15
  47. package/src/index.ts +77 -64
  48. package/src/options.ts +29 -17
  49. package/src/paths.ts +14 -27
  50. package/src/schema.ts +137 -79
  51. package/src/translations.ts +17 -11
package/src/endpoints.ts CHANGED
@@ -1,91 +1,97 @@
1
1
  /**
2
2
  * The plugin's endpoints, under `${api}${routePrefix}` (default /api/onpage):
3
3
  *
4
- * GET /schema/:kind/:slug editable field schema for the signed-in editor
4
+ * GET /schema/:kind/:slug editable field schema for the signed-in editor (JSON)
5
5
  * POST /enter start editing or previewing a document's page
6
6
  * POST /mode switch the current page between editing and previewing
7
7
  * POST /exit leave edit/preview mode
8
8
  * POST /preview-link create a sealed, expiring read-only link (JSON)
9
9
  * GET /preview?token= open a preview link
10
10
  *
11
- * State-changing endpoints are same-origin POSTs (CSRF-safe). Every
12
- * authorization decision goes through ./access (Payload's own access rules).
11
+ * JSON endpoints throw Payload's errors, so responses, logging, CORS and
12
+ * translations match Payload's own. The form/new-tab endpoints answer in plain
13
+ * text or redirect. State-changing endpoints are same-origin POSTs (CSRF-safe),
14
+ * and every authorization decision goes through ./access (Payload's access rules).
13
15
  */
14
16
  import {
17
+ isOnPageMode,
15
18
  parseRefKey,
16
19
  pathnameOf,
17
20
  refKey,
21
+ ROUTES,
18
22
  safePath,
19
23
  samePath,
20
24
  SEAL_PURPOSE,
21
25
  seal,
26
+ type DocRef,
22
27
  type DocumentSchema,
23
- type OnPageMode,
28
+ type PreviewGrant,
24
29
  type SessionAdapter,
25
30
  } from '@payload-onpage/core'
26
- import type { CollectionConfig, Endpoint, GlobalConfig, PayloadRequest } from 'payload'
27
- import { hasAutosaveEnabled, hasDraftsEnabled, getAutosaveInterval } from 'payload/shared'
31
+ import {
32
+ addDataAndFileToRequest,
33
+ APIError,
34
+ Forbidden,
35
+ NotFound,
36
+ type Endpoint,
37
+ type PayloadRequest,
38
+ type TypedLocale,
39
+ } from 'payload'
40
+ import { getAutosaveInterval, hasAutosaveEnabled, hasDraftsEnabled, toWords } from 'payload/shared'
28
41
  import { canEditDocument, canUseOnPage, documentPermissions } from './access.ts'
29
42
  import {
43
+ apiURL,
44
+ entityConfig,
30
45
  isSecure,
31
46
  json,
32
- originOf,
33
- queryOf,
34
47
  readRef,
35
48
  redirectTo,
36
49
  rejectCrossSite,
37
50
  text,
51
+ withIdType,
38
52
  } from './http.ts'
39
53
  import type { Entry, OnPageOptions } from './options.ts'
40
54
  import { resolvePath } from './paths.ts'
41
- import { relationTargets, serializeFields } from './schema.ts'
55
+ import { label, relationTargets, serializeFields } from './schema.ts'
42
56
  import { createPreviewToken, verifyPreviewToken } from './tokens.ts'
43
57
 
44
58
  export interface EndpointContext {
45
- options: OnPageOptions
46
- prefix: string
47
- apiRoute: string
48
- entry: (kind: string, slug: string) => Entry | undefined
59
+ options: OnPageOptions & { session: SessionAdapter }
60
+ prefix: `/${string}`
61
+ entry: (kind: DocRef['kind'], slug: string) => Entry | undefined
49
62
  }
50
63
 
51
64
  const DAY = 24 * 3600
52
65
 
53
- export const secretOf = (req: PayloadRequest, options: OnPageOptions): string =>
66
+ const secretOf = (req: PayloadRequest, options: OnPageOptions): string =>
54
67
  options.secret ?? req.payload.config.secret
55
68
 
56
69
  /** Name of the auth cookie an editor's mode is bound to. */
57
70
  const authCookieOf = (req: PayloadRequest, options: OnPageOptions): string =>
58
71
  options.authCookie ?? `${req.payload.config.cookiePrefix || 'payload'}-token`
59
72
 
60
- function requireSession(options: OnPageOptions): SessionAdapter {
61
- if (!options.session)
62
- throw new Error(
63
- 'payload-onpage: pass `session` (e.g. nextSession()) to use the enter, mode, exit and preview endpoints',
64
- )
65
- return options.session
66
- }
73
+ /** A locale value from a body, if it's one Payload knows. */
74
+ const knownLocale = (req: PayloadRequest, value: unknown): TypedLocale | undefined =>
75
+ typeof value === 'string' && req.payload.config.localization
76
+ ? req.payload.config.localization.localeCodes.find((c) => c === value)
77
+ : undefined
67
78
 
68
79
  export function createEndpoints(ctx: EndpointContext): Endpoint[] {
69
- const { options, prefix, apiRoute } = ctx
80
+ const { options, prefix } = ctx
81
+ const { session } = options
70
82
 
71
83
  const schema: Endpoint = {
72
- path: `${prefix}/schema/:kind/:slug`,
84
+ path: `${prefix}${ROUTES.schema}/:kind/:slug`,
73
85
  method: 'get',
74
86
  handler: async (req) => {
75
87
  const kind = req.routeParams?.kind === 'global' ? 'global' : 'collection'
76
88
  const slug = typeof req.routeParams?.slug === 'string' ? req.routeParams.slug : ''
77
89
  const entry = ctx.entry(kind, slug)
78
- const cfg: CollectionConfig | GlobalConfig | undefined =
79
- kind === 'global'
80
- ? req.payload.globals.config.find((g) => g.slug === slug)
81
- : req.payload.collections[slug]?.config
82
- if (!entry || !cfg) return json({ error: 'Not enabled for on-page editing' }, 404)
83
- if (!(await canUseOnPage(req, options))) return json({ error: 'Forbidden' }, 403)
90
+ const cfg = entityConfig(req, kind, slug)
91
+ if (!entry || !cfg) throw new NotFound(req.t)
92
+ if (!(await canUseOnPage(req, options))) throw new Forbidden(req.t)
84
93
 
85
- const perms = await documentPermissions(
86
- req,
87
- kind === 'global' ? { kind, slug } : { kind, slug },
88
- )
94
+ const perms = await documentPermissions(req, { kind, slug })
89
95
  const pending: Promise<void>[] = []
90
96
  const all = serializeFields(cfg.fields, {
91
97
  i18n: req.i18n,
@@ -94,47 +100,43 @@ export function createEndpoints(ctx: EndpointContext): Endpoint[] {
94
100
  defaults: { req, pending },
95
101
  })
96
102
  await Promise.all(pending)
97
- const fields = entry.options.fields
98
- ? all.filter((f) => entry.options.fields!.includes(f.name))
99
- : all
103
+ const only = entry.options.fields
104
+ const fields = only ? all.filter((f) => only.includes(f.name)) : all
100
105
 
106
+ const i18n = { i18n: req.i18n }
101
107
  const relations: NonNullable<DocumentSchema['relations']> = {}
102
108
  for (const target of relationTargets(fields)) {
103
- const c = req.payload.collections[target]?.config
104
- if (!c) continue
105
- relations[target] = {
106
- titleField: c.admin?.useAsTitle ?? 'id',
107
- label: typeof c.labels?.singular === 'string' ? c.labels.singular : target,
108
- }
109
+ const c = entityConfig(req, 'collection', target)
110
+ if (c)
111
+ relations[target] = {
112
+ titleField: c.admin?.useAsTitle ?? 'id',
113
+ label: label(c.labels?.singular, toWords(target), i18n),
114
+ }
109
115
  }
116
+ const labels =
117
+ 'labels' in cfg
118
+ ? {
119
+ singular: label(cfg.labels?.singular, toWords(slug), i18n),
120
+ plural: label(cfg.labels?.plural, toWords(slug), i18n),
121
+ }
122
+ : {
123
+ singular: label(cfg.label, toWords(slug), i18n),
124
+ plural: label(cfg.label, toWords(slug), i18n),
125
+ }
126
+ const useAsTitle = 'labels' in cfg ? cfg.admin?.useAsTitle : undefined
110
127
  const localization = req.payload.config.localization
111
- const labelOf = (v: unknown) => (typeof v === 'string' ? v : slug)
112
128
  const body: DocumentSchema = {
113
129
  kind,
114
130
  slug,
115
- labels:
116
- kind === 'global'
117
- ? {
118
- singular: labelOf((cfg as GlobalConfig).label),
119
- plural: labelOf((cfg as GlobalConfig).label),
120
- }
121
- : {
122
- singular: labelOf((cfg as CollectionConfig).labels?.singular),
123
- plural: labelOf((cfg as CollectionConfig).labels?.plural),
124
- },
131
+ labels,
125
132
  editable: fields.map((f) => f.name),
126
133
  fields,
127
134
  drafts: hasDraftsEnabled(cfg),
128
135
  ...(hasAutosaveEnabled(cfg) ? { autosaveInterval: getAutosaveInterval(cfg) } : {}),
129
- ...(kind === 'collection' && (cfg as CollectionConfig).admin?.useAsTitle
130
- ? { useAsTitle: (cfg as CollectionConfig).admin!.useAsTitle }
131
- : {}),
136
+ ...(useAsTitle ? { useAsTitle } : {}),
132
137
  ...(Object.keys(relations).length ? { relations } : {}),
133
138
  ...(localization
134
- ? {
135
- defaultLocale: localization.defaultLocale,
136
- locales: localization.locales.map((l) => (typeof l === 'string' ? l : l.code)),
137
- }
139
+ ? { defaultLocale: localization.defaultLocale, locales: localization.localeCodes }
138
140
  : {}),
139
141
  }
140
142
  return json(body)
@@ -142,23 +144,23 @@ export function createEndpoints(ctx: EndpointContext): Endpoint[] {
142
144
  }
143
145
 
144
146
  const enter: Endpoint = {
145
- path: `${prefix}/enter`,
147
+ path: `${prefix}${ROUTES.enter}`,
146
148
  method: 'post',
147
149
  handler: async (req) => {
148
150
  const refused = rejectCrossSite(req)
149
151
  if (refused) return refused
150
- const q = queryOf(req)
151
- const ref = readRef({ kind: q.get('kind'), slug: q.get('slug'), id: q.get('id') })
152
+ const q = req.searchParams
153
+ const ref = readRef(req, { kind: q.get('kind'), slug: q.get('slug'), id: q.get('id') })
152
154
  const entry = ref && ctx.entry(ref.kind, ref.slug)
153
155
  if (!ref || !entry) return text('Unknown document', 400)
154
156
  if (!req.user) return text('Sign in to the admin first', 401)
155
157
  if (!(await canEditDocument(req, ref, options)))
156
158
  return text('You cannot edit this document', 403)
157
- const locale = q.get('locale') ?? undefined
159
+ const locale = req.locale && req.locale !== 'all' ? req.locale : undefined
158
160
  const path = safePath(q.get('path')) ?? (await resolvePath(req, ref, entry, locale))
159
161
  if (!path) return text('This document has no page to open', 404)
160
- const mode: OnPageMode = q.get('mode') === 'view' ? 'view' : 'edit'
161
- await requireSession(options).enable(mode, {
162
+ const mode = q.get('mode')
163
+ await session.enable(isOnPageMode(mode) ? mode : 'edit', {
162
164
  secure: isSecure(req),
163
165
  bindTo: authCookieOf(req, options),
164
166
  })
@@ -167,14 +169,16 @@ export function createEndpoints(ctx: EndpointContext): Endpoint[] {
167
169
  }
168
170
 
169
171
  const mode: Endpoint = {
170
- path: `${prefix}/mode`,
172
+ path: `${prefix}${ROUTES.mode}`,
171
173
  method: 'post',
172
174
  handler: async (req) => {
173
175
  const refused = rejectCrossSite(req)
174
176
  if (refused) return refused
175
- if (!(await canUseOnPage(req, options))) return text('Sign in as an editor first', 401)
176
- const q = queryOf(req)
177
- await requireSession(options).enable(q.get('mode') === 'view' ? 'view' : 'edit', {
177
+ if (!req.user) return text('Sign in to the admin first', 401)
178
+ if (!(await canUseOnPage(req, options))) return text('You cannot use on-page editing', 403)
179
+ const q = req.searchParams
180
+ const next = q.get('mode')
181
+ await session.enable(isOnPageMode(next) ? next : 'edit', {
178
182
  secure: isSecure(req),
179
183
  bindTo: authCookieOf(req, options),
180
184
  })
@@ -183,41 +187,41 @@ export function createEndpoints(ctx: EndpointContext): Endpoint[] {
183
187
  }
184
188
 
185
189
  const exit: Endpoint = {
186
- path: `${prefix}/exit`,
190
+ path: `${prefix}${ROUTES.exit}`,
187
191
  method: 'post',
188
192
  handler: async (req) => {
189
193
  const refused = rejectCrossSite(req)
190
194
  if (refused) return refused
191
- await requireSession(options).disable()
192
- return redirectTo(req, queryOf(req).get('path'))
195
+ await session.disable()
196
+ return redirectTo(req, req.searchParams.get('path'))
193
197
  },
194
198
  }
195
199
 
196
200
  const previewLink: Endpoint = {
197
- path: `${prefix}/preview-link`,
201
+ path: `${prefix}${ROUTES.previewLink}`,
198
202
  method: 'post',
199
203
  handler: async (req) => {
200
204
  const refused = rejectCrossSite(req)
201
205
  if (refused) return refused
202
206
  if (!req.headers.get('content-type')?.includes('application/json'))
203
- return json({ error: 'Expected JSON' }, 415)
204
- const body = ((await req.json?.().catch(() => null)) ?? {}) as Record<string, unknown>
205
- const ref = readRef(body)
207
+ throw new APIError('Expected a JSON body', 415)
208
+ await addDataAndFileToRequest(req) // parses JSON, or throws a 400
209
+ const body: Record<string, unknown> = req.data ?? {}
210
+ const ref = readRef(req, body)
206
211
  const entry = ref && ctx.entry(ref.kind, ref.slug)
207
- if (!ref || !entry) return json({ error: 'Unknown document' }, 400)
208
- if (!(await canEditDocument(req, ref, options))) return json({ error: 'Forbidden' }, 403)
209
- const locale = typeof body.locale === 'string' ? body.locale : undefined
212
+ if (!ref || !entry) throw new APIError('Unknown document', 400)
213
+ if (!(await canEditDocument(req, ref, options))) throw new Forbidden(req.t)
214
+ const locale = knownLocale(req, body.locale)
210
215
  // The path is always derived on the server: a link opens this document's page and nothing else.
211
216
  const path = await resolvePath(req, ref, entry, locale)
212
- if (!path) return json({ error: 'This document has no page' }, 404)
213
- const ttl = options.previewLinkTTL ?? DAY
217
+ if (!path) throw new APIError('This document has no page', 404)
214
218
  const { token, payload } = await createPreviewToken(
215
- { ref: refKey(ref), path, locale },
219
+ { ref: refKey(ref), path, ...(locale ? { locale } : {}) },
216
220
  secretOf(req, options),
217
- ttl,
221
+ options.previewLinkTTL ?? DAY,
218
222
  )
219
223
  return json({
220
- url: `${originOf(req)}${apiRoute}${prefix}/preview?token=${encodeURIComponent(token)}`,
224
+ url: `${apiURL(req, `${prefix}${ROUTES.preview}`)}?token=${encodeURIComponent(token)}`,
221
225
  expiresAt: new Date(payload.exp * 1000).toISOString(),
222
226
  id: payload.jti,
223
227
  })
@@ -225,17 +229,18 @@ export function createEndpoints(ctx: EndpointContext): Endpoint[] {
225
229
  }
226
230
 
227
231
  const preview: Endpoint = {
228
- path: `${prefix}/preview`,
232
+ path: `${prefix}${ROUTES.preview}`,
229
233
  method: 'get',
230
234
  handler: async (req) => {
231
235
  const invalid = () => text('This preview link is invalid or has expired.', 403)
232
236
  const secret = secretOf(req, options)
233
- const token = await verifyPreviewToken(queryOf(req).get('token') ?? '', secret)
234
- const ref = token && parseRefKey(token.ref)
237
+ const token = await verifyPreviewToken(req.searchParams.get('token') ?? '', secret)
238
+ const parsed = token && parseRefKey(token.ref)
239
+ const ref = parsed && withIdType(req, parsed)
235
240
  const entry = ref && ctx.entry(ref.kind, ref.slug)
236
241
  if (!token || !ref || !entry) return invalid()
237
242
  // Still the document's page? (It may have been deleted, or its slug changed.)
238
- const current = await resolvePath(req, ref, entry, token.locale)
243
+ const current = await resolvePath(req, ref, entry, knownLocale(req, token.locale))
239
244
  if (!current || !samePath(current, token.path)) return invalid()
240
245
  if (
241
246
  options.validatePreviewLink &&
@@ -249,17 +254,19 @@ export function createEndpoints(ctx: EndpointContext): Endpoint[] {
249
254
  ) {
250
255
  return invalid()
251
256
  }
252
- const expiresAt = new Date(token.exp * 1000)
253
- await requireSession(options).enableLink({
257
+ await session.enableLink({
254
258
  secure: isSecure(req),
255
259
  path: pathnameOf(token.path),
256
- expiresAt,
257
- seal: (draftSecret) =>
258
- seal(
259
- { path: pathnameOf(token.path), ref: token.ref, exp: token.exp, draft: draftSecret },
260
- secret,
261
- SEAL_PURPOSE.previewCookie,
262
- ),
260
+ expiresAt: new Date(token.exp * 1000),
261
+ seal: (draftSecret) => {
262
+ const grant: PreviewGrant = {
263
+ path: pathnameOf(token.path),
264
+ ref: token.ref,
265
+ exp: token.exp,
266
+ draft: draftSecret,
267
+ }
268
+ return seal(grant, secret, SEAL_PURPOSE.previewCookie)
269
+ },
263
270
  })
264
271
  return redirectTo(req, token.path, 307)
265
272
  },
package/src/hooks.ts CHANGED
@@ -5,23 +5,32 @@
5
5
  * - beforeChange: stale-write guard (409) when the editor sends the version it last saw
6
6
  */
7
7
  import {
8
+ CONTEXT_KEY,
8
9
  encodeDocument,
9
10
  HEADER,
10
11
  refKey,
11
12
  stegaCleanDeep,
13
+ type DocRef,
12
14
  type EncodeFilter,
13
15
  type SchemaField,
14
16
  } from '@payload-onpage/core'
15
- import { APIError, type PayloadRequest } from 'payload'
17
+ import { APIError, type PayloadRequest, type RequestContext } from 'payload'
18
+
19
+ declare module 'payload' {
20
+ interface RequestContext {
21
+ /** Set by `onpageContext()`: this read should carry auto-mapping markers. */
22
+ [CONTEXT_KEY]?: boolean
23
+ }
24
+ }
16
25
 
17
26
  /** True when a read asked for auto-mapping markers (see `onpageContext()`). */
18
- export const wantsMarkers = (context: unknown): boolean =>
19
- !!(context && typeof context === 'object' && (context as Record<string, unknown>).onpage === true)
27
+ export const wantsMarkers = (context: RequestContext | undefined): boolean =>
28
+ context?.[CONTEXT_KEY] === true
20
29
 
21
30
  export const stripMarkers = ({ data }: { data?: unknown }) => (data ? stegaCleanDeep(data) : data)
22
31
 
23
32
  export function encodeOnRead(opts: {
24
- kind: 'collection' | 'global'
33
+ kind: DocRef['kind']
25
34
  slug: string
26
35
  fields: () => SchemaField[]
27
36
  only?: string[]
@@ -34,7 +43,7 @@ export function encodeOnRead(opts: {
34
43
  }: {
35
44
  doc: Record<string, unknown>
36
45
  req: PayloadRequest
37
- context: unknown
46
+ context: RequestContext
38
47
  }) => {
39
48
  if (!wantsMarkers(context) || req.locale === 'all' || !doc) return doc
40
49
  const ref =
package/src/http.ts CHANGED
@@ -1,25 +1,24 @@
1
1
  /** Request helpers shared by the endpoints. */
2
- import { parseRefKey, safePath, type DocRef } from '@payload-onpage/core'
3
- import type { PayloadRequest } from 'payload'
2
+ import { safePath, type DocRef } from '@payload-onpage/core'
3
+ import type { PayloadRequest, SanitizedCollectionConfig, SanitizedGlobalConfig } from 'payload'
4
+ import { formatAdminURL, stripTrailingSlash } from 'payload/shared'
4
5
 
5
6
  /** The site origin: `serverURL` when configured, else the request's own origin. */
6
- export function originOf(req: PayloadRequest): string {
7
+ export const originOf = (req: PayloadRequest): string => {
7
8
  const configured = req.payload.config.serverURL
8
- if (configured) return configured.replace(/\/$/, '')
9
- try {
10
- return new URL(req.url ?? '').origin
11
- } catch {
12
- const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? 'localhost'
13
- const proto = req.headers.get('x-forwarded-proto') ?? 'http'
14
- return `${proto}://${host}`
15
- }
9
+ return configured ? stripTrailingSlash(configured) : req.origin
16
10
  }
17
11
 
18
12
  export const isSecure = (req: PayloadRequest): boolean => originOf(req).startsWith('https:')
19
13
 
20
- export const queryOf = (req: PayloadRequest): URLSearchParams =>
21
- new URL(req.url ?? '/', 'http://localhost').searchParams
14
+ /**
15
+ * Absolute URL of one of our API routes (e.g. `/onpage/preview`), built the way
16
+ * Payload builds its own: honours `routes.api`, Next's basePath and trailing slashes.
17
+ */
18
+ export const apiURL = (req: PayloadRequest, path: `/${string}`): string =>
19
+ formatAdminURL({ apiRoute: req.payload.config.routes.api, path, serverURL: originOf(req) })
22
20
 
21
+ /** Plain-text reply for the form-POST / new-tab endpoints, where JSON would just be shown raw. */
23
22
  export const text = (body: string, status: number): Response =>
24
23
  new Response(body, {
25
24
  status,
@@ -45,13 +44,50 @@ export function rejectCrossSite(req: PayloadRequest): Response | null {
45
44
  return site === 'same-origin' || site === 'none' ? null : text('Cross-site request refused', 403)
46
45
  }
47
46
 
47
+ /** A collection's or global's sanitized config. */
48
+ export function entityConfig(
49
+ req: PayloadRequest,
50
+ kind: 'collection',
51
+ slug: string,
52
+ ): SanitizedCollectionConfig | undefined
53
+ export function entityConfig(
54
+ req: PayloadRequest,
55
+ kind: 'global',
56
+ slug: string,
57
+ ): SanitizedGlobalConfig | undefined
58
+ export function entityConfig(
59
+ req: PayloadRequest,
60
+ kind: DocRef['kind'],
61
+ slug: string,
62
+ ): SanitizedCollectionConfig | SanitizedGlobalConfig | undefined
63
+ export function entityConfig(req: PayloadRequest, kind: DocRef['kind'], slug: string) {
64
+ return kind === 'global'
65
+ ? req.payload.globals.config.find((g) => g.slug === slug)
66
+ : req.payload.collections[slug]?.config
67
+ }
68
+
48
69
  /** A document ref from `kind`, `slug`, `id` query/body values. */
49
- export function readRef(values: { kind?: unknown; slug?: unknown; id?: unknown }): DocRef | null {
70
+ export function readRef(
71
+ req: PayloadRequest,
72
+ values: { kind?: unknown; slug?: unknown; id?: unknown },
73
+ ): DocRef | null {
50
74
  const slug = typeof values.slug === 'string' ? values.slug : ''
51
75
  if (!slug) return null
52
76
  if (values.kind === 'global') return { kind: 'global', slug }
53
77
  const id = values.id
54
78
  if (typeof id === 'number') return { kind: 'collection', slug, id }
55
79
  if (typeof id !== 'string' || !id) return null
56
- return parseRefKey(`c:${slug}:${id}`)
80
+ return withIdType(req, { kind: 'collection', slug, id })
81
+ }
82
+
83
+ /**
84
+ * The ref with its id typed the way the collection stores it (number or text),
85
+ * so a text id that looks numeric ("007") is never coerced.
86
+ */
87
+ export function withIdType(req: PayloadRequest, ref: DocRef): DocRef {
88
+ if (ref.kind === 'global') return ref
89
+ const type = req.payload.collections[ref.slug]?.customIDType ?? req.payload.db.defaultIDType
90
+ const raw = String(ref.id)
91
+ const n = Number(raw)
92
+ return { ...ref, id: type === 'number' && Number.isSafeInteger(n) && String(n) === raw ? n : raw }
57
93
  }