🎩 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.1.2
to
0.2.0
+8
-0
dist/get-by-pointer.d.ts

@@ -0,2 +1,10 @@

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.
*/
export declare const pointerToPath: (pointer: string) => JsonPath;
/**
* Walks a JSON Pointer string (RFC 6901) to the value it points to within

@@ -3,0 +11,0 @@ * `root`. A bare `''` or `'/'` returns the root document. Segment escapes are

+1
-1

@@ -1,1 +0,1 @@

{"version":3,"file":"get-by-pointer.d.ts","sourceRoot":"","sources":["../src/get-by-pointer.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,YAAY,SAAU,OAAO,WAAW,MAAM,KAAG,OAuB7D,CAAA"}
{"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"}
/**
* 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) => {
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

@@ -3,0 +27,0 @@ * `root`. A bare `''` or `'/'` returns the root document. Segment escapes are

@@ -1,6 +0,6 @@

export { getByPointer } from './get-by-pointer.js';
export { getByPointer, pointerToPath } from './get-by-pointer.js';
export { isPrivateHost } from './is-private-host.js';
export { resolveRefs } from './resolve-refs.js';
export { type ResolveRefsOptions, resolveRefs } from './resolve-refs.js';
export { clearRemoteCache, resolveRefsFromFile } from './resolve-refs-from-file.js';
export type { JsonPath, ResolveError, ResolveOptions, ResolveResult } from './types.js';
export type { JsonPath, Origin, OriginMap, ResolveError, ResolveOptions, ResolveResult } from './types.js';
//# sourceMappingURL=index.d.ts.map

@@ -1,1 +0,1 @@

{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AACjD,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC5C,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAChF,YAAY,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,SAAS,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"}

@@ -1,4 +0,4 @@

export { getByPointer } from './get-by-pointer.js';
export { getByPointer, pointerToPath } from './get-by-pointer.js';
export { isPrivateHost } from './is-private-host.js';
export { resolveRefs } from './resolve-refs.js';
export { clearRemoteCache, resolveRefsFromFile } from './resolve-refs-from-file.js';

@@ -1,1 +0,1 @@

{"version":3,"file":"is-private-host.d.ts","sourceRoot":"","sources":["../src/is-private-host.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa,aAAc,MAAM,KAAG,OAyBhD,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"}

@@ -9,2 +9,63 @@ /**

*/
/** True for an IPv4 address (given its first two octets) in a non-public range. */
const isPrivateIpv4 = (a, b) => {
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) => {
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) => {
if (!/^[0-9a-fx.]+$/i.test(host))
return null;
const parts = host.split('.');
if (parts.length === 0 || parts.length > 4)
return null;
const nums = [];
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];
if (byte > 0xff)
return null;
value = value * 256 + byte;
}
const remaining = 4 - (nums.length - 1);
const last = nums[nums.length - 1];
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) => {

@@ -14,30 +75,32 @@ const host = hostname.replace(/^\[|\]$/g, '').toLowerCase();

return true;
// IPv4 (incl. IPv4-mapped IPv6 like ::ffff:127.0.0.1)
const v4 = host.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (v4) {
const a = Number(v4[1]);
const b = Number(v4[2]);
if (a === 10 || a === 127 || a === 0)
return true;
if (a === 169 && b === 254)
return true; // link-local + 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;
}
// IPv6
if (host.includes(':')) {
// --- IPv6 (and IPv4-mapped IPv6) ---
if (host === '::1' || host === '::')
return true;
if (host.startsWith('fe80:') || host.startsWith('fe80::'))
return true; // link-local
// 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; // unique-local
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;
};

@@ -1,1 +0,1 @@

{"version":3,"file":"resolve-refs-from-file.d.ts","sourceRoot":"","sources":["../src/resolve-refs-from-file.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAgB,cAAc,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AA0C1E,iFAAiF;AACjF,eAAO,MAAM,gBAAgB,QAAO,IAEnC,CAAA;AAqKD;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,aAAoB,MAAM,YAAW,cAAc,KAAQ,OAAO,CAAC,aAAa,CAW/G,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"}
import { readFileSync } from 'node:fs';
import { dirname, resolve as resolvePath } from 'node:path';
import { getByPointer } from './get-by-pointer.js';
import { getByPointer, pointerToPath } from './get-by-pointer.js';
import { isPrivateHost } from './is-private-host.js';

@@ -39,2 +39,6 @@ // A ref currently mid-resolution is marked with this sentinel; revisiting it

const remoteCache = new Map();
// 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();
/** Drops every cached remote document. Mainly useful for tests/long sessions. */

@@ -44,2 +48,35 @@ export const clearRemoteCache = () => {

};
// 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, options) => {
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) => JSON.parse(c));
// 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. */

@@ -88,8 +125,12 @@ const denialReason = (location, options) => {

}
// 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 response = await fetch(location);
if (!response.ok)
throw new Error(`HTTP ${response.status} ${response.statusText}`);
const parse = options.parse ?? ((c) => JSON.parse(c));
const doc = parse(await response.text(), location);
const doc = await pending;
remoteCache.set(location, doc);

@@ -104,2 +145,6 @@ docCache.set(location, doc);

}
finally {
if (owner)
inFlight.delete(location);
}
}

@@ -169,7 +214,7 @@ try {

*/
const resolveAt = (node, baseLocation, docCache, refCache) => {
const resolveAt = (node, baseLocation, docCache, refCache, origins) => {
if (node === null || typeof node !== 'object')
return node;
if (Array.isArray(node)) {
return node.map((item) => resolveAt(item, baseLocation, docCache, refCache));
return node.map((item) => resolveAt(item, baseLocation, docCache, refCache, origins));
}

@@ -188,4 +233,13 @@ const obj = node;

const target = pointer ? getByPointer(targetRoot, pointer) : targetRoot;
const resolved = resolveAt(target, targetLocation, docCache, refCache);
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;

@@ -195,3 +249,3 @@ }

for (const key of Object.keys(obj)) {
result[key] = resolveAt(obj[key], baseLocation, docCache, refCache);
result[key] = resolveAt(obj[key], baseLocation, docCache, refCache, origins);
}

@@ -213,4 +267,5 @@ return result;

await prefetch(rootLocation, docCache, options, errors);
const resolved = resolveAt(docCache.get(rootLocation), rootLocation, docCache, new Map());
return { resolved, errors };
const origins = options.trackOrigins ? new Map() : undefined;
const resolved = resolveAt(docCache.get(rootLocation), rootLocation, docCache, new Map(), origins);
return origins ? { resolved, errors, origins } : { resolved, errors };
};
import type { ResolveResult } from './types.js';
/** 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;
};
/**

@@ -7,3 +17,3 @@ * Resolves all internal (`#/...`) `$ref`s in an in-memory document, inlining

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

@@ -1,1 +0,1 @@

{"version":3,"file":"resolve-refs.d.ts","sourceRoot":"","sources":["../src/resolve-refs.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAuC5C;;;;GAIG;AACH,eAAO,MAAM,WAAW,SAAU,OAAO,KAAG,aAG3C,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"}

@@ -1,2 +0,2 @@

import { getByPointer } from './get-by-pointer.js';
import { getByPointer, pointerToPath } from './get-by-pointer.js';
// A ref currently mid-resolution is marked with this sentinel; revisiting it

@@ -12,7 +12,7 @@ // means we have looped, so we return `{}` instead of recursing forever.

*/
const resolveInternal = (node, root, cache) => {
const resolveInternal = (node, root, cache, origins) => {
if (node === null || typeof node !== 'object')
return node;
if (Array.isArray(node))
return node.map((item) => resolveInternal(item, root, cache));
return node.map((item) => resolveInternal(item, root, cache, origins));
const obj = node;

@@ -28,4 +28,10 @@ if (typeof obj['$ref'] === 'string') {

cache.set(ref, CYCLE);
const resolved = resolveInternal(getByPointer(root, ref.slice(1)), root, cache);
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;

@@ -35,3 +41,3 @@ }

for (const key of Object.keys(obj)) {
result[key] = resolveInternal(obj[key], root, cache);
result[key] = resolveInternal(obj[key], root, cache, origins);
}

@@ -45,5 +51,6 @@ return result;

*/
export const resolveRefs = (data) => {
const resolved = resolveInternal(data, data, new Map());
return { resolved, errors: [] };
export const resolveRefs = (data, options = {}) => {
const origins = options.trackOrigins ? new Map() : undefined;
const resolved = resolveInternal(data, data, new Map(), origins);
return origins ? { resolved, errors: [], origins } : { resolved, errors: [] };
};

@@ -11,2 +11,20 @@ /**

};
/**
* 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. */

@@ -17,2 +35,7 @@ export type ResolveResult = {

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;
};

@@ -55,3 +78,10 @@ /** Controls how external `$ref`s to other documents are loaded. */

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;
};
//# sourceMappingURL=types.d.ts.map

@@ -1,1 +0,1 @@

{"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,gFAAgF;AAChF,MAAM,MAAM,aAAa,GAAG;IAC1B,kEAAkE;IAClE,QAAQ,EAAE,OAAO,CAAA;IACjB,MAAM,EAAE,YAAY,EAAE,CAAA;CACvB,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;CACvD,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"}
{
"name": "@amritk/resolve-refs",
"version": "0.1.2",
"version": "0.2.0",
"description": "Resolve and inline JSON Schema / OpenAPI $refs — internal, cross-file, and remote — with session caching and a default-deny SSRF guard.",

@@ -5,0 +5,0 @@ "module": "./dist/index.js",

@@ -0,2 +1,27 @@

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

@@ -3,0 +28,0 @@ * `root`. A bare `''` or `'/'` returns the root document. Segment escapes are

@@ -1,5 +0,5 @@

export { getByPointer } from './get-by-pointer'
export { getByPointer, pointerToPath } from './get-by-pointer'
export { isPrivateHost } from './is-private-host'
export { resolveRefs } from './resolve-refs'
export { type ResolveRefsOptions, resolveRefs } from './resolve-refs'
export { clearRemoteCache, resolveRefsFromFile } from './resolve-refs-from-file'
export type { JsonPath, ResolveError, ResolveOptions, ResolveResult } from './types'
export type { JsonPath, Origin, OriginMap, ResolveError, ResolveOptions, ResolveResult } from './types'

@@ -31,2 +31,27 @@ import { describe, expect, it } from 'vitest'

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', () => {

@@ -36,3 +61,6 @@ expect(isPrivateHost('example.com')).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)
})
})

@@ -9,2 +9,53 @@ /**

*/
/** 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 => {

@@ -14,23 +65,29 @@ const host = hostname.replace(/^\[|\]$/g, '').toLowerCase()

// IPv4 (incl. IPv4-mapped IPv6 like ::ffff:127.0.0.1)
const v4 = host.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
if (v4) {
const a = Number(v4[1])
const b = Number(v4[2])
if (a === 10 || a === 127 || a === 0) return true
if (a === 169 && b === 254) return true // link-local + 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
}
// IPv6
if (host.includes(':')) {
// --- IPv6 (and IPv4-mapped IPv6) ---
if (host === '::1' || host === '::') return true
if (host.startsWith('fe80:') || host.startsWith('fe80::')) return true // link-local
if (host.startsWith('fc') || host.startsWith('fd')) return true // unique-local
// 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
}

@@ -47,2 +47,61 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'

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 () => {

@@ -126,2 +185,64 @@ writeFileSync(join(dir, 'api.json'), JSON.stringify({ x: { $ref: './missing.json#/Nope' } }))

})
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 } from './get-by-pointer'
import { getByPointer, pointerToPath } from './get-by-pointer'
import { isPrivateHost } from './is-private-host'
import type { ResolveError, ResolveOptions, ResolveResult } from './types'
import type { OriginMap, ResolveError, ResolveOptions, ResolveResult } from './types'

@@ -48,2 +48,7 @@ // A ref currently mid-resolution is marked with this sentinel; revisiting it

// 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. */

@@ -54,2 +59,36 @@ export const clearRemoteCache = (): void => {

// 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. */

@@ -104,7 +143,12 @@ const denialReason = (location: string, options: ResolveOptions): string | null => {

}
// 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 response = await fetch(location)
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`)
const parse = options.parse ?? ((c: string) => JSON.parse(c) as unknown)
const doc = parse(await response.text(), location)
const doc = await pending
remoteCache.set(location, doc)

@@ -117,2 +161,4 @@ docCache.set(location, doc)

return false
} finally {
if (owner) inFlight.delete(location)
}

@@ -191,6 +237,7 @@ }

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))
return node.map((item) => resolveAt(item, baseLocation, docCache, refCache, origins))
}

@@ -210,4 +257,13 @@ const obj = node as Record<string, unknown>

const target = pointer ? getByPointer(targetRoot, pointer) : targetRoot
const resolved = resolveAt(target, targetLocation, docCache, refCache)
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

@@ -217,3 +273,3 @@ }

for (const key of Object.keys(obj)) {
result[key] = resolveAt(obj[key], baseLocation, docCache, refCache)
result[key] = resolveAt(obj[key], baseLocation, docCache, refCache, origins)
}

@@ -237,4 +293,5 @@ return result

await prefetch(rootLocation, docCache, options, errors)
const resolved = resolveAt(docCache.get(rootLocation), rootLocation, docCache, new Map())
return { resolved, 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 }
}

@@ -50,2 +50,30 @@ import { describe, expect, it } from 'vitest'

})
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] })
})
})

@@ -1,3 +0,3 @@

import { getByPointer } from './get-by-pointer'
import type { ResolveResult } from './types'
import { getByPointer, pointerToPath } from './get-by-pointer'
import type { OriginMap, ResolveResult } from './types'

@@ -9,2 +9,13 @@ // A ref currently mid-resolution is marked with this sentinel; revisiting it

/** 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
}
/**

@@ -17,5 +28,10 @@ * Single-pass internal-only `$ref` resolver. Each unique ref string is resolved

*/
const resolveInternal = (node: unknown, root: unknown, cache: Map<string, CacheValue>): unknown => {
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))
if (Array.isArray(node)) return node.map((item) => resolveInternal(item, root, cache, origins))

@@ -31,4 +47,10 @@ const obj = node as Record<string, unknown>

cache.set(ref, CYCLE)
const resolved = resolveInternal(getByPointer(root, ref.slice(1)), root, cache)
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

@@ -39,3 +61,3 @@ }

for (const key of Object.keys(obj)) {
result[key] = resolveInternal(obj[key], root, cache)
result[key] = resolveInternal(obj[key], root, cache, origins)
}

@@ -50,5 +72,6 @@ return result

*/
export const resolveRefs = (data: unknown): ResolveResult => {
const resolved = resolveInternal(data, data, new Map())
return { resolved, errors: [] }
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: [] }
}

@@ -13,2 +13,22 @@ /**

/**
* 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. */

@@ -19,2 +39,7 @@ export type ResolveResult = {

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
}

@@ -58,2 +83,9 @@

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
}