🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@amritk/resolve-refs

Package Overview
Dependencies
Maintainers
1
Versions
12
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@amritk/resolve-refs - npm Package Compare versions

Comparing version
0.2.0
to
0.2.1
+6
-4
dist/get-by-pointer.d.ts
import type { JsonPath } from './types.js';
/**
* Parses a JSON Pointer string (RFC 6901) into a path of keys: strips the leading
* `/`, splits on `/`, decodes each segment (`%XX`, then `~1` → `/`, `~0` → `~`),
* and coerces all-digit segments to numbers so array indices and object keys read
* back the same way a path consumer expects. A bare `''` or `'/'` is the empty path.
* `/`, splits on `/`, decodes each segment, and coerces canonical array-index
* tokens to numbers so indices and object keys read back the way a path consumer
* expects. Only RFC 6901 array indices (`0` or a non-zero-leading run of digits)
* are coerced; a numeric *object* key with a leading zero such as `"01"` is kept
* as a string, since coercing it would alias to a different key (`obj["01"]` is
* not `obj[1]`). A bare `''` or `'/'` is the empty path.
*/

@@ -16,2 +19,1 @@ export declare const pointerToPath: (pointer: string) => JsonPath;

export declare const getByPointer: (root: unknown, pointer: string) => unknown;
//# sourceMappingURL=get-by-pointer.d.ts.map
+23
-25
/**
* Decodes a single JSON Pointer segment (RFC 6901): percent-decodes it, then
* unescapes `~1` → `/` and `~0` → `~`. Invalid percent-escapes are left as-is.
*/
const decodeSegment = (segment) => {
let decoded = segment;
try {
decoded = decodeURIComponent(segment);
}
catch {
// leave invalid percent-escapes as-is
}
return decoded.replace(/~1/g, '/').replace(/~0/g, '~');
};
/**
* Parses a JSON Pointer string (RFC 6901) into a path of keys: strips the leading
* `/`, splits on `/`, decodes each segment (`%XX`, then `~1` → `/`, `~0` → `~`),
* and coerces all-digit segments to numbers so array indices and object keys read
* back the same way a path consumer expects. A bare `''` or `'/'` is the empty path.
* `/`, splits on `/`, decodes each segment, and coerces canonical array-index
* tokens to numbers so indices and object keys read back the way a path consumer
* expects. Only RFC 6901 array indices (`0` or a non-zero-leading run of digits)
* are coerced; a numeric *object* key with a leading zero such as `"01"` is kept
* as a string, since coercing it would alias to a different key (`obj["01"]` is
* not `obj[1]`). A bare `''` or `'/'` is the empty path.
*/

@@ -14,11 +31,4 @@ export const pointerToPath = (pointer) => {

.map((segment) => {
let decoded = segment;
try {
decoded = decodeURIComponent(segment);
}
catch {
// leave invalid percent-escapes as-is
}
decoded = decoded.replace(/~1/g, '/').replace(/~0/g, '~');
return /^\d+$/.test(decoded) ? Number(decoded) : decoded;
const decoded = decodeSegment(segment);
return /^(0|[1-9]\d*)$/.test(decoded) ? Number(decoded) : decoded;
});

@@ -35,15 +45,3 @@ };

return root;
const segments = pointer
.replace(/^\//, '')
.split('/')
.map((segment) => {
let decoded = segment;
try {
decoded = decodeURIComponent(segment);
}
catch {
// leave invalid percent-escapes as-is
}
return decoded.replace(/~1/g, '/').replace(/~0/g, '~');
});
const segments = pointer.replace(/^\//, '').split('/').map(decodeSegment);
let current = root;

@@ -50,0 +48,0 @@ for (const segment of segments) {

@@ -6,2 +6,1 @@ export { getByPointer, pointerToPath } from './get-by-pointer.js';

export type { JsonPath, Origin, OriginMap, ResolveError, ResolveOptions, ResolveResult } from './types.js';
//# sourceMappingURL=index.d.ts.map

@@ -10,2 +10,1 @@ /**

export declare const isPrivateHost: (hostname: string) => boolean;
//# sourceMappingURL=is-private-host.d.ts.map

@@ -10,2 +10,1 @@ import type { ResolveOptions, ResolveResult } from './types.js';

export declare const resolveRefsFromFile: (filename: string, options?: ResolveOptions) => Promise<ResolveResult>;
//# sourceMappingURL=resolve-refs-from-file.d.ts.map

@@ -18,2 +18,1 @@ import type { ResolveResult } from './types.js';

export declare const resolveRefs: (data: unknown, options?: ResolveRefsOptions) => ResolveResult;
//# sourceMappingURL=resolve-refs.d.ts.map

@@ -84,2 +84,1 @@ /**

};
//# sourceMappingURL=types.d.ts.map
{
"name": "@amritk/resolve-refs",
"version": "0.2.0",
"version": "0.2.1",
"description": "Resolve and inline JSON Schema / OpenAPI $refs — internal, cross-file, and remote — with session caching and a default-deny SSRF guard.",

@@ -29,4 +29,3 @@ "module": "./dist/index.js",

"files": [
"dist",
"src"
"dist"
],

@@ -39,7 +38,7 @@ "publishConfig": {

"types:check": "tsgo -p . --noEmit",
"test": "NODE_ENV=production vitest run --root ../.. resolve-refs"
"test": "NODE_ENV=production vitest run --root ../.. resolve-refs",
"bench": "bun run ./bench/run.ts"
},
"exports": {
".": {
"development": "./src/index.ts",
"default": "./dist/index.js",

@@ -46,0 +45,0 @@ "types": "./dist/index.d.ts"

{"version":3,"file":"get-by-pointer.d.ts","sourceRoot":"","sources":["../src/get-by-pointer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAEvC;;;;;GAKG;AACH,eAAO,MAAM,aAAa,YAAa,MAAM,KAAG,QAe/C,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,YAAY,SAAU,OAAO,WAAW,MAAM,KAAG,OAuB7D,CAAA"}
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAA;AAC9D,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AACjD,OAAO,EAAE,KAAK,kBAAkB,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AACrE,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAChF,YAAY,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA"}
{"version":3,"file":"is-private-host.d.ts","sourceRoot":"","sources":["../src/is-private-host.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAoDH,eAAO,MAAM,aAAa,aAAc,MAAM,KAAG,OA+BhD,CAAA"}
{"version":3,"file":"resolve-refs-from-file.d.ts","sourceRoot":"","sources":["../src/resolve-refs-from-file.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAA2B,cAAc,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AA+CrF,iFAAiF;AACjF,eAAO,MAAM,gBAAgB,QAAO,IAEnC,CAAA;AAwND;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,aAAoB,MAAM,YAAW,cAAc,KAAQ,OAAO,CAAC,aAAa,CAY/G,CAAA"}
{"version":3,"file":"resolve-refs.d.ts","sourceRoot":"","sources":["../src/resolve-refs.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAa,aAAa,EAAE,MAAM,SAAS,CAAA;AAOvD,0CAA0C;AAC1C,MAAM,MAAM,kBAAkB,GAAG;IAC/B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB,CAAA;AA6CD;;;;GAIG;AACH,eAAO,MAAM,WAAW,SAAU,OAAO,YAAW,kBAAkB,KAAQ,aAI7E,CAAA"}
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,MAAM,QAAQ,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAA;AAE1C,iFAAiF;AACjF,MAAM,MAAM,YAAY,GAAG;IACzB,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,QAAQ,CAAA;CACf,CAAA;AAED;;;;GAIG;AACH,MAAM,MAAM,MAAM,GAAG;IACnB,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,QAAQ,CAAA;CAClB,CAAA;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,SAAS,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;AAE3C,gFAAgF;AAChF,MAAM,MAAM,aAAa,GAAG;IAC1B,kEAAkE;IAClE,QAAQ,EAAE,OAAO,CAAA;IACjB,MAAM,EAAE,YAAY,EAAE,CAAA;IACtB;;;OAGG;IACH,OAAO,CAAC,EAAE,SAAS,CAAA;CACpB,CAAA;AAED,mEAAmE;AACnE,MAAM,MAAM,cAAc,GAAG;IAC3B;;OAEG;IACH,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;IACvB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B;;;;;;;;;;;;;;;OAeG;IACH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAA;IACtD;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB,CAAA"}
import { describe, expect, it } from 'vitest'
import { getByPointer } from './get-by-pointer'
describe('get-by-pointer', () => {
const root = {
info: { title: 'API' },
paths: [{ get: { id: 1 } }, { post: { id: 2 } }],
'weird/key': true,
'tilde~key': false,
}
it('returns the root for an empty or bare-slash pointer', () => {
expect(getByPointer(root, '')).toBe(root)
expect(getByPointer(root, '/')).toBe(root)
})
it('navigates object keys', () => {
expect(getByPointer(root, '/info/title')).toBe('API')
})
it('navigates array indices', () => {
expect(getByPointer(root, '/paths/1/post/id')).toBe(2)
})
it('decodes ~1 and ~0 escapes', () => {
expect(getByPointer(root, '/weird~1key')).toBe(true)
expect(getByPointer(root, '/tilde~0key')).toBe(false)
})
it('percent-decodes URI-encoded segments', () => {
const doc = { '{volume_id}': 'found', 'weird/key': true }
expect(getByPointer(doc, '/%7Bvolume_id%7D')).toBe('found')
// %2F in a segment must not be treated as a path separator
expect(getByPointer(doc, '/weird%2Fkey')).toBe(true)
})
it('returns undefined when a segment is missing', () => {
expect(getByPointer(root, '/info/nope')).toBeUndefined()
expect(getByPointer(root, '/info/title/tooDeep')).toBeUndefined()
})
})
import type { JsonPath } from './types'
/**
* Parses a JSON Pointer string (RFC 6901) into a path of keys: strips the leading
* `/`, splits on `/`, decodes each segment (`%XX`, then `~1` → `/`, `~0` → `~`),
* and coerces all-digit segments to numbers so array indices and object keys read
* back the same way a path consumer expects. A bare `''` or `'/'` is the empty path.
*/
export const pointerToPath = (pointer: string): JsonPath => {
if (pointer === '' || pointer === '/') return []
return pointer
.replace(/^\//, '')
.split('/')
.map((segment) => {
let decoded = segment
try {
decoded = decodeURIComponent(segment)
} catch {
// leave invalid percent-escapes as-is
}
decoded = decoded.replace(/~1/g, '/').replace(/~0/g, '~')
return /^\d+$/.test(decoded) ? Number(decoded) : decoded
})
}
/**
* Walks a JSON Pointer string (RFC 6901) to the value it points to within
* `root`. A bare `''` or `'/'` returns the root document. Segment escapes are
* decoded (`~1` → `/`, `~0` → `~`). Returns `undefined` when any segment along
* the path is missing or traverses a non-object.
*/
export const getByPointer = (root: unknown, pointer: string): unknown => {
if (pointer === '' || pointer === '/') return root
const segments = pointer
.replace(/^\//, '')
.split('/')
.map((segment) => {
let decoded = segment
try {
decoded = decodeURIComponent(segment)
} catch {
// leave invalid percent-escapes as-is
}
return decoded.replace(/~1/g, '/').replace(/~0/g, '~')
})
let current: unknown = root
for (const segment of segments) {
if (current === null || typeof current !== 'object') return undefined
const key = Array.isArray(current) ? Number(segment) : segment
current = (current as Record<string, unknown>)[key]
}
return current
}
export { getByPointer, pointerToPath } from './get-by-pointer'
export { isPrivateHost } from './is-private-host'
export { type ResolveRefsOptions, resolveRefs } from './resolve-refs'
export { clearRemoteCache, resolveRefsFromFile } from './resolve-refs-from-file'
export type { JsonPath, Origin, OriginMap, ResolveError, ResolveOptions, ResolveResult } from './types'
import { describe, expect, it } from 'vitest'
import { isPrivateHost } from './is-private-host'
describe('is-private-host', () => {
it('treats localhost and its subdomains as private', () => {
expect(isPrivateHost('localhost')).toBe(true)
expect(isPrivateHost('api.localhost')).toBe(true)
})
it('flags loopback and RFC 1918 / CGNAT IPv4 ranges', () => {
expect(isPrivateHost('127.0.0.1')).toBe(true)
expect(isPrivateHost('10.1.2.3')).toBe(true)
expect(isPrivateHost('172.16.0.1')).toBe(true)
expect(isPrivateHost('172.31.255.255')).toBe(true)
expect(isPrivateHost('192.168.0.1')).toBe(true)
expect(isPrivateHost('100.64.0.1')).toBe(true)
})
it('flags the cloud-metadata link-local endpoint', () => {
expect(isPrivateHost('169.254.169.254')).toBe(true)
})
it('flags private IPv6 (loopback, link-local, unique-local)', () => {
expect(isPrivateHost('::1')).toBe(true)
expect(isPrivateHost('[::1]')).toBe(true)
expect(isPrivateHost('fe80::1')).toBe(true)
expect(isPrivateHost('fd00::1')).toBe(true)
})
it('flags the full fe80::/10 link-local range, not just fe80', () => {
expect(isPrivateHost('fe9a::1')).toBe(true)
expect(isPrivateHost('feba::1')).toBe(true)
expect(isPrivateHost('febf::1')).toBe(true)
// fec0:: is outside fe80::/10 (third nibble c), so it is not link-local.
expect(isPrivateHost('fec0::1')).toBe(false)
})
it('flags IPv4-mapped IPv6 loopback in both dotted and hex form', () => {
expect(isPrivateHost('::ffff:127.0.0.1')).toBe(true)
// The form `new URL()` produces for ::ffff:127.0.0.1.
expect(isPrivateHost('::ffff:7f00:1')).toBe(true)
// ::ffff:169.254.169.254 (cloud metadata) → hex a9fe:a9fe.
expect(isPrivateHost('::ffff:a9fe:a9fe')).toBe(true)
// A mapped public address stays public.
expect(isPrivateHost('::ffff:8.8.8.8')).toBe(false)
})
it('flags decimal/octal/hex IPv4 encodings (defense-in-depth)', () => {
expect(isPrivateHost('2130706433')).toBe(true) // 127.0.0.1
expect(isPrivateHost('0177.0.0.1')).toBe(true)
expect(isPrivateHost('0x7f000001')).toBe(true)
expect(isPrivateHost('127.1')).toBe(true)
})
it('allows public hosts', () => {
expect(isPrivateHost('example.com')).toBe(false)
expect(isPrivateHost('8.8.8.8')).toBe(false)
expect(isPrivateHost('172.32.0.1')).toBe(false)
// Hostnames made only of hex letters must not be mistaken for IPs.
expect(isPrivateHost('cafe')).toBe(false)
expect(isPrivateHost('dead.beef')).toBe(false)
})
})
/**
* Best-effort check for a hostname that resolves to a non-public address —
* loopback, private (RFC 1918 / ULA), link-local (incl. the `169.254.169.254`
* cloud-metadata endpoint), or otherwise host-local. This is a syntactic guard
* on the URL only; it does not perform DNS, so a public name that resolves to a
* private IP is not caught here. It exists to stop the obvious SSRF footguns
* when resolving remote `$ref`s.
*/
/** True for an IPv4 address (given its first two octets) in a non-public range. */
const isPrivateIpv4 = (a: number, b: number): boolean => {
if (a === 10 || a === 127 || a === 0) return true
if (a === 169 && b === 254) return true // link-local + cloud metadata
if (a === 172 && b >= 16 && b <= 31) return true
if (a === 192 && b === 168) return true
if (a === 100 && b >= 64 && b <= 127) return true // CGNAT
return false
}
/** Parses a single IPv4 part in decimal, octal (`0…`), or hex (`0x…`) form. */
const parseIpv4Part = (s: string): number | null => {
if (/^0x[0-9a-f]+$/i.test(s)) return Number.parseInt(s, 16)
if (/^0[0-7]*$/.test(s)) return Number.parseInt(s, 8)
if (/^[1-9][0-9]*$/.test(s)) return Number.parseInt(s, 10)
return null
}
/**
* Resolves the first two octets of an IPv4 host written in any inet_aton form
* (dotted/decimal/octal/hex, 1–4 parts), or `null` if it is not an IPv4 literal.
* The WHATWG URL parser normalizes these to dotted-decimal before they reach us,
* but a direct caller (this is an exported guard) can pass the raw form.
*/
const ipv4Octets = (host: string): [number, number] | null => {
if (!/^[0-9a-fx.]+$/i.test(host)) return null
const parts = host.split('.')
if (parts.length === 0 || parts.length > 4) return null
const nums: number[] = []
for (const part of parts) {
const n = parseIpv4Part(part)
if (n === null) return null
nums.push(n)
}
// inet_aton packing: every part but the last is one byte; the last fills the
// remaining low bytes (so `127.1` is 127.0.0.1 and `2130706433` is too).
let value = 0
for (let i = 0; i < nums.length - 1; i++) {
const byte = nums[i] as number
if (byte > 0xff) return null
value = value * 256 + byte
}
const remaining = 4 - (nums.length - 1)
const last = nums[nums.length - 1] as number
if (last > 256 ** remaining - 1) return null
value = value * 256 ** remaining + last
if (value > 0xffffffff) return null
return [(value >>> 24) & 0xff, (value >>> 16) & 0xff]
}
export const isPrivateHost = (hostname: string): boolean => {
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase()
if (host === 'localhost' || host.endsWith('.localhost')) return true
if (host.includes(':')) {
// --- IPv6 (and IPv4-mapped IPv6) ---
if (host === '::1' || host === '::') return true
// fe80::/10 link-local spans fe80–febf (third nibble 8–b).
if (/^fe[89ab][0-9a-f]:/.test(host)) return true
if (host.startsWith('fc') || host.startsWith('fd')) return true // fc00::/7 unique-local
// IPv4-mapped/compatible, dotted (`::ffff:127.0.0.1`) — the URL parser
// rewrites this to hex, but a direct caller may pass the dotted form.
const dotted = /:((?:\d{1,3}\.){3}\d{1,3})$/.exec(host)
if (dotted?.[1]) {
const oct = ipv4Octets(dotted[1])
if (oct) return isPrivateIpv4(oct[0], oct[1])
}
// IPv4-mapped, hex (`::ffff:7f00:1`) — what `new URL()` produces.
const hex = /:ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host)
if (hex?.[1] && hex[2]) {
const hi = Number.parseInt(hex[1], 16)
return isPrivateIpv4((hi >> 8) & 0xff, hi & 0xff)
}
return false
}
// --- IPv4 (any inet_aton encoding) ---
const oct = ipv4Octets(host)
if (oct) return isPrivateIpv4(oct[0], oct[1])
return false
}
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { clearRemoteCache, resolveRefsFromFile } from './resolve-refs-from-file'
describe('resolve-refs-from-file', () => {
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'resolve-refs-'))
clearRemoteCache()
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
vi.restoreAllMocks()
})
it('inlines a cross-file $ref between local documents', async () => {
writeFileSync(
join(dir, 'pet.json'),
JSON.stringify({ Pet: { type: 'object', properties: { name: { type: 'string' } } } }),
)
writeFileSync(
join(dir, 'api.json'),
JSON.stringify({ components: { schemas: { Pet: { $ref: './pet.json#/Pet' } } } }),
)
const { resolved, errors } = await resolveRefsFromFile(join(dir, 'api.json'))
expect(errors).toEqual([])
expect(resolved).toMatchObject({
components: { schemas: { Pet: { type: 'object', properties: { name: { type: 'string' } } } } },
})
})
it('inlines an internal $ref within a single document', async () => {
writeFileSync(join(dir, 'root.json'), JSON.stringify({ a: { $ref: '#/b' }, b: { value: 1 } }))
const { resolved } = await resolveRefsFromFile(join(dir, 'root.json'))
expect(resolved).toMatchObject({ a: { value: 1 }, b: { value: 1 } })
})
it('omits the origin map unless trackOrigins is set', async () => {
writeFileSync(join(dir, 'root.json'), JSON.stringify({ a: { $ref: '#/b' }, b: { value: 1 } }))
const result = await resolveRefsFromFile(join(dir, 'root.json'))
expect(result.origins).toBeUndefined()
})
it('stamps inlined nodes with their origin document and in-file path', async () => {
const petPath = join(dir, 'pet.json')
const apiPath = join(dir, 'api.json')
writeFileSync(petPath, JSON.stringify({ Pet: { type: 'object', properties: { name: { type: 'string' } } } }))
writeFileSync(
apiPath,
JSON.stringify({
components: { schemas: { Pet: { $ref: './pet.json#/Pet' }, Pet2: { $ref: './pet.json#/Pet' } } },
widget: { type: 'object' },
useWidget: { $ref: '#/widget' },
}),
)
const { resolved, origins } = await resolveRefsFromFile(apiPath, { trackOrigins: true })
expect(origins).toBeDefined()
const tree = resolved as {
components: { schemas: { Pet: object; Pet2: object } }
useWidget: object
}
// The cross-file node is stamped with pet.json and its in-file path; both call
// sites share the one inlined object, so the stamp identifies the definition.
expect(tree.components.schemas.Pet).toBe(tree.components.schemas.Pet2)
expect(origins?.get(tree.components.schemas.Pet)).toEqual({ location: petPath, pointer: ['Pet'] })
// An internal ref is stamped against the root document at the target path.
expect(origins?.get(tree.useWidget)).toEqual({ location: apiPath, pointer: ['widget'] })
})
it('keeps the definition origin when a node is reached through a chained ref (first-write-wins)', async () => {
const petPath = join(dir, 'pet.json')
const apiPath = join(dir, 'api.json')
writeFileSync(petPath, JSON.stringify({ Pet: { type: 'object' } }))
writeFileSync(
apiPath,
JSON.stringify({
components: { schemas: { Pet: { $ref: './pet.json#/Pet' } } },
// Resolves through the internal ref to the same pet.json object.
alias: { $ref: '#/components/schemas/Pet' },
}),
)
const { resolved, origins } = await resolveRefsFromFile(apiPath, { trackOrigins: true })
const tree = resolved as { components: { schemas: { Pet: object } }; alias: object }
// `alias` resolves through to the same object; its origin stays the pet.json
// definition rather than the intermediate root-document pointer.
expect(tree.alias).toBe(tree.components.schemas.Pet)
expect(origins?.get(tree.alias)).toEqual({ location: petPath, pointer: ['Pet'] })
})
it('records an error and degrades to {} when a referenced file is missing', async () => {
writeFileSync(join(dir, 'api.json'), JSON.stringify({ x: { $ref: './missing.json#/Nope' } }))
const { resolved, errors } = await resolveRefsFromFile(join(dir, 'api.json'))
// The missing document degrades to {}, so the pointer into it misses and the
// ref resolves to undefined — the important part is that we recorded the
// failure instead of throwing.
expect((resolved as { x: unknown }).x).toBeUndefined()
expect(errors.length).toBeGreaterThan(0)
})
it('refuses a remote $ref to a private host by default (SSRF guard)', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch')
writeFileSync(
join(dir, 'api.json'),
JSON.stringify({ x: { $ref: 'http://169.254.169.254/latest/meta-data#/foo' } }),
)
const { resolved, errors } = await resolveRefsFromFile(join(dir, 'api.json'))
// The refused document degrades to {}, so the pointer into it misses.
expect((resolved as { x: unknown }).x).toBeUndefined()
expect(errors[0]?.message).toMatch(/Refusing to resolve remote \$ref/)
// The guard is syntactic — we never even attempt the request.
expect(fetchSpy).not.toHaveBeenCalled()
})
it('refuses any remote $ref when remote resolution is disabled', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch')
writeFileSync(join(dir, 'api.json'), JSON.stringify({ x: { $ref: 'https://example.com/s.json#/Foo' } }))
const { errors } = await resolveRefsFromFile(join(dir, 'api.json'), { remote: false })
expect(errors[0]?.message).toMatch(/remote \$ref resolution is disabled/)
expect(fetchSpy).not.toHaveBeenCalled()
})
it('uses a custom parse callback to load non-JSON (e.g. YAML) documents', async () => {
// Real YAML that JSON.parse would reject — a custom callback handles it.
writeFileSync(join(dir, 'contact.yaml'), 'type: object\nproperties:\n name:\n type: string\n')
writeFileSync(join(dir, 'api.json'), JSON.stringify({ contact: { $ref: './contact.yaml' } }))
const parse = (content: string, location: string): unknown => {
if (/\.ya?ml$/i.test(location)) return { type: 'object', properties: { name: { type: 'string' } } }
return JSON.parse(content) as unknown
}
const { resolved, errors } = await resolveRefsFromFile(join(dir, 'api.json'), { parse })
expect(errors).toEqual([])
expect(resolved).toMatchObject({ contact: { type: 'object', properties: { name: { type: 'string' } } } })
})
it('fetches an allow-listed remote $ref and caches it for the session', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ Foo: { type: 'string' } }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
)
writeFileSync(
join(dir, 'api.json'),
JSON.stringify({
a: { $ref: 'https://api.example.com/s.json#/Foo' },
b: { $ref: 'https://api.example.com/s.json#/Foo' },
}),
)
const { resolved, errors } = await resolveRefsFromFile(join(dir, 'api.json'), {
allowedHosts: ['api.example.com'],
})
expect(errors).toEqual([])
expect(resolved).toMatchObject({ a: { type: 'string' }, b: { type: 'string' } })
// Both refs hit the same document, which is fetched and cached exactly once.
expect(fetchSpy).toHaveBeenCalledTimes(1)
})
it('refuses a redirect that lands on a private host (SSRF via redirect)', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(
new Response(null, { status: 302, headers: { location: 'http://169.254.169.254/latest/meta-data' } }),
)
writeFileSync(join(dir, 'api.json'), JSON.stringify({ x: { $ref: 'https://api.example.com/s.json#/Foo' } }))
const { resolved, errors } = await resolveRefsFromFile(join(dir, 'api.json'), {
allowedHosts: ['api.example.com'],
})
expect((resolved as { x: unknown }).x).toBeUndefined()
expect(errors[0]?.message).toMatch(/refusing to follow redirect/i)
// The initial host was allowed, so the first request happened — but the
// redirect target was re-checked and refused before any second request.
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(fetchSpy).toHaveBeenCalledWith('https://api.example.com/s.json', { redirect: 'manual' })
})
it('follows a redirect to another allowed host', async () => {
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(
new Response(null, { status: 301, headers: { location: 'https://cdn.example.com/s.json' } }),
)
.mockResolvedValueOnce(new Response(JSON.stringify({ Foo: { type: 'string' } }), { status: 200 }))
writeFileSync(join(dir, 'api.json'), JSON.stringify({ x: { $ref: 'https://api.example.com/s.json#/Foo' } }))
const { resolved, errors } = await resolveRefsFromFile(join(dir, 'api.json'), {
allowedHosts: ['api.example.com', 'cdn.example.com'],
})
expect(errors).toEqual([])
expect(resolved).toMatchObject({ x: { type: 'string' } })
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
it('coalesces two concurrent resolves of the same remote URL into one fetch', async () => {
let resolveFetch: ((r: Response) => void) | undefined
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(
() =>
new Promise<Response>((res) => {
resolveFetch = res
}),
)
writeFileSync(join(dir, 'api.json'), JSON.stringify({ x: { $ref: 'https://api.example.com/s.json#/Foo' } }))
// Two passes start before either fetch settles — they must share one request.
const pass1 = resolveRefsFromFile(join(dir, 'api.json'), { allowedHosts: ['api.example.com'] })
const pass2 = resolveRefsFromFile(join(dir, 'api.json'), { allowedHosts: ['api.example.com'] })
await Promise.resolve()
resolveFetch?.(new Response(JSON.stringify({ Foo: { type: 'string' } }), { status: 200 }))
const [r1, r2] = await Promise.all([pass1, pass2])
expect(r1.errors).toEqual([])
expect(r2.errors).toEqual([])
expect(r1.resolved).toMatchObject({ x: { type: 'string' } })
expect(r2.resolved).toMatchObject({ x: { type: 'string' } })
expect(fetchSpy).toHaveBeenCalledTimes(1)
})
})
import { readFileSync } from 'node:fs'
import { dirname, resolve as resolvePath } from 'node:path'
import { getByPointer, pointerToPath } from './get-by-pointer'
import { isPrivateHost } from './is-private-host'
import type { OriginMap, ResolveError, ResolveOptions, ResolveResult } from './types'
// A ref currently mid-resolution is marked with this sentinel; revisiting it
// means a cycle, so we return `{}` instead of recursing forever.
const CYCLE = Symbol('cycle')
type CacheValue = unknown | typeof CYCLE
// --- Document location helpers ---------------------------------------------
//
// A "location" is the absolute identity of a document: either an absolute file
// path or an absolute http(s) URL. Refs are resolved relative to the location
// of the document they appear in, so a relative ref inside a remote document
// resolves to another remote URL, and one inside a local file to another path.
const isRemote = (location: string): boolean => /^https?:\/\//i.test(location)
/** Resolves the location of `ref` (its file/URL part) relative to `base`. */
const joinLocation = (base: string, ref: string): string => {
if (isRemote(ref)) return ref
if (isRemote(base)) return new URL(ref, base).href
return resolvePath(dirname(base), ref)
}
/** Splits a `$ref` into its document part and JSON-pointer part. */
const splitRef = (ref: string): { filePart: string; pointer: string } => {
const hashIdx = ref.indexOf('#')
return {
filePart: hashIdx === -1 ? ref : ref.slice(0, hashIdx),
pointer: hashIdx === -1 ? '' : ref.slice(hashIdx + 1),
}
}
// --- Remote document cache -------------------------------------------------
//
// Fetched remote documents are cached in memory for the lifetime of the
// process ("the session"). Local files are intentionally NOT cached across
// resolve passes — they can change on disk during a long-lived session (e.g.
// an editor/LSP), so each pass re-reads them. Remote documents are assumed
// stable for the session; call `clearRemoteCache()` to drop them.
const remoteCache = new Map<string, unknown>()
// In-flight remote loads, keyed by location. Two resolve passes that start at
// the same time and reach the same URL share a single fetch instead of racing
// two requests; whichever arrives first installs the promise, the rest await it.
const inFlight = new Map<string, Promise<unknown>>()
/** Drops every cached remote document. Mainly useful for tests/long sessions. */
export const clearRemoteCache = (): void => {
remoteCache.clear()
}
// Cap on redirect hops before we give up — generous enough for real services,
// low enough to stop a redirect loop from spinning forever.
const MAX_REDIRECTS = 5
/**
* Fetches and parses a remote document, following redirects manually so the
* SSRF guard is re-applied to every hop. `fetch` follows redirects by default,
* which would let an allow-listed public URL bounce to a private/loopback
* address (e.g. the `169.254.169.254` metadata endpoint) — so we set
* `redirect: 'manual'` and re-run {@link denialReason} on each `Location`.
*/
const fetchRemote = async (location: string, options: ResolveOptions): Promise<unknown> => {
let current = location
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const reason = denialReason(current, options)
if (reason !== null) throw new Error(`refusing to follow redirect (${reason}): ${current}`)
const response = await fetch(current, { redirect: 'manual' })
if (response.status >= 300 && response.status < 400) {
const next = response.headers.get('location')
if (!next) throw new Error(`HTTP ${response.status} redirect with no Location header`)
current = new URL(next, current).href
continue
}
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`)
const parse = options.parse ?? ((c: string) => JSON.parse(c) as unknown)
// Parse against the original request location so the caller's format sniffing
// (e.g. `.yaml` vs `.json`) and any relative refs key off a stable identity.
return parse(await response.text(), location)
}
throw new Error(`too many redirects (>${MAX_REDIRECTS}): ${location}`)
}
/** Returns why a remote location may not be fetched, or `null` if it is allowed. */
const denialReason = (location: string, options: ResolveOptions): string | null => {
if (options.remote === false) return 'remote $ref resolution is disabled'
let url: URL
try {
url = new URL(location)
} catch {
return 'the URL is invalid'
}
const allow = options.allowedHosts
// An explicit allow-list entry is an intentional opt-in and bypasses the
// private-host guard; otherwise refuse non-public targets by default.
if (allow && allow.length > 0) {
return allow.includes(url.host) ? null : 'host is not in the allow-list'
}
if (!options.allowPrivateHosts && isPrivateHost(url.hostname)) {
return 'host resolves to a private or loopback address (set allowPrivateHosts to permit)'
}
return null
}
/**
* Loads a document into `docCache`, fetching/reading it if needed. Remote
* documents are additionally cached for the session in `remoteCache`. On
* failure an error is recorded and the location is cached as `{}` so that
* pointer lookups degrade gracefully instead of throwing. Returns whether the
* document loaded successfully.
*/
const loadDoc = async (
location: string,
docCache: Map<string, unknown>,
options: ResolveOptions,
errors: ResolveError[],
): Promise<boolean> => {
if (docCache.has(location)) return true
if (isRemote(location)) {
if (remoteCache.has(location)) {
docCache.set(location, remoteCache.get(location))
return true
}
const reason = denialReason(location, options)
if (reason !== null) {
errors.push({ message: `Refusing to resolve remote $ref (${reason}): ${location}`, path: [] })
docCache.set(location, {})
return false
}
// Coalesce concurrent loads of the same URL onto one in-flight request; the
// owner (first caller) clears the slot once it settles.
let pending = inFlight.get(location)
const owner = pending === undefined
if (pending === undefined) {
pending = fetchRemote(location, options)
inFlight.set(location, pending)
}
try {
const doc = await pending
remoteCache.set(location, doc)
docCache.set(location, doc)
return true
} catch (err) {
errors.push({ message: `Failed to fetch ${location}: ${String(err)}`, path: [] })
docCache.set(location, {})
return false
} finally {
if (owner) inFlight.delete(location)
}
}
try {
const parse = options.parse ?? ((c: string) => JSON.parse(c) as unknown)
docCache.set(location, parse(readFileSync(location, 'utf8'), location))
return true
} catch (err) {
errors.push({ message: String(err), path: [] })
docCache.set(location, {})
return false
}
}
/** Collects the distinct document parts of every `$ref` directly under `node`. */
const collectRefTargets = (node: unknown, out: Set<string>): Set<string> => {
if (node === null || typeof node !== 'object') return out
if (Array.isArray(node)) {
for (const item of node) collectRefTargets(item, out)
return out
}
const obj = node as Record<string, unknown>
if (typeof obj['$ref'] === 'string') {
// A `$ref` object's siblings are ignored on resolution, so we do not recurse
// into them — mirroring resolveAt's behaviour.
const { filePart } = splitRef(obj['$ref'])
if (filePart !== '') out.add(filePart)
return out
}
for (const key of Object.keys(obj)) collectRefTargets(obj[key], out)
return out
}
/**
* Walks every reachable document starting from `rootLocation`, loading each one
* so the synchronous resolve pass can look them up. This is the only async part
* of resolution: remote documents are fetched here (in dependency order) and
* cached for the session.
*/
const prefetch = async (
rootLocation: string,
docCache: Map<string, unknown>,
options: ResolveOptions,
errors: ResolveError[],
): Promise<void> => {
const seen = new Set<string>([rootLocation])
const queue: string[] = [rootLocation]
while (queue.length > 0) {
const location = queue.shift() as string
for (const filePart of collectRefTargets(docCache.get(location), new Set())) {
const target = joinLocation(location, filePart)
if (seen.has(target)) continue
seen.add(target)
await loadDoc(target, docCache, options, errors)
queue.push(target)
}
}
}
/**
* Single-pass resolver that inlines internal and external (`$ref` to other
* file/URL) references. Every reachable document has already been loaded into
* `docCache` by `prefetch`, so this stays synchronous. Refs are resolved once
* (`refCache`); the CYCLE sentinel short-circuits re-entrant resolution.
*
* `baseLocation` is the location of the document `node` belongs to, used to
* resolve relative refs and `#/...` pointers within it.
*/
const resolveAt = (
node: unknown,
baseLocation: string,
docCache: Map<string, unknown>,
refCache: Map<string, CacheValue>,
origins: OriginMap | undefined,
): unknown => {
if (node === null || typeof node !== 'object') return node
if (Array.isArray(node)) {
return node.map((item) => resolveAt(item, baseLocation, docCache, refCache, origins))
}
const obj = node as Record<string, unknown>
if (typeof obj['$ref'] === 'string') {
const { filePart, pointer } = splitRef(obj['$ref'])
const targetLocation = filePart === '' ? baseLocation : joinLocation(baseLocation, filePart)
const targetRoot = docCache.get(targetLocation) ?? {}
const cacheKey = `${targetLocation}#${pointer}`
if (refCache.has(cacheKey)) {
const cached = refCache.get(cacheKey)
return cached === CYCLE ? {} : cached
}
refCache.set(cacheKey, CYCLE)
const target = pointer ? getByPointer(targetRoot, pointer) : targetRoot
const resolved = resolveAt(target, targetLocation, docCache, refCache, origins)
refCache.set(cacheKey, resolved)
// Stamp the inlined node with where it was defined so a consumer can map a
// resolved-tree node back to its source document/path. Only objects/arrays are
// stamped (primitives can't key the map). First-write-wins: resolution recurses
// to the deepest `$ref` before returning, so the *definition* site stamps first;
// an outer ref that merely points (transitively) at the same object must not
// overwrite it with an intermediate location.
if (origins && resolved !== null && typeof resolved === 'object' && !origins.has(resolved)) {
origins.set(resolved, { location: targetLocation, pointer: pointerToPath(pointer) })
}
return resolved
}
const result: Record<string, unknown> = {}
for (const key of Object.keys(obj)) {
result[key] = resolveAt(obj[key], baseLocation, docCache, refCache, origins)
}
return result
}
/**
* Resolves `$ref`s in a document on disk (or at a URL), including cross-file
* and remote refs. Remote documents are fetched on the fly and cached in memory
* for the session; `options` governs whether/which remote hosts are allowed.
*/
export const resolveRefsFromFile = async (filename: string, options: ResolveOptions = {}): Promise<ResolveResult> => {
const rootLocation = isRemote(filename) ? filename : resolvePath(filename)
const errors: ResolveError[] = []
const docCache = new Map<string, unknown>()
if (!(await loadDoc(rootLocation, docCache, options, errors))) {
return { resolved: {}, errors }
}
await prefetch(rootLocation, docCache, options, errors)
const origins: OriginMap | undefined = options.trackOrigins ? new Map() : undefined
const resolved = resolveAt(docCache.get(rootLocation), rootLocation, docCache, new Map(), origins)
return origins ? { resolved, errors, origins } : { resolved, errors }
}
import { describe, expect, it } from 'vitest'
import { resolveRefs } from './resolve-refs'
describe('resolve-refs', () => {
it('inlines an internal $ref', () => {
const { resolved, errors } = resolveRefs({
type: 'object',
properties: { contact: { $ref: '#/$defs/contact' } },
$defs: { contact: { type: 'string' } },
})
expect(errors).toEqual([])
expect(resolved).toMatchObject({ properties: { contact: { type: 'string' } } })
})
it('breaks a self-referential cycle with an empty object', () => {
const { resolved } = resolveRefs({
$defs: { node: { type: 'object', properties: { next: { $ref: '#/$defs/node' } } } },
properties: { head: { $ref: '#/$defs/node' } },
})
const head = (resolved as { properties: { head: { properties: { next: unknown } } } }).properties.head
// The first level resolves; the recursive self-reference terminates at `{}`.
expect(head.properties.next).toEqual({})
})
it('breaks a mutual cycle (A → B → A) without infinite recursion', () => {
const { resolved } = resolveRefs({
$defs: {
a: { type: 'object', properties: { b: { $ref: '#/$defs/b' } } },
b: { type: 'object', properties: { a: { $ref: '#/$defs/a' } } },
},
properties: { root: { $ref: '#/$defs/a' } },
})
// Resolution terminates; `a` is in the cache by the time `properties.root` is
// processed, so we get the partially-resolved shape where the back-reference
// leg terminated at `{}` rather than looping forever.
const root = (resolved as { properties: { root: { type: string; properties: { b: unknown } } } }).properties.root
expect(root.type).toBe('object')
expect(root.properties.b).toEqual({})
})
it('leaves external (non-#) refs untouched', () => {
const { resolved } = resolveRefs({ properties: { pet: { $ref: 'pet.json#/Pet' } } })
expect(resolved).toEqual({ properties: { pet: { $ref: 'pet.json#/Pet' } } })
})
it('omits the origin map unless trackOrigins is set', () => {
const result = resolveRefs({ a: { $ref: '#/$defs/x' }, $defs: { x: { type: 'string' } } })
expect(result.origins).toBeUndefined()
})
it('stamps each inlined node with its in-document origin path', () => {
const { resolved, origins } = resolveRefs(
{
properties: { a: { $ref: '#/$defs/x' }, b: { $ref: '#/$defs/x' } },
$defs: { x: { type: 'object', properties: { id: { type: 'string' } } } },
},
{ trackOrigins: true },
)
const tree = resolved as { properties: { a: object; b: object } }
// Repeated refs share one object, stamped once with the definition path.
expect(tree.properties.a).toBe(tree.properties.b)
expect(origins?.get(tree.properties.a)).toEqual({ location: '', pointer: ['$defs', 'x'] })
})
it('decodes pointer escapes and array indices in origin paths', () => {
const { resolved, origins } = resolveRefs(
{ ref: { $ref: '#/a~1b/c~0d/0' }, 'a/b': { 'c~d': [{ type: 'number' }] } },
{ trackOrigins: true },
)
const node = (resolved as { ref: object }).ref
expect(origins?.get(node)).toEqual({ location: '', pointer: ['a/b', 'c~d', 0] })
})
})
import { getByPointer, pointerToPath } from './get-by-pointer'
import type { OriginMap, ResolveResult } from './types'
// A ref currently mid-resolution is marked with this sentinel; revisiting it
// means we have looped, so we return `{}` instead of recursing forever.
const CYCLE = Symbol('cycle')
type CacheValue = unknown | typeof CYCLE
/** Options for the in-memory resolver. */
export type ResolveRefsOptions = {
/**
* Record a per-node origin map on the result (`origins`). For every object or
* array inlined in place of a `$ref`, the map records the in-document path it
* was defined at (the `location` is `''`, the single in-memory document).
* Defaults to `false`.
*/
trackOrigins?: boolean
}
/**
* Single-pass internal-only `$ref` resolver. Each unique ref string is resolved
* exactly once (the `cache`); revisiting a ref that is still mid-resolution
* means a cycle, so we return `{}` (the CYCLE sentinel). Non-`#` refs (external
* files, HTTP) are left untouched — callers that need those should use
* `resolveRefsFromFile`.
*/
const resolveInternal = (
node: unknown,
root: unknown,
cache: Map<string, CacheValue>,
origins: OriginMap | undefined,
): unknown => {
if (node === null || typeof node !== 'object') return node
if (Array.isArray(node)) return node.map((item) => resolveInternal(item, root, cache, origins))
const obj = node as Record<string, unknown>
if (typeof obj['$ref'] === 'string') {
const ref = obj['$ref']
if (!ref.startsWith('#')) return obj // external ref — leave as-is
if (cache.has(ref)) {
const cached = cache.get(ref)
return cached === CYCLE ? {} : cached
}
cache.set(ref, CYCLE)
const resolved = resolveInternal(getByPointer(root, ref.slice(1)), root, cache, origins)
cache.set(ref, resolved)
// Stamp the inlined node with the path it was defined at (see resolveAt).
// First-write-wins so the deepest definition stamps before any outer ref that
// transitively points at the same object. Primitives can't key the map.
if (origins && resolved !== null && typeof resolved === 'object' && !origins.has(resolved)) {
origins.set(resolved, { location: '', pointer: pointerToPath(ref.slice(1)) })
}
return resolved
}
const result: Record<string, unknown> = {}
for (const key of Object.keys(obj)) {
result[key] = resolveInternal(obj[key], root, cache, origins)
}
return result
}
/**
* Resolves all internal (`#/...`) `$ref`s in an in-memory document, inlining
* each target. External and remote refs are left as-is. Cycles are broken with
* an empty object so the result is always finite.
*/
export const resolveRefs = (data: unknown, options: ResolveRefsOptions = {}): ResolveResult => {
const origins: OriginMap | undefined = options.trackOrigins ? new Map() : undefined
const resolved = resolveInternal(data, data, new Map(), origins)
return origins ? { resolved, errors: [], origins } : { resolved, errors: [] }
}
/**
* A path into a JSON document: object keys and array indices, in order. Errors
* report the location of the offending `$ref` with one of these.
*/
export type JsonPath = (string | number)[]
/** A single `$ref` resolution failure (a missing file, a bad URL, a refusal). */
export type ResolveError = {
message: string
path: JsonPath
}
/**
* Where an inlined node came from: the absolute location (file path or URL, or
* `''` for the single in-memory document) of the document it was defined in, and
* the path to it within that document.
*/
export type Origin = {
location: string
pointer: JsonPath
}
/**
* Per-node origin map produced when `trackOrigins` is set: maps each object/array
* that was inlined in place of a `$ref` to where it was defined. A consumer can
* then attribute a node in the resolved tree back to its source document and path
* with a single lookup instead of re-deriving the `$ref` traversal. Keyed by node
* identity, so it relies on the resolver sharing one object per repeated `$ref`
* target (which it does).
*/
export type OriginMap = Map<object, Origin>
/** The outcome of a resolve pass: the dereferenced document plus any errors. */
export type ResolveResult = {
/** The dereferenced document (all resolvable `$ref`s inlined). */
resolved: unknown
errors: ResolveError[]
/**
* Per-node origin map. Present only when `trackOrigins` was requested; each
* entry maps an inlined object/array to the document and path it came from.
*/
origins?: OriginMap
}
/** Controls how external `$ref`s to other documents are loaded. */
export type ResolveOptions = {
/**
* Whether http(s) `$ref`s may be fetched. Defaults to `true`.
*/
remote?: boolean
/**
* If non-empty, only these hosts (e.g. `api.example.com`) may be fetched for
* remote `$ref`s. An empty/undefined list allows any host (subject to
* `remote`). An explicit entry here always bypasses the private-host guard.
*/
allowedHosts?: string[]
/**
* Allow remote `$ref`s to loopback, private, link-local, and other
* non-public addresses. Defaults to `false`: such hosts are refused as a
* best-effort SSRF guard (notably the `169.254.169.254` cloud-metadata
* endpoint). An explicit `allowedHosts` entry always bypasses this guard.
*/
allowPrivateHosts?: boolean
/**
* Custom content parser. Receives the raw text of every loaded document and
* its absolute location (file path or URL). Defaults to `JSON.parse`.
*
* Pass a YAML-aware function to support `.yaml`/`.yml` documents without
* adding a dependency to this package:
*
* ```ts
* import { parse as parseYaml } from 'yaml'
*
* resolveRefsFromFile(path, {
* parse: (content, location) =>
* /\.ya?ml$/i.test(location) ? parseYaml(content) : JSON.parse(content),
* })
* ```
*/
parse?: (content: string, location: string) => unknown
/**
* Record a per-node origin map on the result (`origins`). For every object or
* array inlined in place of a `$ref`, the map records the document and in-file
* path it was defined at, so a consumer can attribute resolved-tree nodes back
* to their source without re-walking the `$ref` chain. Defaults to `false`.
*/
trackOrigins?: boolean
}