@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 +55 -12
- package/dist/index.d.ts +1 -1
- package/dist/options.js +1 -0
- package/dist/spec/buildDocument.js +37 -4
- package/dist/spec/components.d.ts +0 -2
- package/dist/spec/components.js +1 -16
- package/dist/spec/filters.js +9 -2
- package/dist/spec/paths/collections.d.ts +3 -1
- package/dist/spec/paths/collections.js +12 -16
- package/dist/spec/paths/globals.d.ts +3 -1
- package/dist/spec/paths/globals.js +5 -9
- package/dist/spec/security.d.ts +19 -0
- package/dist/spec/security.js +96 -0
- package/dist/types.d.ts +9 -0
- package/package.json +1 -1
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
|
|
103
|
-
| ----------------- |
|
|
104
|
-
| `metadata` | `OpenApiMetadata`
|
|
105
|
-
| `openapiVersion` | `'3.0' \| '3.1' \| '3.2'`
|
|
106
|
-
| `specEndpoint` | `string`
|
|
107
|
-
| `enabled` | `boolean`
|
|
108
|
-
| `serve` | `boolean`
|
|
109
|
-
| `filters` | `FilterOptions`
|
|
110
|
-
| `interactiveAuth` | `boolean \| { endpoint }`
|
|
111
|
-
| `nestedTags` | `boolean`
|
|
112
|
-
| `
|
|
113
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
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 {};
|
package/dist/spec/components.js
CHANGED
|
@@ -262,19 +262,4 @@ const securityScheme = ({ cookiePrefix, t })=>({
|
|
|
262
262
|
cookiePrefix
|
|
263
263
|
})
|
|
264
264
|
});
|
|
265
|
-
|
|
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 };
|
package/dist/spec/filters.js
CHANGED
|
@@ -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
|
|
24
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
|
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:
|
|
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:
|
|
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
|
}
|