@scalar/json-magic 0.13.3 → 0.13.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @scalar/json-magic
2
2
 
3
+ ## 0.13.4
4
+
5
+ ### Patch Changes
6
+
7
+ - [#10079](https://github.com/scalar/scalar/pull/10079): Harden the mock server against SSRF and local file disclosure through OpenAPI `$ref`s. External `$ref` resolution now refuses to fetch private, loopback, link-local, and metadata addresses, and confines local file reads to the document's directory. The `fetchUrls` and `readFiles` bundling plugins gain opt-in `blockPrivateNetworks` and `basePath` options, so other callers keep their current behavior unless they opt in.
8
+
3
9
  ## 0.13.3
4
10
 
5
11
  ## 0.13.2
@@ -1,4 +1,4 @@
1
- export { fetchUrls } from './fetch-urls/index.js';
1
+ export { fetchUrls } from './fetch-urls/browser.js';
2
2
  export { parseJson } from './parse-json/index.js';
3
3
  export { parseYaml } from './parse-yaml/index.js';
4
4
  //# sourceMappingURL=browser.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../../../src/bundle/plugins/browser.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AACxC,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AACxC,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA"}
1
+ {"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../../../src/bundle/plugins/browser.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAA;AAChD,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AACxC,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA"}
@@ -1,4 +1,4 @@
1
1
  // biome-ignore lint/performance/noBarrelFile: entrypoint
2
- export { fetchUrls } from './fetch-urls/index.js';
2
+ export { fetchUrls } from './fetch-urls/browser.js';
3
3
  export { parseJson } from './parse-json/index.js';
4
4
  export { parseYaml } from './parse-yaml/index.js';
@@ -0,0 +1,16 @@
1
+ import type { LoaderPlugin } from '../../../bundle/index.js';
2
+ type FetchConfig = Partial<{
3
+ headers: {
4
+ headers: HeadersInit;
5
+ domains: string[];
6
+ }[];
7
+ fetch: (input: string | URL | globalThis.Request, init?: RequestInit) => Promise<Response>;
8
+ }>;
9
+ /**
10
+ * Creates a browser-safe plugin for handling remote URL references.
11
+ */
12
+ export declare const fetchUrls: (config?: FetchConfig & Partial<{
13
+ limit: number | null;
14
+ }>) => LoaderPlugin;
15
+ export {};
16
+ //# sourceMappingURL=browser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../../../../src/bundle/plugins/fetch-urls/browser.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAiB,MAAM,UAAU,CAAA;AAI3D,KAAK,WAAW,GAAG,OAAO,CAAC;IACzB,OAAO,EAAE;QAAE,OAAO,EAAE,WAAW,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,EAAE,CAAA;IACtD,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,GAAG,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;CAC3F,CAAC,CAAA;AA+CF;;GAEG;AACH,eAAO,MAAM,SAAS,GAAI,SAAS,WAAW,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC,KAAG,YAQpF,CAAA"}
@@ -0,0 +1,51 @@
1
+ import { createLimiter } from '@scalar/helpers/general/create-limiter';
2
+ import { isHttpUrl } from '../../../helpers/is-http-url.js';
3
+ import { normalize } from '../../../helpers/normalize.js';
4
+ /**
5
+ * Safely checks for host from a URL.
6
+ * Needed because we cannot create a URL from a relative remote URL such as examples/openapi.json.
7
+ */
8
+ const getHost = (url) => {
9
+ try {
10
+ return new URL(url).host;
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ };
16
+ /**
17
+ * Fetches and normalizes data from a remote URL in a browser environment.
18
+ */
19
+ const fetchUrl = async (url, limiter, config) => {
20
+ try {
21
+ const host = getHost(url);
22
+ const headers = config?.headers?.find((a) => a.domains.find((d) => d === host) !== undefined)?.headers;
23
+ const exec = config?.fetch ?? fetch;
24
+ const result = await limiter(() => exec(url, { headers }));
25
+ if (result.ok) {
26
+ const body = await result.text();
27
+ return { ok: true, data: normalize(body), raw: body };
28
+ }
29
+ const contentType = result.headers.get('Content-Type') ?? '';
30
+ if (['text/html', 'application/xml'].includes(contentType)) {
31
+ console.warn(`[WARN] We only support JSON/YAML formats, received ${contentType}`);
32
+ }
33
+ console.warn(`[WARN] Fetch failed with status ${result.status} ${result.statusText} for URL: ${url}`);
34
+ return { ok: false };
35
+ }
36
+ catch {
37
+ console.warn(`[WARN] Failed to parse JSON/YAML from URL: ${url}`);
38
+ return { ok: false };
39
+ }
40
+ };
41
+ /**
42
+ * Creates a browser-safe plugin for handling remote URL references.
43
+ */
44
+ export const fetchUrls = (config) => {
45
+ const limiter = config?.limit ? createLimiter(config.limit) : (fn) => fn();
46
+ return {
47
+ type: 'loader',
48
+ validate: isHttpUrl,
49
+ exec: (value) => fetchUrl(value, limiter, config),
50
+ };
51
+ };
@@ -5,6 +5,12 @@ type FetchConfig = Partial<{
5
5
  domains: string[];
6
6
  }[];
7
7
  fetch: (input: string | URL | globalThis.Request, init?: RequestInit) => Promise<Response>;
8
+ /**
9
+ * When true, refuse to fetch URLs that resolve to a private, loopback, link-local, or otherwise
10
+ * internal address. Only enforced in Node, where DNS resolution is available. Off by default so
11
+ * existing callers keep working unchanged.
12
+ */
13
+ blockPrivateNetworks: boolean;
8
14
  }>;
9
15
  /**
10
16
  * Fetches and normalizes data from a remote URL
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/bundle/plugins/fetch-urls/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAI3D,KAAK,WAAW,GAAG,OAAO,CAAC;IACzB,OAAO,EAAE;QAAE,OAAO,EAAE,WAAW,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,EAAE,CAAA;IACtD,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,GAAG,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;CAC3F,CAAC,CAAA;AAcF;;;;;;;;;;;;;GAaG;AACH,wBAAsB,QAAQ,CAC5B,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,EAChD,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CA0CxB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,SAAS,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC,GAAG,YAAY,CAShG"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/bundle/plugins/fetch-urls/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAI3D,KAAK,WAAW,GAAG,OAAO,CAAC;IACzB,OAAO,EAAE;QAAE,OAAO,EAAE,WAAW,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,EAAE,CAAA;IACtD,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,GAAG,UAAU,CAAC,OAAO,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;IAC1F;;;;OAIG;IACH,oBAAoB,EAAE,OAAO,CAAA;CAC9B,CAAC,CAAA;AAyBF;;;;;;;;;;;;;GAaG;AACH,wBAAsB,QAAQ,CAC5B,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,EAChD,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,aAAa,CAAC,CAkExB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,SAAS,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC,GAAG,YAAY,CAShG"}
@@ -13,6 +13,17 @@ const getHost = (url) => {
13
13
  return null;
14
14
  }
15
15
  };
16
+ /**
17
+ * Safely reads the hostname (without port) from a URL, for the private-address check.
18
+ */
19
+ const getHostname = (url) => {
20
+ try {
21
+ return new URL(url).hostname;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ };
16
27
  /**
17
28
  * Fetches and normalizes data from a remote URL
18
29
  * @param url - The URL to fetch data from
@@ -29,12 +40,31 @@ const getHost = (url) => {
29
40
  */
30
41
  export async function fetchUrl(url, limiter, config) {
31
42
  try {
43
+ // SSRF guard: optionally refuse to fetch internal or private targets before making the request.
44
+ // Only runs in Node, since the browser build has no DNS access and its own network isolation.
45
+ if (config?.blockPrivateNetworks && typeof window === 'undefined') {
46
+ const hostname = getHostname(url);
47
+ if (hostname) {
48
+ const { isBlockedHost } = await import('./is-blocked-host.js');
49
+ if (await isBlockedHost(hostname)) {
50
+ console.warn(`[WARN] Refused to fetch a private or internal address: ${url}`);
51
+ return {
52
+ ok: false,
53
+ };
54
+ }
55
+ }
56
+ }
32
57
  const host = getHost(url);
33
58
  // Get the headers that match the domain
34
59
  const headers = config?.headers?.find((a) => a.domains.find((d) => d === host) !== undefined)?.headers;
35
60
  const exec = config?.fetch ?? fetch;
61
+ // Under the SSRF guard, do not follow redirects. Otherwise a public URL that passes the host
62
+ // check above could redirect to an internal target (for example the metadata endpoint) that
63
+ // the redirect would reach without being re-validated.
64
+ const redirect = config?.blockPrivateNetworks && typeof window === 'undefined' ? 'error' : undefined;
36
65
  const result = await limiter(() => exec(url, {
37
66
  headers,
67
+ redirect,
38
68
  }));
39
69
  if (result.ok) {
40
70
  const body = await result.text();
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Reports whether a hostname resolves to a private, loopback, link-local, or otherwise internal
3
+ * address that a bundled `$ref` should not be allowed to reach.
4
+ *
5
+ * Literal IPs are checked directly. Hostnames are resolved via DNS and every returned address is
6
+ * checked, so a name that points at an internal IP is rejected. A host that cannot be resolved is
7
+ * treated as blocked.
8
+ *
9
+ * This lives in a Node-only module because it depends on `node:dns` and `node:net`. Load it with a
10
+ * dynamic import so the browser build of the fetch plugin stays free of Node built-ins.
11
+ *
12
+ * @param hostname - The hostname or IP (brackets around an IPv6 literal are tolerated).
13
+ */
14
+ export declare const isBlockedHost: (hostname: string) => Promise<boolean>;
15
+ //# sourceMappingURL=is-blocked-host.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"is-blocked-host.d.ts","sourceRoot":"","sources":["../../../../src/bundle/plugins/fetch-urls/is-blocked-host.ts"],"names":[],"mappings":"AAmEA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,aAAa,GAAU,UAAU,MAAM,KAAG,OAAO,CAAC,OAAO,CAerE,CAAA"}
@@ -0,0 +1,81 @@
1
+ import { lookup } from 'node:dns/promises';
2
+ import { BlockList, isIP, isIPv4 } from 'node:net';
3
+ /**
4
+ * Address ranges that must never be reachable through a bundled `$ref`.
5
+ *
6
+ * Covers loopback, private, link-local (including the cloud metadata endpoint 169.254.169.254),
7
+ * carrier-grade NAT, and their IPv6 equivalents. The IPv6 transition ranges (6to4, NAT64, Teredo,
8
+ * IPv4-compatible) are blocked wholesale so an address like 64:ff9b::a9fe:a9fe cannot smuggle a
9
+ * private IPv4 destination past the checks. Those ranges are deprecated or rarely used for real API
10
+ * hosting, so blocking them outright is a safe trade.
11
+ */
12
+ const blockList = new BlockList();
13
+ blockList.addAddress('0.0.0.0', 'ipv4');
14
+ blockList.addSubnet('127.0.0.0', 8, 'ipv4');
15
+ blockList.addSubnet('10.0.0.0', 8, 'ipv4');
16
+ blockList.addSubnet('172.16.0.0', 12, 'ipv4');
17
+ blockList.addSubnet('192.168.0.0', 16, 'ipv4');
18
+ blockList.addSubnet('169.254.0.0', 16, 'ipv4');
19
+ blockList.addSubnet('100.64.0.0', 10, 'ipv4');
20
+ blockList.addSubnet('::', 96, 'ipv6'); // unspecified, loopback (::1), and deprecated IPv4-compatible
21
+ blockList.addSubnet('fe80::', 10, 'ipv6'); // link-local
22
+ blockList.addSubnet('fc00::', 7, 'ipv6'); // unique local
23
+ blockList.addSubnet('2002::', 16, 'ipv6'); // 6to4
24
+ blockList.addSubnet('64:ff9b::', 96, 'ipv6'); // NAT64
25
+ blockList.addSubnet('2001::', 32, 'ipv6'); // Teredo
26
+ /**
27
+ * Checks whether a single IP address falls inside a blocked range.
28
+ */
29
+ const ipIsBlocked = (ip) => {
30
+ const family = isIP(ip);
31
+ if (family === 0) {
32
+ // Not a valid IP, block to be safe
33
+ return true;
34
+ }
35
+ if (blockList.check(ip, family === 4 ? 'ipv4' : 'ipv6')) {
36
+ return true;
37
+ }
38
+ // Normalize an IPv4-mapped IPv6 address (::ffff:x.x.x.x, in dotted or hex form) and re-check it
39
+ // against the IPv4 rules, since it targets the embedded IPv4 destination.
40
+ const dotted = ip.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
41
+ if (dotted && isIPv4(dotted[1]) && blockList.check(dotted[1], 'ipv4')) {
42
+ return true;
43
+ }
44
+ const hex = ip.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
45
+ if (hex) {
46
+ const high = Number.parseInt(hex[1], 16);
47
+ const low = Number.parseInt(hex[2], 16);
48
+ const mapped = `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
49
+ if (blockList.check(mapped, 'ipv4')) {
50
+ return true;
51
+ }
52
+ }
53
+ return false;
54
+ };
55
+ /**
56
+ * Reports whether a hostname resolves to a private, loopback, link-local, or otherwise internal
57
+ * address that a bundled `$ref` should not be allowed to reach.
58
+ *
59
+ * Literal IPs are checked directly. Hostnames are resolved via DNS and every returned address is
60
+ * checked, so a name that points at an internal IP is rejected. A host that cannot be resolved is
61
+ * treated as blocked.
62
+ *
63
+ * This lives in a Node-only module because it depends on `node:dns` and `node:net`. Load it with a
64
+ * dynamic import so the browser build of the fetch plugin stays free of Node built-ins.
65
+ *
66
+ * @param hostname - The hostname or IP (brackets around an IPv6 literal are tolerated).
67
+ */
68
+ export const isBlockedHost = async (hostname) => {
69
+ const host = hostname.replace(/^\[/, '').replace(/\]$/, '');
70
+ if (isIP(host)) {
71
+ return ipIsBlocked(host);
72
+ }
73
+ try {
74
+ const addresses = await lookup(host, { all: true });
75
+ return addresses.some(({ address }) => ipIsBlocked(address));
76
+ }
77
+ catch {
78
+ // Block when the host cannot be resolved
79
+ return true;
80
+ }
81
+ };
@@ -1,7 +1,8 @@
1
1
  import type { LoaderPlugin, ResolveResult } from '../../../bundle/index.js';
2
2
  /**
3
3
  * Reads and normalizes data from a local file
4
- * @param path - The file path to read from
4
+ * @param filePath - The file path to read from
5
+ * @param basePath - When set, reads are confined to this directory so a `$ref` cannot escape it
5
6
  * @returns A promise that resolves to either the normalized data or an error result
6
7
  * @example
7
8
  * ```ts
@@ -13,17 +14,20 @@ import type { LoaderPlugin, ResolveResult } from '../../../bundle/index.js';
13
14
  * }
14
15
  * ```
15
16
  */
16
- export declare function readFile(path: string): Promise<ResolveResult>;
17
+ export declare function readFile(filePath: string, basePath?: string): Promise<ResolveResult>;
17
18
  /**
18
19
  * Creates a plugin for handling local file references.
19
20
  * This plugin validates and reads data from local filesystem paths.
20
21
  *
21
22
  * @returns A plugin object with validate and exec functions
23
+ * @param config - Optional settings. `basePath` confines reads to a directory.
22
24
  * @example
23
25
  * const filePlugin = readFiles()
24
26
  * if (filePlugin.validate('./local-schema.json')) {
25
27
  * const result = await filePlugin.exec('./local-schema.json')
26
28
  * }
27
29
  */
28
- export declare function readFiles(): LoaderPlugin;
30
+ export declare function readFiles(config?: {
31
+ basePath?: string;
32
+ }): LoaderPlugin;
29
33
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/bundle/plugins/read-files/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAI3D;;;;;;;;;;;;;GAaG;AACH,wBAAsB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAoBnE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,SAAS,IAAI,YAAY,CAMxC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/bundle/plugins/read-files/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AA8B3D;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CA6B1F;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CAAC,MAAM,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,YAAY,CAMtE"}
@@ -1,8 +1,32 @@
1
+ import path from 'pathe';
1
2
  import { isFilePath } from '../../../helpers/is-file-path.js';
2
3
  import { normalize } from '../../../helpers/normalize.js';
4
+ /**
5
+ * Resolves a path to its real location, following symlinks. Falls back to a lexical resolve when the
6
+ * path does not exist yet, so a missing file is still confined (the read fails afterwards anyway).
7
+ */
8
+ const realpathOrResolve = async (fs, target) => {
9
+ try {
10
+ return await fs.realpath(path.resolve(target));
11
+ }
12
+ catch {
13
+ return path.resolve(target);
14
+ }
15
+ };
16
+ /**
17
+ * Reports whether a file escapes the allowed base directory. Symlinks are dereferenced first, so a
18
+ * link inside the base directory cannot point at a file outside it.
19
+ */
20
+ const isOutsideBasePath = async (fs, filePath, basePath) => {
21
+ const realBase = await realpathOrResolve(fs, basePath);
22
+ const realTarget = await realpathOrResolve(fs, filePath);
23
+ const relative = path.relative(realBase, realTarget);
24
+ return relative === '..' || relative.startsWith('../') || path.isAbsolute(relative);
25
+ };
3
26
  /**
4
27
  * Reads and normalizes data from a local file
5
- * @param path - The file path to read from
28
+ * @param filePath - The file path to read from
29
+ * @param basePath - When set, reads are confined to this directory so a `$ref` cannot escape it
6
30
  * @returns A promise that resolves to either the normalized data or an error result
7
31
  * @example
8
32
  * ```ts
@@ -14,13 +38,21 @@ import { normalize } from '../../../helpers/normalize.js';
14
38
  * }
15
39
  * ```
16
40
  */
17
- export async function readFile(path) {
41
+ export async function readFile(filePath, basePath) {
18
42
  const fs = typeof window === 'undefined' ? await import('node:fs/promises') : undefined;
19
43
  if (fs === undefined) {
20
44
  throw 'Can not use readFiles plugin outside of a node environment';
21
45
  }
46
+ // Confine reads to basePath when provided, so a `$ref` like `../../../../etc/passwd` (or a symlink
47
+ // pointing outside the directory) cannot read files outside the document's directory.
48
+ if (basePath !== undefined && (await isOutsideBasePath(fs, filePath, basePath))) {
49
+ console.warn(`[WARN] Refused to read a file outside the allowed directory: ${filePath}`);
50
+ return {
51
+ ok: false,
52
+ };
53
+ }
22
54
  try {
23
- const fileContents = await fs.readFile(path, { encoding: 'utf-8' });
55
+ const fileContents = await fs.readFile(filePath, { encoding: 'utf-8' });
24
56
  return {
25
57
  ok: true,
26
58
  data: normalize(fileContents),
@@ -38,16 +70,17 @@ export async function readFile(path) {
38
70
  * This plugin validates and reads data from local filesystem paths.
39
71
  *
40
72
  * @returns A plugin object with validate and exec functions
73
+ * @param config - Optional settings. `basePath` confines reads to a directory.
41
74
  * @example
42
75
  * const filePlugin = readFiles()
43
76
  * if (filePlugin.validate('./local-schema.json')) {
44
77
  * const result = await filePlugin.exec('./local-schema.json')
45
78
  * }
46
79
  */
47
- export function readFiles() {
80
+ export function readFiles(config) {
48
81
  return {
49
82
  type: 'loader',
50
83
  validate: isFilePath,
51
- exec: readFile,
84
+ exec: (value) => readFile(value, config?.basePath),
52
85
  };
53
86
  }
package/package.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "url": "git+https://github.com/scalar/scalar.git",
11
11
  "directory": "packages/json-magic"
12
12
  },
13
- "version": "0.13.3",
13
+ "version": "0.13.4",
14
14
  "engines": {
15
15
  "node": ">=22"
16
16
  },
@@ -109,7 +109,7 @@
109
109
  "dependencies": {
110
110
  "pathe": "^2.0.3",
111
111
  "yaml": "^2.9.0",
112
- "@scalar/helpers": "0.11.2"
112
+ "@scalar/helpers": "0.11.3"
113
113
  },
114
114
  "devDependencies": {
115
115
  "fastify": "^5.11.2",