@seshuk/payload-plugin-openapi 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,6 +33,7 @@
33
33
  - [OpenAPI Version](#openapi-version)
34
34
  - [Filters](#filters)
35
35
  - [Interactive Auth](#interactive-auth)
36
+ - [Security Marking](#security-marking)
36
37
  - [Caching](#caching)
37
38
  - [Docs UI](#docs-ui)
38
39
  - [Documenting Custom Endpoints](#documenting-custom-endpoints)
@@ -99,18 +100,19 @@ Prefer Swagger UI? Swap `scalar()` for `swaggerUi()` — or mount both on differ
99
100
 
100
101
  ### Plugin Options
101
102
 
102
- | Option | Type | Default | Description |
103
- | ----------------- | ------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------- |
104
- | `metadata` | `OpenApiMetadata` | — | API title, version, and description. **Required.** |
105
- | `openapiVersion` | `'3.0' \| '3.1' \| '3.2'` | `'3.2'` | Spec version to serve |
106
- | `specEndpoint` | `string` | `'/openapi.json'` | Path the spec is served from (relative to the API route) |
107
- | `enabled` | `boolean` | `true` | Set `false` to disable the plugin entirely |
108
- | `serve` | `boolean` | `true` | Set `false` to register only the CLI generator and serve nothing over HTTP ([details](#generate-only-no-runtime-endpoint)) |
109
- | `filters` | `FilterOptions` | see below | Which entities and operations to document ([details](#filters)) |
110
- | `interactiveAuth` | `boolean \| { endpoint }` | `false` | Username/password login for the docs UI |
111
- | `nestedTags` | `boolean` | `false` | Emit an OpenAPI 3.2 nested tag hierarchy (see below) |
112
- | `cache` | `boolean` | `true` | Cache the built document for the life of the process |
113
- | `extensions` | `OpenApiExtension[]` | `[]` | Inject paths, components, tags, or transform the document |
103
+ | Option | Type | Default | Description |
104
+ | ----------------- | ------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------- |
105
+ | `metadata` | `OpenApiMetadata` | — | API title, version, and description. **Required.** |
106
+ | `openapiVersion` | `'3.0' \| '3.1' \| '3.2'` | `'3.2'` | Spec version to serve |
107
+ | `specEndpoint` | `string` | `'/openapi.json'` | Path the spec is served from (relative to the API route) |
108
+ | `enabled` | `boolean` | `true` | Set `false` to disable the plugin entirely |
109
+ | `serve` | `boolean` | `true` | Set `false` to register only the CLI generator and serve nothing over HTTP ([details](#generate-only-no-runtime-endpoint)) |
110
+ | `filters` | `FilterOptions` | see below | Which entities and operations to document ([details](#filters)) |
111
+ | `interactiveAuth` | `boolean \| { endpoint }` | `false` | Username/password login for the docs UI |
112
+ | `nestedTags` | `boolean` | `false` | Emit an OpenAPI 3.2 nested tag hierarchy (see below) |
113
+ | `securityWhen` | `(ctx) => boolean \| undefined` | — | Override the auto-detected security marking per operation ([details](#security-marking)) |
114
+ | `cache` | `boolean` | `true` | Cache the built document for the life of the process |
115
+ | `extensions` | `OpenApiExtension[]` | `[]` | Inject paths, components, tags, or transform the document |
114
116
 
115
117
  ### Metadata
116
118
 
@@ -267,6 +269,47 @@ The endpoint logs in against your first auth-enabled collection (falling back to
267
269
  > [!WARNING]
268
270
  > The interactive auth endpoint exchanges credentials for a live JWT. Only enable it on docs you intend real users to authenticate against, and serve them over HTTPS.
269
271
 
272
+ ### Security Marking
273
+
274
+ Each operation in the spec is marked either public or secured (referencing the `PayloadToken` scheme). The marking is a **static hint**, not a live access check — it tells a reader which endpoints need a token.
275
+
276
+ By default the plugin figures this out per operation by probing your Payload access functions as an **anonymous request** (`user: null`): an operation is marked public only if its access function settles on `true`. The probe is deliberately conservative:
277
+
278
+ - An `async` access function is awaited — `async () => true` is correctly public.
279
+ - A function that returns a `Where` query (partial access), throws, or times out is marked secured.
280
+ - A function that reaches into the database (`req.payload.find(...)`) is marked secured — the probe never runs live queries, so DB-driven access always errs on the side of a padlock.
281
+
282
+ The marking is per operation: `read` covers list/find-by-id/count, `create` covers create/duplicate, `update` covers update and bulk update, `delete` covers delete and bulk delete.
283
+
284
+ When the guess is wrong, override it — two ways, override wins over the probe:
285
+
286
+ **Per entity, with `custom.openapi.security`** on a collection or global:
287
+
288
+ ```ts
289
+ export const Posts: CollectionConfig = {
290
+ slug: 'posts',
291
+ custom: {
292
+ openapi: {
293
+ // true → all operations public; false → all secured; or per operation:
294
+ security: { read: true, create: false, update: false, delete: false },
295
+ },
296
+ },
297
+ // ...
298
+ }
299
+ ```
300
+
301
+ **Across the whole document, with `securityWhen`** — an escape hatch mirroring `filters.excludeWhen`. Return `true` to mark an operation public, `false` to mark it secured, or `undefined` to keep the detected marking. It runs last, after `custom.openapi.security` and the probe, over collection, global, auth, and version operations alike:
302
+
303
+ ```ts
304
+ openapi({
305
+ metadata: { title: 'My API', version: '1.0.0' },
306
+ securityWhen: ({ slug, method }) => (slug === 'public-feed' && method === 'get' ? true : undefined),
307
+ })
308
+ ```
309
+
310
+ > [!NOTE]
311
+ > This is public-access marking only — the plugin never runs per-user access checks. The generated spec matches what the HTTP endpoint enforces at runtime; the marking just documents it.
312
+
270
313
  ### Caching
271
314
 
272
315
  The Payload config is static after boot, so the document is identical on every request apart from the server URL (which is always filled in fresh). Caching is on by default. Disable it in development so edits to your config show up without a restart:
package/dist/index.d.ts CHANGED
@@ -6,4 +6,4 @@ export type { BuildOpenApiInput } from './spec/build.js';
6
6
  export { toOpenApi30, toOpenApi31 } from './spec/downconvert.js';
7
7
  export { scalar } from './ui/scalar.js';
8
8
  export { swaggerUi } from './ui/swagger.js';
9
- export type { BuildContext, FilterOptions, OpenApiExtension, OpenApiMetadata, OpenApiPluginOptions, OpenApiVersion, UiPluginOptions, } from './types.js';
9
+ export type { BuildContext, EntityOperation, EntitySecurityOverride, FilterOptions, OpenApiExtension, OpenApiMetadata, OpenApiPluginOptions, OpenApiVersion, OperationContext, UiPluginOptions, } from './types.js';
package/dist/options.js CHANGED
@@ -37,6 +37,7 @@ const resolveOptions = (options)=>{
37
37
  filters: resolveFilters(options.filters),
38
38
  interactiveAuth: resolveInteractiveAuth(options.interactiveAuth),
39
39
  nestedTags: options.nestedTags ?? false,
40
+ securityWhen: options.securityWhen,
40
41
  cache: options.cache ?? true,
41
42
  extensions: options.extensions ?? []
42
43
  };
@@ -7,6 +7,7 @@ import { buildBlockSchema, buildEntitySchemas } from "./entitySchemas.js";
7
7
  import { filterOperations, shouldIncludeCollection, shouldIncludeGlobal } from "./filters.js";
8
8
  import { blockSchemaName, createSchemaName, globalSchemaName, joinsSchemaName, listSchemaName, populateSchemaName, querySchemaName, schemaName, selectSchemaName, updateSchemaName } from "./names.js";
9
9
  import { buildParamSchemas, buildQueryOperationsSchema, collectionHasFilters } from "./params.js";
10
+ import { applySecurityWhen, resolveEntitySecurity } from "./security.js";
10
11
  import { buildTagHierarchy } from "./tags.js";
11
12
  import { buildAuthPaths } from "./paths/auth.js";
12
13
  import { buildCollectionPaths } from "./paths/collections.js";
@@ -66,10 +67,21 @@ const buildDocument = async (input)=>{
66
67
  ctx
67
68
  });
68
69
  registerParamSchemas(schemas, base, collection.fields, ctx);
70
+ const security = await resolveEntitySecurity({
71
+ entity: collection,
72
+ operations: [
73
+ 'read',
74
+ 'create',
75
+ 'update',
76
+ 'delete'
77
+ ],
78
+ locale: ctx.locales[0]
79
+ });
69
80
  const collectionPaths = {
70
81
  ...buildCollectionPaths({
71
82
  collection,
72
- ctx
83
+ ctx,
84
+ security
73
85
  })
74
86
  };
75
87
  if (filters.includeAuth) Object.assign(collectionPaths, buildAuthPaths({
@@ -94,11 +106,17 @@ const buildDocument = async (input)=>{
94
106
  nestedTags: options.nestedTags
95
107
  }));
96
108
  }
97
- Object.assign(paths, filterOperations({
109
+ const filteredCollectionPaths = filterOperations({
98
110
  paths: collectionPaths,
99
111
  slug: collection.slug,
100
112
  kind: 'collection',
101
113
  filters
114
+ });
115
+ Object.assign(paths, applySecurityWhen({
116
+ paths: filteredCollectionPaths,
117
+ slug: collection.slug,
118
+ kind: 'collection',
119
+ securityWhen: options.securityWhen
102
120
  }));
103
121
  } catch (error) {
104
122
  logger.warn(`${PLUGIN_NAME}: skipped collection "${collection.slug}": ${error.message}`);
@@ -118,10 +136,19 @@ const buildDocument = async (input)=>{
118
136
  schemas[gbase] = read;
119
137
  schemas[updateSchemaName(gbase)] = update;
120
138
  registerParamSchemas(schemas, gbase, global.fields, ctx);
139
+ const security = await resolveEntitySecurity({
140
+ entity: global,
141
+ operations: [
142
+ 'read',
143
+ 'update'
144
+ ],
145
+ locale: ctx.locales[0]
146
+ });
121
147
  const globalPaths = {
122
148
  ...buildGlobalPaths({
123
149
  global,
124
- ctx
150
+ ctx,
151
+ security
125
152
  })
126
153
  };
127
154
  if (filters.includeVersions && global.versions) {
@@ -140,11 +167,17 @@ const buildDocument = async (input)=>{
140
167
  nestedTags: options.nestedTags
141
168
  }));
142
169
  }
143
- Object.assign(paths, filterOperations({
170
+ const filteredGlobalPaths = filterOperations({
144
171
  paths: globalPaths,
145
172
  slug: global.slug,
146
173
  kind: 'global',
147
174
  filters
175
+ });
176
+ Object.assign(paths, applySecurityWhen({
177
+ paths: filteredGlobalPaths,
178
+ slug: global.slug,
179
+ kind: 'global',
180
+ securityWhen: options.securityWhen
148
181
  }));
149
182
  } catch (error) {
150
183
  logger.warn(`${PLUGIN_NAME}: skipped global "${global.slug}": ${error.message}`);
@@ -1,4 +1,3 @@
1
- import type { Access } from 'payload';
2
1
  import type { ReferenceObject, RequestBodyObject, ResponseObject, ResponsesObject, SchemaObject, SecuritySchemeObject } from '@scalar/openapi-types/3.2';
3
2
  import type { Translate } from '../translations/types.js';
4
3
  type SchemaOrRef = SchemaObject | ReferenceObject;
@@ -42,5 +41,4 @@ export declare const securityScheme: ({ cookiePrefix, t }: {
42
41
  cookiePrefix: string;
43
42
  t: Translate;
44
43
  }) => SecuritySchemeObject;
45
- export declare const isOpenToPublic: (access: Access | undefined) => boolean;
46
44
  export {};
@@ -262,19 +262,4 @@ const securityScheme = ({ cookiePrefix, t })=>({
262
262
  cookiePrefix
263
263
  })
264
264
  });
265
- const isOpenToPublic = (access)=>{
266
- if (!access) return true;
267
- const trap = new Proxy({}, {
268
- get () {
269
- throw new Error('req accessed');
270
- }
271
- });
272
- try {
273
- return true === access({
274
- req: trap
275
- });
276
- } catch {
277
- return false;
278
- }
279
- };
280
- export { ERRORS, ERROR_SCHEMA_NAME, INTERACTIVE_SCHEME_NAME, SECURITY_SCHEME_NAME, buildErrorResponseSchema, buildListEnvelopeSchema, errorResponses, interactiveSecurityScheme, isOpenToPublic, jsonBody, jsonOk, jsonResponse, messageResponse, nullableType, securityScheme, uploadRequestBody };
265
+ export { ERRORS, ERROR_SCHEMA_NAME, INTERACTIVE_SCHEME_NAME, SECURITY_SCHEME_NAME, buildErrorResponseSchema, buildListEnvelopeSchema, errorResponses, interactiveSecurityScheme, jsonBody, jsonOk, jsonResponse, messageResponse, nullableType, securityScheme, uploadRequestBody };
@@ -20,8 +20,15 @@ const matchesEntity = (matcher, { slug, kind })=>{
20
20
  const anyMatch = (matchers, entity)=>matchers.some((m)=>matchesEntity(m, entity));
21
21
  const isHiddenCollection = (collection)=>{
22
22
  if (true === collection.hidden) return true;
23
- const adminHidden = collection.admin?.hidden;
24
- return true === adminHidden || 'function' == typeof adminHidden;
23
+ const hidden = collection.admin?.hidden;
24
+ if ('function' == typeof hidden) try {
25
+ return true === hidden({
26
+ user: null
27
+ });
28
+ } catch {
29
+ return false;
30
+ }
31
+ return true === hidden;
25
32
  };
26
33
  const passesIncludeExclude = (entity, filters)=>{
27
34
  if (filters.include.length > 0 && !anyMatch(filters.include, entity)) return false;
@@ -1,7 +1,9 @@
1
1
  import type { SanitizedCollectionConfig } from 'payload';
2
2
  import type { PathsObject } from '@scalar/openapi-types/3.2';
3
3
  import type { BuildContext } from '../../types.js';
4
- export declare const buildCollectionPaths: ({ collection, ctx, }: {
4
+ import type { EntitySecurity } from '../security.js';
5
+ export declare const buildCollectionPaths: ({ collection, ctx, security, }: {
5
6
  collection: SanitizedCollectionConfig;
6
7
  ctx: BuildContext;
8
+ security?: EntitySecurity;
7
9
  }) => PathsObject;
@@ -1,8 +1,8 @@
1
1
  import { makeT } from "../../translations/index.js";
2
- import { ERRORS, SECURITY_SCHEME_NAME, errorResponses, isOpenToPublic, jsonOk, uploadRequestBody } from "../components.js";
2
+ import { ERRORS, errorResponses, jsonOk, uploadRequestBody } from "../components.js";
3
3
  import { createSchemaName, listSchemaName, refTo, schemaName, updateSchemaName } from "../names.js";
4
4
  import { buildListParams, buildParamSchemas, commonReadParams } from "../params.js";
5
- const buildCollectionPaths = ({ collection, ctx })=>{
5
+ const buildCollectionPaths = ({ collection, ctx, security = {} })=>{
6
6
  const t = makeT(ctx.i18n);
7
7
  const name = schemaName(collection.slug);
8
8
  const base = `${ctx.apiRoute}/${collection.slug}`;
@@ -19,11 +19,7 @@ const buildCollectionPaths = ({ collection, ctx })=>{
19
19
  $ref: refTo(updateSchemaName(name))
20
20
  };
21
21
  const idType = 'number' === ctx.defaultIDType ? 'integer' : 'string';
22
- const secured = isOpenToPublic(collection.access?.read) ? void 0 : [
23
- {
24
- [SECURITY_SCHEME_NAME]: []
25
- }
26
- ];
22
+ const { read: secRead, create: secCreate, update: secUpdate, delete: secDelete } = security;
27
23
  const allowBulk = true !== collection.disableBulkEdit;
28
24
  const allowDuplicate = true !== collection.disableDuplicate;
29
25
  const paramSchemas = buildParamSchemas({
@@ -140,7 +136,7 @@ const buildCollectionPaths = ({ collection, ctx })=>{
140
136
  parameters: bulkParams,
141
137
  requestBody: requestBody(updateRef, false),
142
138
  responses: bulkUpdateResponse,
143
- security: secured
139
+ security: secUpdate
144
140
  },
145
141
  delete: {
146
142
  tags: [
@@ -149,7 +145,7 @@ const buildCollectionPaths = ({ collection, ctx })=>{
149
145
  operationId: `delete${name}`,
150
146
  parameters: bulkParams,
151
147
  responses: bulkDeleteResponse,
152
- security: secured
148
+ security: secDelete
153
149
  }
154
150
  } : {};
155
151
  const paths = {
@@ -166,7 +162,7 @@ const buildCollectionPaths = ({ collection, ctx })=>{
166
162
  refs
167
163
  }),
168
164
  responses: listResponse,
169
- security: secured
165
+ security: secRead
170
166
  },
171
167
  post: {
172
168
  tags: [
@@ -175,7 +171,7 @@ const buildCollectionPaths = ({ collection, ctx })=>{
175
171
  operationId: `create${name}`,
176
172
  requestBody: requestBody(createRef, fileRequiredOnCreate),
177
173
  responses: createResponse,
178
- security: secured
174
+ security: secCreate
179
175
  },
180
176
  ...bulkOps
181
177
  },
@@ -187,7 +183,7 @@ const buildCollectionPaths = ({ collection, ctx })=>{
187
183
  operationId: `count${name}`,
188
184
  parameters: bulkParams,
189
185
  responses: countResponse,
190
- security: secured
186
+ security: secRead
191
187
  }
192
188
  },
193
189
  [`${base}/{id}`]: {
@@ -212,7 +208,7 @@ const buildCollectionPaths = ({ collection, ctx })=>{
212
208
  refs
213
209
  }),
214
210
  responses: findByIdResponse,
215
- security: secured
211
+ security: secRead
216
212
  },
217
213
  patch: {
218
214
  tags: [
@@ -221,7 +217,7 @@ const buildCollectionPaths = ({ collection, ctx })=>{
221
217
  operationId: `update${name}ById`,
222
218
  requestBody: requestBody(updateRef, false),
223
219
  responses: updateResponse,
224
- security: secured
220
+ security: secUpdate
225
221
  },
226
222
  delete: {
227
223
  tags: [
@@ -229,7 +225,7 @@ const buildCollectionPaths = ({ collection, ctx })=>{
229
225
  ],
230
226
  operationId: `delete${name}ById`,
231
227
  responses: deleteResponse,
232
- security: secured
228
+ security: secDelete
233
229
  }
234
230
  }
235
231
  };
@@ -253,7 +249,7 @@ const buildCollectionPaths = ({ collection, ctx })=>{
253
249
  ...jsonOk(t('collectionDuplicated'), mutationSchema),
254
250
  ...errorResponses(ERRORS.create, t)
255
251
  },
256
- security: secured
252
+ security: secCreate
257
253
  }
258
254
  };
259
255
  return paths;
@@ -1,7 +1,9 @@
1
1
  import type { SanitizedGlobalConfig } from 'payload';
2
2
  import type { PathsObject } from '@scalar/openapi-types/3.2';
3
3
  import type { BuildContext } from '../../types.js';
4
- export declare const buildGlobalPaths: ({ global, ctx, }: {
4
+ import type { EntitySecurity } from '../security.js';
5
+ export declare const buildGlobalPaths: ({ global, ctx, security, }: {
5
6
  global: SanitizedGlobalConfig;
6
7
  ctx: BuildContext;
8
+ security?: EntitySecurity;
7
9
  }) => PathsObject;
@@ -1,8 +1,8 @@
1
1
  import { makeT } from "../../translations/index.js";
2
- import { ERRORS, SECURITY_SCHEME_NAME, errorResponses, isOpenToPublic, jsonOk } from "../components.js";
2
+ import { ERRORS, errorResponses, jsonOk } from "../components.js";
3
3
  import { globalSchemaName, refTo, updateSchemaName } from "../names.js";
4
4
  import { buildParamSchemas, commonReadParams } from "../params.js";
5
- const buildGlobalPaths = ({ global, ctx })=>{
5
+ const buildGlobalPaths = ({ global, ctx, security = {} })=>{
6
6
  const t = makeT(ctx.i18n);
7
7
  const name = globalSchemaName(global.slug);
8
8
  const docRef = {
@@ -11,11 +11,7 @@ const buildGlobalPaths = ({ global, ctx })=>{
11
11
  const updateRef = {
12
12
  $ref: refTo(updateSchemaName(name))
13
13
  };
14
- const secured = isOpenToPublic(global.access?.read) ? void 0 : [
15
- {
16
- [SECURITY_SCHEME_NAME]: []
17
- }
18
- ];
14
+ const { read: secRead, update: secUpdate } = security;
19
15
  const paramSchemas = buildParamSchemas({
20
16
  fields: global.fields,
21
17
  ctx
@@ -42,7 +38,7 @@ const buildGlobalPaths = ({ global, ctx })=>{
42
38
  ...okDoc,
43
39
  ...errorResponses(ERRORS.globalRead, t)
44
40
  },
45
- security: secured
41
+ security: secRead
46
42
  },
47
43
  post: {
48
44
  tags: [
@@ -60,7 +56,7 @@ const buildGlobalPaths = ({ global, ctx })=>{
60
56
  ...okDoc,
61
57
  ...errorResponses(ERRORS.globalUpdate, t)
62
58
  },
63
- security: secured
59
+ security: secUpdate
64
60
  }
65
61
  }
66
62
  };
@@ -0,0 +1,19 @@
1
+ import type { Access } from 'payload';
2
+ import type { PathsObject, SecurityRequirementObject } from '@scalar/openapi-types/3.2';
3
+ import type { Entity, EntityKind, EntityOperation, OperationContext } from '../types.js';
4
+ export type EntitySecurity = Partial<Record<EntityOperation, SecurityRequirementObject[] | undefined>>;
5
+ export declare const evaluateAccess: (access: Access | undefined, opts?: {
6
+ locale?: string;
7
+ timeoutMs?: number;
8
+ }) => Promise<boolean>;
9
+ export declare const resolveEntitySecurity: ({ entity, operations, locale, }: {
10
+ entity: Entity;
11
+ operations: EntityOperation[];
12
+ locale?: string;
13
+ }) => Promise<EntitySecurity>;
14
+ export declare const applySecurityWhen: ({ paths, slug, kind, securityWhen, }: {
15
+ paths: PathsObject;
16
+ slug: string;
17
+ kind: EntityKind;
18
+ securityWhen?: (ctx: OperationContext) => boolean | undefined;
19
+ }) => PathsObject;
@@ -0,0 +1,96 @@
1
+ import { SECURITY_SCHEME_NAME } from "./components.js";
2
+ const HTTP_METHODS = [
3
+ 'get',
4
+ 'post',
5
+ 'patch',
6
+ 'put',
7
+ 'delete'
8
+ ];
9
+ const securedRequirement = ()=>[
10
+ {
11
+ [SECURITY_SCHEME_NAME]: []
12
+ }
13
+ ];
14
+ const withTimeout = async (promise, timeoutMs)=>{
15
+ let timer;
16
+ const guard = new Promise((resolve)=>{
17
+ timer = setTimeout(()=>resolve(false), timeoutMs);
18
+ });
19
+ try {
20
+ return await Promise.race([
21
+ promise.then((value)=>true === value, ()=>false),
22
+ guard
23
+ ]);
24
+ } finally{
25
+ if (timer) clearTimeout(timer);
26
+ }
27
+ };
28
+ const evaluateAccess = async (access, opts = {})=>{
29
+ if (!access) return false;
30
+ const { locale, timeoutMs = 250 } = opts;
31
+ const payloadTrap = new Proxy({}, {
32
+ get () {
33
+ throw new Error('req.payload accessed during build');
34
+ }
35
+ });
36
+ const req = {
37
+ user: null,
38
+ headers: new Headers(),
39
+ context: {},
40
+ locale,
41
+ payload: payloadTrap
42
+ };
43
+ try {
44
+ const result = access({
45
+ req
46
+ });
47
+ if (result && 'function' == typeof result.then) return await withTimeout(result, timeoutMs);
48
+ return true === result;
49
+ } catch {
50
+ return false;
51
+ }
52
+ };
53
+ const readSecurityOverride = (entity)=>{
54
+ const meta = entity.custom?.openapi?.security;
55
+ if ('boolean' == typeof meta) return meta;
56
+ if (meta && 'object' == typeof meta) return meta;
57
+ };
58
+ const overrideFor = (override, op)=>{
59
+ if (void 0 === override) return;
60
+ if ('boolean' == typeof override) return override;
61
+ return override[op];
62
+ };
63
+ const resolveEntitySecurity = async ({ entity, operations, locale })=>{
64
+ const override = readSecurityOverride(entity);
65
+ const access = entity.access;
66
+ const out = {};
67
+ for (const op of operations){
68
+ const declared = overrideFor(override, op);
69
+ const isPublic = declared ?? await evaluateAccess(access?.[op], {
70
+ locale
71
+ });
72
+ out[op] = isPublic ? void 0 : securedRequirement();
73
+ }
74
+ return out;
75
+ };
76
+ const applySecurityWhen = ({ paths, slug, kind, securityWhen })=>{
77
+ if (!securityWhen) return paths;
78
+ for (const [path, item] of Object.entries(paths)){
79
+ if (!item) continue;
80
+ const ops = item;
81
+ for (const method of HTTP_METHODS){
82
+ const op = ops[method];
83
+ if (!op) continue;
84
+ const decision = securityWhen({
85
+ method,
86
+ path,
87
+ slug,
88
+ kind
89
+ });
90
+ if (true === decision) delete op.security;
91
+ else if (false === decision) op.security = securedRequirement();
92
+ }
93
+ }
94
+ return paths;
95
+ };
96
+ export { applySecurityWhen, evaluateAccess, resolveEntitySecurity };
package/dist/types.d.ts CHANGED
@@ -6,6 +6,8 @@ export type EntityKind = 'collection' | 'global';
6
6
  export type HttpMethod = 'get' | 'post' | 'patch' | 'put' | 'delete';
7
7
  export type IDType = 'text' | 'number';
8
8
  export type Entity = SanitizedCollectionConfig | SanitizedGlobalConfig;
9
+ export type EntityOperation = 'read' | 'create' | 'update' | 'delete';
10
+ export type EntitySecurityOverride = boolean | Partial<Record<EntityOperation, boolean>>;
9
11
  export interface OpenApiMetadata {
10
12
  title: string;
11
13
  version: string;
@@ -116,6 +118,12 @@ export type OpenApiPluginOptions = {
116
118
  * descriptions.
117
119
  */
118
120
  nestedTags?: boolean;
121
+ /**
122
+ * Override the security marking per operation: `true` marks it public, `false`
123
+ * secured (`PayloadToken`), `undefined` keeps the detected marking. Runs last,
124
+ * after `custom.openapi.security` and the probe, across every operation group.
125
+ */
126
+ securityWhen?: (ctx: OperationContext) => boolean | undefined;
119
127
  /**
120
128
  * Cache the built document for the life of the process. The Payload config is
121
129
  * static after boot, so the spec is the same every time apart from the server
@@ -169,6 +177,7 @@ export interface ResolvedOptions {
169
177
  endpoint: string;
170
178
  };
171
179
  nestedTags: boolean;
180
+ securityWhen?: (ctx: OperationContext) => boolean | undefined;
172
181
  cache: boolean;
173
182
  extensions: OpenApiExtension[];
174
183
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seshuk/payload-plugin-openapi",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "OpenAPI 3.0/3.1/3.2 spec generator for Payload CMS, with Scalar/Swagger UI.",
5
5
  "keywords": [
6
6
  "api-docs",