feat(storage-*): include modified headers into the response headers of files when using adapters (#12096)

This PR makes it so that `modifyResponseHeaders` is supported in our
adapters when set on the collection config. Previously it would be
ignored.

This means that users can now modify or append new headers to what's
returned by each service.

```ts
import type { CollectionConfig } from 'payload'

export const Media: CollectionConfig = {
  slug: 'media',
  upload: {
    modifyResponseHeaders: ({ headers }) => {
      const newHeaders = new Headers(headers) // Copy existing headers
      newHeaders.set('X-Frame-Options', 'DENY') // Set new header

      return newHeaders
    },
  },
}
```

Also adds support for `void` return on the `modifyResponseHeaders`
function in the case where the user just wants to use existing headers
and doesn't need more control.

eg:

```ts
import type { CollectionConfig } from 'payload'

export const Media: CollectionConfig = {
  slug: 'media',
  upload: {
    modifyResponseHeaders: ({ headers }) => {
      headers.set('X-Frame-Options', 'DENY') // You can directly set headers without returning
    },
  },
}
```

Manual testing checklist (no CI e2es setup for these envs yet):
- [x] GCS
- [x] S3
- [x] Azure
- [x] UploadThing
- [x] Vercel Blob

---------

Co-authored-by: James <james@trbl.design>
This commit is contained in:
Paul
2025-07-10 16:00:26 +01:00
committed by GitHub
parent 055cc4ef12
commit cb6a73e1b4
17 changed files with 256 additions and 99 deletions

View File

@@ -116,6 +116,7 @@ _An asterisk denotes that an option is required._
| **`withMetadata`** | If specified, appends metadata to the output image file. Accepts a boolean or a function that receives `metadata` and `req`, returning a boolean. |
| **`hideFileInputOnCreate`** | Set to `true` to prevent the admin UI from showing file inputs during document creation, useful for programmatic file generation. |
| **`hideRemoveFile`** | Set to `true` to prevent the admin UI having a way to remove an existing file while editing. |
| **`modifyResponseHeaders`** | Accepts an object with existing `headers` and allows you to manipulate the response headers for media files. [More](#modifying-response-headers) |
### Payload-wide Upload Options
@@ -453,7 +454,7 @@ To fetch files from **restricted URLs** that would otherwise be blocked by CORS,
Heres how to configure the pasteURL option to control remote URL fetching:
```
```ts
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
@@ -466,7 +467,7 @@ export const Media: CollectionConfig = {
pathname: '',
port: '',
protocol: 'https',
search: ''
search: '',
},
{
hostname: 'example.com',
@@ -519,3 +520,44 @@ _An asterisk denotes that an option is required._
## Access Control
All files that are uploaded to each Collection automatically support the `read` [Access Control](/docs/access-control/overview) function from the Collection itself. You can use this to control who should be allowed to see your uploads, and who should not.
## Modifying response headers
You can modify the response headers for files by specifying the `modifyResponseHeaders` option in your upload config. This option accepts an object with existing headers and allows you to manipulate the response headers for media files.
### Modifying existing headers
With this method you can directly interface with the `Headers` object and modify the existing headers to append or remove headers.
```ts
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
upload: {
modifyResponseHeaders: ({ headers }) => {
headers.set('X-Frame-Options', 'DENY') // You can directly set headers without returning
},
},
}
```
### Return new headers
You can also return a new `Headers` object with the modified headers. This is useful if you want to set new headers or remove existing ones.
```ts
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
upload: {
modifyResponseHeaders: ({ headers }) => {
const newHeaders = new Headers(headers) // Copy existing headers
newHeaders.set('X-Frame-Options', 'DENY') // Set new header
return newHeaders
},
},
}
```

View File

@@ -38,9 +38,12 @@ export const getFileHandler: PayloadHandler = async (req) => {
if (collection.config.upload.handlers?.length) {
let customResponse: null | Response | void = null
const headers = new Headers()
for (const handler of collection.config.upload.handlers) {
customResponse = await handler(req, {
doc: accessResult,
headers,
params: {
collection: collection.config.slug,
filename,
@@ -95,7 +98,7 @@ export const getFileHandler: PayloadHandler = async (req) => {
headers.set('Content-Type', fileTypeResult.mime)
headers.set('Content-Length', stats.size + '')
headers = collection.config.upload?.modifyResponseHeaders
? collection.config.upload.modifyResponseHeaders({ headers })
? collection.config.upload.modifyResponseHeaders({ headers }) || headers
: headers
return new Response(data, {

View File

@@ -211,6 +211,7 @@ export type UploadConfig = {
req: PayloadRequest,
args: {
doc: TypeWithID
headers?: Headers
params: { clientUploadContext?: unknown; collection: string; filename: string }
},
) => Promise<Response> | Promise<void> | Response | void)[]
@@ -233,7 +234,7 @@ export type UploadConfig = {
* Ability to modify the response headers fetching a file.
* @default undefined
*/
modifyResponseHeaders?: ({ headers }: { headers: Headers }) => Headers
modifyResponseHeaders?: ({ headers }: { headers: Headers }) => Headers | void
/**
* Controls the behavior of pasting/uploading files from URLs.
* If set to `false`, fetching from remote URLs is disabled.

View File

@@ -58,6 +58,7 @@ export type StaticHandler = (
req: PayloadRequest,
args: {
doc?: TypeWithID
headers?: Headers
params: { clientUploadContext?: unknown; collection: string; filename: string }
},
) => Promise<Response> | Response

View File

@@ -14,7 +14,7 @@ interface Args {
}
export const getHandler = ({ collection, getStorageClient }: Args): StaticHandler => {
return async (req, { params: { clientUploadContext, filename } }) => {
return async (req, { headers: incomingHeaders, params: { clientUploadContext, filename } }) => {
try {
const prefix = await getFilePrefix({ clientUploadContext, collection, filename, req })
const blockBlobClient = getStorageClient().getBlockBlobClient(
@@ -30,14 +30,34 @@ export const getHandler = ({ collection, getStorageClient }: Args): StaticHandle
const response = blob._response
let initHeaders: Headers = {
...(response.headers.rawHeaders() as unknown as Headers),
}
// Typescript is difficult here with merging these types from Azure
if (incomingHeaders) {
initHeaders = {
...initHeaders,
...incomingHeaders,
}
}
let headers = new Headers(initHeaders)
const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match')
const objectEtag = response.headers.get('etag')
if (
collection.upload &&
typeof collection.upload === 'object' &&
typeof collection.upload.modifyResponseHeaders === 'function'
) {
headers = collection.upload.modifyResponseHeaders({ headers }) || headers
}
if (etagFromHeaders && etagFromHeaders === objectEtag) {
return new Response(null, {
headers: new Headers({
...response.headers.rawHeaders(),
}),
headers,
status: 304,
})
}
@@ -63,7 +83,7 @@ export const getHandler = ({ collection, getStorageClient }: Args): StaticHandle
})
return new Response(readableStream, {
headers: response.headers.rawHeaders(),
headers,
status: response.status,
})
} catch (err: unknown) {

View File

@@ -12,7 +12,7 @@ interface Args {
}
export const getHandler = ({ bucket, collection, getStorageClient }: Args): StaticHandler => {
return async (req, { params: { clientUploadContext, filename } }) => {
return async (req, { headers: incomingHeaders, params: { clientUploadContext, filename } }) => {
try {
const prefix = await getFilePrefix({ clientUploadContext, collection, filename, req })
const file = getStorageClient().bucket(bucket).file(path.posix.join(prefix, filename))
@@ -22,13 +22,23 @@ export const getHandler = ({ bucket, collection, getStorageClient }: Args): Stat
const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match')
const objectEtag = metadata.etag
let headers = new Headers(incomingHeaders)
headers.append('Content-Length', String(metadata.size))
headers.append('Content-Type', String(metadata.contentType))
headers.append('ETag', String(metadata.etag))
if (
collection.upload &&
typeof collection.upload === 'object' &&
typeof collection.upload.modifyResponseHeaders === 'function'
) {
headers = collection.upload.modifyResponseHeaders({ headers }) || headers
}
if (etagFromHeaders && etagFromHeaders === objectEtag) {
return new Response(null, {
headers: new Headers({
'Content-Length': String(metadata.size),
'Content-Type': String(metadata.contentType),
ETag: String(metadata.etag),
}),
headers,
status: 304,
})
}
@@ -50,11 +60,7 @@ export const getHandler = ({ bucket, collection, getStorageClient }: Args): Stat
})
return new Response(readableStream, {
headers: new Headers({
'Content-Length': String(metadata.size),
'Content-Type': String(metadata.contentType),
ETag: String(metadata.etag),
}),
headers,
status: 200,
})
} catch (err: unknown) {

View File

@@ -61,7 +61,7 @@ export const getHandler = ({
getStorageClient,
signedDownloads,
}: Args): StaticHandler => {
return async (req, { params: { clientUploadContext, filename } }) => {
return async (req, { headers: incomingHeaders, params: { clientUploadContext, filename } }) => {
let object: AWS.GetObjectOutput | undefined = undefined
try {
const prefix = await getFilePrefix({ clientUploadContext, collection, filename, req })
@@ -94,17 +94,31 @@ export const getHandler = ({
Key: key,
})
if (!object.Body) {
return new Response(null, { status: 404, statusText: 'Not Found' })
}
let headers = new Headers(incomingHeaders)
headers.append('Content-Length', String(object.ContentLength))
headers.append('Content-Type', String(object.ContentType))
headers.append('Accept-Ranges', String(object.AcceptRanges))
headers.append('ETag', String(object.ETag))
const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match')
const objectEtag = object.ETag
if (
collection.upload &&
typeof collection.upload === 'object' &&
typeof collection.upload.modifyResponseHeaders === 'function'
) {
headers = collection.upload.modifyResponseHeaders({ headers }) || headers
}
if (etagFromHeaders && etagFromHeaders === objectEtag) {
return new Response(null, {
headers: new Headers({
'Accept-Ranges': String(object.AcceptRanges),
'Content-Length': String(object.ContentLength),
'Content-Type': String(object.ContentType),
ETag: String(object.ETag),
}),
headers,
status: 304,
})
}
@@ -125,12 +139,7 @@ export const getHandler = ({
const bodyBuffer = await streamToBuffer(object.Body)
return new Response(bodyBuffer, {
headers: new Headers({
'Accept-Ranges': String(object.AcceptRanges),
'Content-Length': String(object.ContentLength),
'Content-Type': String(object.ContentType),
ETag: String(object.ETag),
}),
headers,
status: 200,
})
} catch (err) {

View File

@@ -9,9 +9,13 @@ type Args = {
}
export const getHandler = ({ utApi }: Args): StaticHandler => {
return async (req, { doc, params: { clientUploadContext, collection, filename } }) => {
return async (
req,
{ doc, headers: incomingHeaders, params: { clientUploadContext, collection, filename } },
) => {
try {
let key: string
const collectionConfig = req.payload.collections[collection]?.config
if (
clientUploadContext &&
@@ -21,7 +25,6 @@ export const getHandler = ({ utApi }: Args): StaticHandler => {
) {
key = clientUploadContext.key
} else {
const collectionConfig = req.payload.collections[collection]?.config
let retrievedDoc = doc
if (!retrievedDoc) {
@@ -82,23 +85,32 @@ export const getHandler = ({ utApi }: Args): StaticHandler => {
const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match')
const objectEtag = response.headers.get('etag')
let headers = new Headers(incomingHeaders)
headers.append('Content-Length', String(blob.size))
headers.append('Content-Type', blob.type)
if (objectEtag) {
headers.append('ETag', objectEtag)
}
if (
collectionConfig?.upload &&
typeof collectionConfig.upload === 'object' &&
typeof collectionConfig.upload.modifyResponseHeaders === 'function'
) {
headers = collectionConfig.upload.modifyResponseHeaders({ headers }) || headers
}
if (etagFromHeaders && etagFromHeaders === objectEtag) {
return new Response(null, {
headers: new Headers({
'Content-Length': String(blob.size),
'Content-Type': blob.type,
ETag: objectEtag,
}),
headers,
status: 304,
})
}
return new Response(blob, {
headers: new Headers({
'Content-Length': String(blob.size),
'Content-Type': blob.type,
ETag: objectEtag!,
}),
headers,
status: 200,
})
} catch (err) {

View File

@@ -15,27 +15,36 @@ export const getStaticHandler = (
{ baseUrl, cacheControlMaxAge = 0, token }: StaticHandlerArgs,
collection: CollectionConfig,
): StaticHandler => {
return async (req, { params: { clientUploadContext, filename } }) => {
return async (req, { headers: incomingHeaders, params: { clientUploadContext, filename } }) => {
try {
const prefix = await getFilePrefix({ clientUploadContext, collection, filename, req })
const fileKey = path.posix.join(prefix, encodeURIComponent(filename))
const fileUrl = `${baseUrl}/${fileKey}`
const etagFromHeaders = req.headers.get('etag') || req.headers.get('if-none-match')
const blobMetadata = await head(fileUrl, { token })
const uploadedAtString = blobMetadata.uploadedAt.toISOString()
const { contentDisposition, contentType, size, uploadedAt } = blobMetadata
const uploadedAtString = uploadedAt.toISOString()
const ETag = `"${fileKey}-${uploadedAtString}"`
const { contentDisposition, contentType, size } = blobMetadata
let headers = new Headers(incomingHeaders)
headers.append('Cache-Control', `public, max-age=${cacheControlMaxAge}`)
headers.append('Content-Disposition', contentDisposition)
headers.append('Content-Length', String(size))
headers.append('Content-Type', contentType)
headers.append('ETag', ETag)
if (
collection.upload &&
typeof collection.upload === 'object' &&
typeof collection.upload.modifyResponseHeaders === 'function'
) {
headers = collection.upload.modifyResponseHeaders({ headers }) || headers
}
if (etagFromHeaders && etagFromHeaders === ETag) {
return new Response(null, {
headers: new Headers({
'Cache-Control': `public, max-age=${cacheControlMaxAge}`,
'Content-Disposition': contentDisposition,
'Content-Length': String(size),
'Content-Type': contentType,
ETag,
}),
headers,
status: 304,
})
}
@@ -55,15 +64,10 @@ export const getStaticHandler = (
const bodyBuffer = await blob.arrayBuffer()
headers.append('Last-Modified', uploadedAtString)
return new Response(bodyBuffer, {
headers: new Headers({
'Cache-Control': `public, max-age=${cacheControlMaxAge}`,
'Content-Disposition': contentDisposition,
'Content-Length': String(size),
'Content-Type': contentType,
ETag,
'Last-Modified': blobMetadata.uploadedAt.toUTCString(),
}),
headers,
status: 200,
})
} catch (err: unknown) {

View File

@@ -3,6 +3,9 @@ import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
upload: {
modifyResponseHeaders({ headers }) {
headers.set('X-Universal-Truth', 'Set')
},
disableLocalStorage: true,
resizeOptions: {
position: 'center',

View File

@@ -84,7 +84,7 @@ export interface Config {
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
defaultIDType: number;
};
globals: {};
globalsSelect: {};
@@ -120,7 +120,7 @@ export interface UserAuthOperations {
* via the `definition` "media".
*/
export interface Media {
id: string;
id: number;
alt?: string | null;
updatedAt: string;
createdAt: string;
@@ -157,7 +157,7 @@ export interface Media {
* via the `definition` "media-with-prefix".
*/
export interface MediaWithPrefix {
id: string;
id: number;
prefix?: string | null;
updatedAt: string;
createdAt: string;
@@ -176,7 +176,7 @@ export interface MediaWithPrefix {
* via the `definition` "users".
*/
export interface User {
id: string;
id: number;
updatedAt: string;
createdAt: string;
email: string;
@@ -186,6 +186,13 @@ export interface User {
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**
@@ -193,24 +200,24 @@ export interface User {
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
id: number;
document?:
| ({
relationTo: 'media';
value: string | Media;
value: number | Media;
} | null)
| ({
relationTo: 'media-with-prefix';
value: string | MediaWithPrefix;
value: number | MediaWithPrefix;
} | null)
| ({
relationTo: 'users';
value: string | User;
value: number | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
updatedAt: string;
createdAt: string;
@@ -220,10 +227,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
id: number;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
key?: string | null;
value?:
@@ -243,7 +250,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
@@ -323,6 +330,13 @@ export interface UsersSelect<T extends boolean = true> {
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema

View File

@@ -3,6 +3,9 @@ import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
upload: {
modifyResponseHeaders({ headers }) {
headers.set('X-Universal-Truth', 'Set')
},
disableLocalStorage: true,
resizeOptions: {
position: 'center',

View File

@@ -3,6 +3,9 @@ import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
upload: {
modifyResponseHeaders({ headers }) {
headers.set('X-Universal-Truth', 'Set')
},
disableLocalStorage: true,
resizeOptions: {
position: 'center',

View File

@@ -3,6 +3,9 @@ import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
upload: {
modifyResponseHeaders({ headers }) {
headers.set('X-Universal-Truth', 'Set')
},
disableLocalStorage: true,
resizeOptions: {
position: 'center',

View File

@@ -84,7 +84,7 @@ export interface Config {
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
defaultIDType: number;
};
globals: {};
globalsSelect: {};
@@ -120,7 +120,7 @@ export interface UserAuthOperations {
* via the `definition` "media".
*/
export interface Media {
id: string;
id: number;
alt?: string | null;
_key?: string | null;
updatedAt: string;
@@ -160,7 +160,7 @@ export interface Media {
* via the `definition` "media-with-prefix".
*/
export interface MediaWithPrefix {
id: string;
id: number;
updatedAt: string;
createdAt: string;
url?: string | null;
@@ -178,7 +178,7 @@ export interface MediaWithPrefix {
* via the `definition` "users".
*/
export interface User {
id: string;
id: number;
updatedAt: string;
createdAt: string;
email: string;
@@ -188,6 +188,13 @@ export interface User {
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**
@@ -195,24 +202,24 @@ export interface User {
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
id: number;
document?:
| ({
relationTo: 'media';
value: string | Media;
value: number | Media;
} | null)
| ({
relationTo: 'media-with-prefix';
value: string | MediaWithPrefix;
value: number | MediaWithPrefix;
} | null)
| ({
relationTo: 'users';
value: string | User;
value: number | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
updatedAt: string;
createdAt: string;
@@ -222,10 +229,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
id: number;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
key?: string | null;
value?:
@@ -245,7 +252,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
@@ -327,6 +334,13 @@ export interface UsersSelect<T extends boolean = true> {
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema

View File

@@ -3,6 +3,9 @@ import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
upload: {
modifyResponseHeaders({ headers }) {
headers.set('X-Universal-Truth', 'Set')
},
resizeOptions: {
position: 'center',
width: 200,

View File

@@ -84,7 +84,7 @@ export interface Config {
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
defaultIDType: number;
};
globals: {};
globalsSelect: {};
@@ -120,7 +120,7 @@ export interface UserAuthOperations {
* via the `definition` "media".
*/
export interface Media {
id: string;
id: number;
alt?: string | null;
updatedAt: string;
createdAt: string;
@@ -157,7 +157,8 @@ export interface Media {
* via the `definition` "media-with-prefix".
*/
export interface MediaWithPrefix {
id: string;
id: number;
prefix?: string | null;
updatedAt: string;
createdAt: string;
url?: string | null;
@@ -175,7 +176,7 @@ export interface MediaWithPrefix {
* via the `definition` "users".
*/
export interface User {
id: string;
id: number;
updatedAt: string;
createdAt: string;
email: string;
@@ -185,6 +186,13 @@ export interface User {
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
}
/**
@@ -192,24 +200,24 @@ export interface User {
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
id: number;
document?:
| ({
relationTo: 'media';
value: string | Media;
value: number | Media;
} | null)
| ({
relationTo: 'media-with-prefix';
value: string | MediaWithPrefix;
value: number | MediaWithPrefix;
} | null)
| ({
relationTo: 'users';
value: string | User;
value: number | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
updatedAt: string;
createdAt: string;
@@ -219,10 +227,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
id: number;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
key?: string | null;
value?:
@@ -242,7 +250,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
@@ -295,6 +303,7 @@ export interface MediaSelect<T extends boolean = true> {
* via the `definition` "media-with-prefix_select".
*/
export interface MediaWithPrefixSelect<T extends boolean = true> {
prefix?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
@@ -321,6 +330,13 @@ export interface UsersSelect<T extends boolean = true> {
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema