@manifest-network/manifest-agent-core 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/close-lease.d.ts +3 -2
- package/dist/close-lease.d.ts.map +1 -1
- package/dist/close-lease.js +4 -3
- package/dist/close-lease.js.map +1 -1
- package/dist/deploy-app.d.ts +3 -2
- package/dist/deploy-app.d.ts.map +1 -1
- package/dist/deploy-app.js +245 -77
- package/dist/deploy-app.js.map +1 -1
- package/dist/internals/build-fred-input.d.ts +38 -0
- package/dist/internals/build-fred-input.d.ts.map +1 -0
- package/dist/internals/build-fred-input.js +147 -0
- package/dist/internals/build-fred-input.js.map +1 -0
- package/dist/internals/evaluate-readiness-from-fred.d.ts +28 -0
- package/dist/internals/evaluate-readiness-from-fred.d.ts.map +1 -0
- package/dist/internals/evaluate-readiness-from-fred.js +94 -0
- package/dist/internals/evaluate-readiness-from-fred.js.map +1 -0
- package/dist/internals/format-success.js.map +1 -1
- package/dist/internals/guarded-fetch.d.ts +2 -138
- package/dist/internals/guarded-fetch.js +1 -241
- package/dist/internals/humanize-denom.js.map +1 -1
- package/dist/internals/inspect-image.js.map +1 -1
- package/dist/internals/lease-items.js +1 -4
- package/dist/internals/lease-items.js.map +1 -1
- package/dist/internals/render-deployment-plan.js.map +1 -1
- package/dist/internals/verify-recover.js.map +1 -1
- package/dist/manage-domain.d.ts +3 -2
- package/dist/manage-domain.d.ts.map +1 -1
- package/dist/manage-domain.js +4 -3
- package/dist/manage-domain.js.map +1 -1
- package/dist/troubleshoot.js.map +1 -1
- package/dist/types.d.ts +19 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -5
- package/dist/internals/guarded-fetch.d.ts.map +0 -1
- package/dist/internals/guarded-fetch.js.map +0 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { evaluateReadiness } from "./evaluate-readiness.js";
|
|
2
|
+
//#region src/internals/evaluate-readiness-from-fred.ts
|
|
3
|
+
/**
|
|
4
|
+
* Translate fred's snake_case `CheckDeploymentReadinessResult` into
|
|
5
|
+
* the canonical `EvaluateReadinessInputs` (camelCase + context) and
|
|
6
|
+
* invoke `evaluateReadiness`. Returns the typed `Readiness` verdict
|
|
7
|
+
* the orchestrator gates on (`status === 'block'` → INVALID_CONFIG).
|
|
8
|
+
*
|
|
9
|
+
* @param raw fred's wire response (snake_case, readonly).
|
|
10
|
+
* @param gasPrice Gas-price string from `clientManager.getConfig().gasPrice`
|
|
11
|
+
* (e.g. `'1umfx'`). Required by the evaluator's
|
|
12
|
+
* wallet-gas check; defaulted upstream when absent.
|
|
13
|
+
* @param denomMap Pre-loaded `DenomMap` for humanization. Pass
|
|
14
|
+
* `EMPTY_DENOM_MAP` when no chain-data file is
|
|
15
|
+
* configured.
|
|
16
|
+
* @param tenantAddress Canonical tenant address from the orchestrator's
|
|
17
|
+
* address-source consistency guard. PREFERRED over
|
|
18
|
+
* `raw.tenant` so a fred response whose `tenant`
|
|
19
|
+
* differs (configuration drift / replayed mock)
|
|
20
|
+
* does NOT silently route the verdict against a
|
|
21
|
+
* different wallet.
|
|
22
|
+
*/
|
|
23
|
+
function evaluateReadinessFromFredResponse(raw, gasPrice, denomMap, tenantAddress) {
|
|
24
|
+
const skuNames = new Set(raw.available_sku_names);
|
|
25
|
+
if (raw.sku !== null) skuNames.add(raw.sku.name);
|
|
26
|
+
return evaluateReadiness({
|
|
27
|
+
tenant: tenantAddress,
|
|
28
|
+
image: raw.image,
|
|
29
|
+
size: raw.size,
|
|
30
|
+
walletBalances: toCoinArray(raw.wallet_balances),
|
|
31
|
+
credits: translateCredits(raw),
|
|
32
|
+
sku: translateSku(raw.sku),
|
|
33
|
+
availableSkuNames: [...skuNames],
|
|
34
|
+
gasPrice,
|
|
35
|
+
denomMap
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Translate fred's `credits` object + top-level `current_balance` /
|
|
40
|
+
* `hours_remaining` into the evaluator's nested `credits` input shape.
|
|
41
|
+
*
|
|
42
|
+
* Null preservation: when fred returns `credits: null`, the translator
|
|
43
|
+
* returns null even if `current_balance` / `hours_remaining` are
|
|
44
|
+
* present at the top level — synthesizing a credits object from the
|
|
45
|
+
* stray fields would suppress the "no credit account funded" warn
|
|
46
|
+
* rule the evaluator owns.
|
|
47
|
+
*/
|
|
48
|
+
function translateCredits(raw) {
|
|
49
|
+
if (raw.credits === null) return null;
|
|
50
|
+
const out = {};
|
|
51
|
+
if (Array.isArray(raw.credits.available_balances)) out.availableBalances = toCoinArray(raw.credits.available_balances);
|
|
52
|
+
if (Array.isArray(raw.credits.balances)) out.balances = toCoinArray(raw.credits.balances);
|
|
53
|
+
if (raw.current_balance !== void 0) out.currentBalance = toCoinArray(raw.current_balance);
|
|
54
|
+
if (raw.hours_remaining !== void 0) out.hoursRemaining = raw.hours_remaining;
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Translate fred's `SkuSummary | null` into the evaluator's
|
|
59
|
+
* `{ name: string; price: Coin } | null`.
|
|
60
|
+
*
|
|
61
|
+
* Drops fred-only fields (`uuid`, `provider_uuid`, `active`). Coerces
|
|
62
|
+
* a price-less SKU to `null` — the evaluator's `sku.price` is required
|
|
63
|
+
* (`Coin`), so an SKU without price is structurally invalid input.
|
|
64
|
+
* Without this coercion the evaluator would treat the SKU as truthy
|
|
65
|
+
* and crash accessing `price.amount`.
|
|
66
|
+
*/
|
|
67
|
+
function translateSku(sku) {
|
|
68
|
+
if (sku === null) return null;
|
|
69
|
+
if (sku.price === void 0) return null;
|
|
70
|
+
return {
|
|
71
|
+
name: sku.name,
|
|
72
|
+
price: {
|
|
73
|
+
denom: sku.price.denom,
|
|
74
|
+
amount: sku.price.amount
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Spread a readonly Coin-shaped array into a mutable Coin[]. The
|
|
80
|
+
* evaluator's `EvaluateReadinessInputs.walletBalances` / credit-balance
|
|
81
|
+
* arrays are mutable; fred's wire shapes are `ReadonlyArray<...>`. A
|
|
82
|
+
* shallow copy is sufficient — each element is a frozen-ish `{denom,
|
|
83
|
+
* amount}` value tuple, never mutated by the evaluator.
|
|
84
|
+
*/
|
|
85
|
+
function toCoinArray(arr) {
|
|
86
|
+
return arr.map((c) => ({
|
|
87
|
+
denom: c.denom,
|
|
88
|
+
amount: c.amount
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
//#endregion
|
|
92
|
+
export { evaluateReadinessFromFredResponse };
|
|
93
|
+
|
|
94
|
+
//# sourceMappingURL=evaluate-readiness-from-fred.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"evaluate-readiness-from-fred.js","names":[],"sources":["../../src/internals/evaluate-readiness-from-fred.ts"],"sourcesContent":["/**\n * Translator: fred's `CheckDeploymentReadinessResult` (snake_case wire\n * shape) → canonical `evaluateReadiness`'s `EvaluateReadinessInputs`\n * (camelCase + deploy-app context). ENG-185 sub-PR B, item 1.\n *\n * Replaces the always-`'ok'` stub `evaluateReadinessFromRaw` previously\n * inlined in `deploy-app.ts`. With this translator wired in, the\n * `status === 'block'` short-circuit at BOTH call sites\n * (`deploy-app.ts` L207 initial-spec + L327 post-edit recall) fires\n * correctly, killing the silent \"always proceed\" path the stub kept open.\n *\n * Three concerns the translator owns:\n *\n * 1. **Field renames** — snake_case → camelCase across all 9 fred\n * top-level fields the evaluator consumes (`wallet_balances` →\n * `walletBalances`, `available_sku_names` → `availableSkuNames`,\n * `credits.available_balances` → `credits.availableBalances`, etc.).\n *\n * 2. **Folding top-level → nested** — fred's `getBalance` emits\n * `current_balance` and `hours_remaining` ALONGSIDE `credits`\n * (top-level on the response); the evaluator's input nests them\n * INSIDE `credits` as `currentBalance` / `hoursRemaining`. The\n * translator moves them across the boundary.\n *\n * Guard: when fred returns `credits: null`, the translator\n * preserves null — synthesizing a credits object from the stray\n * top-level fields would bypass the evaluator's\n * `credits === null` warn rule (\"No credit account funded for\n * compute leases\").\n *\n * 3. **Context injection** — `gasPrice`, `denomMap`, and `tenant`\n * come from the orchestrator's scope (not from fred). For\n * `tenant`, the translator deliberately IGNORES `raw.tenant` and\n * uses the `tenantAddress` arg: the orchestrator already resolved\n * and validated the canonical wallet/client address via the\n * address-source consistency guard (deploy-app.ts L154-161).\n *\n * Also: drops fred sku fields `uuid` / `provider_uuid` / `active` (the\n * evaluator only needs `name` + `price`), and coerces a price-less\n * SKU to `null` — `EvaluateReadinessInputs.sku` requires `price: Coin`,\n * so an SKU without price is structurally not a valid input.\n */\n\nimport type { CheckDeploymentReadinessResult } from '@manifest-network/manifest-mcp-fred';\nimport type { Coin, Readiness } from '../types.js';\nimport {\n type EvaluateReadinessInputs,\n evaluateReadiness,\n} from './evaluate-readiness.js';\nimport type { DenomMap } from './humanize-denom.js';\n\n/**\n * Translate fred's snake_case `CheckDeploymentReadinessResult` into\n * the canonical `EvaluateReadinessInputs` (camelCase + context) and\n * invoke `evaluateReadiness`. Returns the typed `Readiness` verdict\n * the orchestrator gates on (`status === 'block'` → INVALID_CONFIG).\n *\n * @param raw fred's wire response (snake_case, readonly).\n * @param gasPrice Gas-price string from `clientManager.getConfig().gasPrice`\n * (e.g. `'1umfx'`). Required by the evaluator's\n * wallet-gas check; defaulted upstream when absent.\n * @param denomMap Pre-loaded `DenomMap` for humanization. Pass\n * `EMPTY_DENOM_MAP` when no chain-data file is\n * configured.\n * @param tenantAddress Canonical tenant address from the orchestrator's\n * address-source consistency guard. PREFERRED over\n * `raw.tenant` so a fred response whose `tenant`\n * differs (configuration drift / replayed mock)\n * does NOT silently route the verdict against a\n * different wallet.\n */\nexport function evaluateReadinessFromFredResponse(\n raw: CheckDeploymentReadinessResult,\n gasPrice: string,\n denomMap: DenomMap,\n tenantAddress: string,\n): Readiness {\n // Union `raw.sku.name` into the names list (Copilot #3319670583).\n // Fred caps `available_sku_names` at `MAX_SKU_NAMES_RETURNED = 50`\n // (`packages/fred/src/tools/checkDeploymentReadiness.ts`); when the\n // chain has >50 SKUs and the user's requested size falls past the\n // slice, the evaluator's SKU-availability rule false-blocks even\n // though fred already resolved the SKU into `raw.sku`. Folding\n // `raw.sku.name` into the set closes the gap. Set handles dedupe\n // (`raw.sku.name` may already be in the first-50 list).\n const skuNames = new Set(raw.available_sku_names);\n if (raw.sku !== null) skuNames.add(raw.sku.name);\n\n return evaluateReadiness({\n tenant: tenantAddress,\n image: raw.image,\n size: raw.size,\n walletBalances: toCoinArray(raw.wallet_balances),\n credits: translateCredits(raw),\n sku: translateSku(raw.sku),\n availableSkuNames: [...skuNames],\n gasPrice,\n denomMap,\n });\n}\n\n/**\n * Translate fred's `credits` object + top-level `current_balance` /\n * `hours_remaining` into the evaluator's nested `credits` input shape.\n *\n * Null preservation: when fred returns `credits: null`, the translator\n * returns null even if `current_balance` / `hours_remaining` are\n * present at the top level — synthesizing a credits object from the\n * stray fields would suppress the \"no credit account funded\" warn\n * rule the evaluator owns.\n */\nfunction translateCredits(\n raw: CheckDeploymentReadinessResult,\n): EvaluateReadinessInputs['credits'] {\n if (raw.credits === null) return null;\n // Defensive emission: only write a field when fred actually supplied\n // it. Fred's `CheckDeploymentReadinessResult` declares `balances` /\n // `available_balances` as required, but the evaluator's input shape\n // accepts both as OPTIONAL — and skipping the field on absent input\n // is preferable to surfacing a `.map of undefined` crash if a mock or\n // upstream variant elides the field. The evaluator's source-of-truth\n // precedence (availableBalances → balances → currentBalance → []) is\n // already CJS-parity-correct for partial credits objects.\n const out: NonNullable<EvaluateReadinessInputs['credits']> = {};\n if (Array.isArray(raw.credits.available_balances)) {\n out.availableBalances = toCoinArray(raw.credits.available_balances);\n }\n if (Array.isArray(raw.credits.balances)) {\n out.balances = toCoinArray(raw.credits.balances);\n }\n if (raw.current_balance !== undefined) {\n out.currentBalance = toCoinArray(raw.current_balance);\n }\n if (raw.hours_remaining !== undefined) {\n out.hoursRemaining = raw.hours_remaining;\n }\n return out;\n}\n\n/**\n * Translate fred's `SkuSummary | null` into the evaluator's\n * `{ name: string; price: Coin } | null`.\n *\n * Drops fred-only fields (`uuid`, `provider_uuid`, `active`). Coerces\n * a price-less SKU to `null` — the evaluator's `sku.price` is required\n * (`Coin`), so an SKU without price is structurally invalid input.\n * Without this coercion the evaluator would treat the SKU as truthy\n * and crash accessing `price.amount`.\n */\nfunction translateSku(\n sku: CheckDeploymentReadinessResult['sku'],\n): EvaluateReadinessInputs['sku'] {\n if (sku === null) return null;\n if (sku.price === undefined) return null;\n return {\n name: sku.name,\n price: { denom: sku.price.denom, amount: sku.price.amount },\n };\n}\n\n/**\n * Spread a readonly Coin-shaped array into a mutable Coin[]. The\n * evaluator's `EvaluateReadinessInputs.walletBalances` / credit-balance\n * arrays are mutable; fred's wire shapes are `ReadonlyArray<...>`. A\n * shallow copy is sufficient — each element is a frozen-ish `{denom,\n * amount}` value tuple, never mutated by the evaluator.\n */\nfunction toCoinArray(\n arr: ReadonlyArray<{ readonly denom: string; readonly amount: string }>,\n): Coin[] {\n return arr.map((c) => ({ denom: c.denom, amount: c.amount }));\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAuEA,SAAgB,kCACd,KACA,UACA,UACA,eACW;CASX,MAAM,WAAW,IAAI,IAAI,IAAI,oBAAoB;AACjD,KAAI,IAAI,QAAQ,KAAM,UAAS,IAAI,IAAI,IAAI,KAAK;AAEhD,QAAO,kBAAkB;EACvB,QAAQ;EACR,OAAO,IAAI;EACX,MAAM,IAAI;EACV,gBAAgB,YAAY,IAAI,gBAAgB;EAChD,SAAS,iBAAiB,IAAI;EAC9B,KAAK,aAAa,IAAI,IAAI;EAC1B,mBAAmB,CAAC,GAAG,SAAS;EAChC;EACA;EACD,CAAC;;;;;;;;;;;;AAaJ,SAAS,iBACP,KACoC;AACpC,KAAI,IAAI,YAAY,KAAM,QAAO;CASjC,MAAM,MAAuD,EAAE;AAC/D,KAAI,MAAM,QAAQ,IAAI,QAAQ,mBAAmB,CAC/C,KAAI,oBAAoB,YAAY,IAAI,QAAQ,mBAAmB;AAErE,KAAI,MAAM,QAAQ,IAAI,QAAQ,SAAS,CACrC,KAAI,WAAW,YAAY,IAAI,QAAQ,SAAS;AAElD,KAAI,IAAI,oBAAoB,KAAA,EAC1B,KAAI,iBAAiB,YAAY,IAAI,gBAAgB;AAEvD,KAAI,IAAI,oBAAoB,KAAA,EAC1B,KAAI,iBAAiB,IAAI;AAE3B,QAAO;;;;;;;;;;;;AAaT,SAAS,aACP,KACgC;AAChC,KAAI,QAAQ,KAAM,QAAO;AACzB,KAAI,IAAI,UAAU,KAAA,EAAW,QAAO;AACpC,QAAO;EACL,MAAM,IAAI;EACV,OAAO;GAAE,OAAO,IAAI,MAAM;GAAO,QAAQ,IAAI,MAAM;GAAQ;EAC5D;;;;;;;;;AAUH,SAAS,YACP,KACQ;AACR,QAAO,IAAI,KAAK,OAAO;EAAE,OAAO,EAAE;EAAO,QAAQ,EAAE;EAAQ,EAAE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"format-success.js","names":["decodeLeaseState"],"sources":["../../src/internals/format-success.ts"],"sourcesContent":["import {\n extractRunningEndpoints,\n formatEndpointAsIngress,\n normalizeFredUrl,\n type RunningEndpoint,\n} from './connection.js';\nimport { decode as decodeLeaseState } from './lease-state.js';\n\n/**\n * Render the user-facing \"Deployed.\" block for a successful `deployApp` run.\n *\n * Takes `lease-uuid` and `deploy_response` via the typed `FormatSuccessInput`.\n *\n * Output is plain text suitable for direct chat display. Designed to be\n * printed verbatim by `deployApp` (and downstream renderers) — no\n * paraphrasing or surrounding prose.\n *\n * **Lease-state decoding:** `deploy_response.state` may be an integer (raw\n * chain emit) or a `LEASE_STATE_*` string (codec.toJSON form). Both flow\n * through `lease-state.ts:decode`. Unknown values render as\n * `UNKNOWN(<raw>)` so the raw remains visible.\n *\n * **Multi-instance / multi-service stacks** emit `Ingresses:` followed by\n * one bare FQDN per UNIQUE FQDN across running instances. Instances\n * sharing an FQDN (e.g. replicas behind one subdomain) are deduped by\n * `extractRunningEndpoints`.\n *\n * **Custom-domain line** is emitted BEFORE the Ingress block when the\n * deploy response carries a `custom_domain` (the set-domain tx confirmed\n * alongside create-lease). The \"(provisioning)\" qualifier reflects that\n * the chain tx confirmed but the provider may still be issuing the cert.\n *\n * **Provider rendering**: the CJS once attempted to resolve a friendly\n * provider name via `browse_catalog` and dropped it when upstream's\n * catalog shape carried no `name` field. The TS port keeps the\n * `provider_uuid` rendering for the same reason — if upstream later adds\n * `name`, restore via a thin helper.\n */\n\n/** RFC 4122 UUID — 36 chars, hex + 4 hyphens, lowercase or upper. */\nconst UUID_RE =\n /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;\n\n/** Deploy response shape consumed by `formatSuccess`. Subset of fred's `mcp__manifest-fred__deploy_app` response. */\nexport interface DeployResponse {\n /** Chain lease state — int (raw) or `LEASE_STATE_*` string (codec). */\n state?: number | string;\n /** Provider UUID — rendered as-is (catalog has no friendly-name field). */\n provider_uuid?: string;\n /** Custom domain attached to the lease item; populated when set-domain confirmed. */\n custom_domain?: string;\n /** Provider connection payload — walked by `connection.ts`. */\n connection?: unknown;\n /**\n * Top-level URL — legacy fallback when provider reports\n * `connection.host` / `connection.ports` shape rather than\n * `connection.instances`. Used when no FQDN can be extracted from\n * `connection`. Defensive: `classify-deploy-response.ts:43-51,76-80`\n * already defends against the same legacy shape; mirroring here keeps\n * the renderer consistent with the classifier so a fred response of\n * `{ url: 'https://app.example.com/' }` renders an Ingress line\n * rather than `(none …)`.\n */\n url?: string;\n}\n\nexport interface FormatSuccessInput {\n /** Validated lease UUID (RFC 4122 v1-v5). */\n leaseUuid: string;\n /** Deploy response from `deploy_app` (or equivalent atomic broadcast). */\n deployResponse: DeployResponse;\n}\n\nexport function formatSuccess(input: FormatSuccessInput): string {\n if (!UUID_RE.test(input.leaseUuid)) {\n throw new TypeError(\n `formatSuccess: leaseUuid must be a UUID; got \"${input.leaseUuid}\"`,\n );\n }\n if (\n input.deployResponse === null ||\n typeof input.deployResponse !== 'object'\n ) {\n throw new TypeError(\n 'formatSuccess: deployResponse must be a non-null object',\n );\n }\n\n const dr = input.deployResponse;\n const providerName =\n typeof dr.provider_uuid === 'string' && dr.provider_uuid.length > 0\n ? dr.provider_uuid\n : '(unknown)';\n const stateName = decodeStateName(dr.state);\n const endpoints = extractRunningEndpoints(dr.connection);\n const ingresses = endpoints\n .map(formatEndpointAsIngress)\n .filter((s): s is string => typeof s === 'string' && s.length > 0);\n // Copilot review fix (PR #58 r3250192778): the custom-domain block's\n // TLS note (\\\"the Ingress URL below works immediately\\\") promises a\n // URL that may not exist when `connection.instances` is empty AND\n // there's no top-level `url`. Compute ingress availability up-front\n // so the custom-domain block can branch its second line accordingly.\n const hasIngress =\n ingresses.length > 0 || (typeof dr.url === 'string' && dr.url.length > 0);\n\n const lines: string[] = [\n 'Deployed.',\n ` Provider: ${providerName}`,\n ` Lease UUID: ${input.leaseUuid}`,\n ` Lease Status: ${stateName}`,\n ];\n\n // Custom-domain block — chain tx confirmed, provider may still be\n // provisioning. Present BEFORE Ingress so the user sees the requested\n // endpoint first, alongside the immediately-working provider FQDN (if\n // any). The TLS note's \"Ingress URL below works immediately\" promise\n // only fires when an Ingress is actually present (r3250192778).\n if (typeof dr.custom_domain === 'string' && dr.custom_domain.length > 0) {\n lines.push(` Custom domain (provisioning): https://${dr.custom_domain}/`);\n lines.push(\n hasIngress\n ? ' — TLS may take a few minutes; the Ingress URL below works immediately.'\n : ' — TLS may take a few minutes.',\n );\n }\n\n if (ingresses.length === 0) {\n // Legacy fallback: when no FQDN can be extracted from `connection`\n // (e.g. providers reporting the older `connection.host` / `ports`\n // shape rather than `connection.instances`), fred may still surface\n // the URL at the top level. `normalizeFredUrl` is the shared helper\n // (mirrored across `classify-deploy-response.ts`, this renderer,\n // and `deploy-app.ts`'s `DeployResult.urls` fallback). The\n // `(none …)` fallback stays for the truly-empty case.\n if (typeof dr.url === 'string' && dr.url.length > 0) {\n lines.push(` Ingress: ${normalizeFredUrl(dr.url)}`);\n } else {\n lines.push(\n ' Ingress: (none — service is internal or no FQDN reported)',\n );\n }\n } else if (ingresses.length === 1) {\n lines.push(` Ingress: ${ingresses[0]}`);\n } else {\n lines.push(' Ingresses:');\n for (const fqdn of ingresses) {\n lines.push(` - ${fqdn}`);\n }\n }\n lines.push('');\n lines.push(\n `For logs / status: /manifest-agent:troubleshoot-deployment ${input.leaseUuid}`,\n );\n\n return lines.join('\\n');\n}\n\n/**\n * Return the user-facing form of a lease state. The `LEASE_STATE_` prefix\n * is stripped for display (e.g. `LEASE_STATE_ACTIVE` → `ACTIVE`). Unknown\n * decodes render as `UNKNOWN(<raw>)` so the raw remains visible; absent\n * state renders as `(unknown)`.\n */\nfunction decodeStateName(state: number | string | undefined): string {\n if (state === undefined) return '(unknown)';\n const canonical = decodeLeaseState(state);\n if (canonical !== undefined) {\n return canonical.slice('LEASE_STATE_'.length);\n }\n return `UNKNOWN(${String(state)})`;\n}\n\n// Re-export for callers that want to walk endpoints themselves without\n// re-importing from `./connection.js`. Keeps `format-success.ts` the\n// single consumer-facing entry for success-rendering plumbing.\nexport type { RunningEndpoint };\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwCA,MAAM,UACJ;AAgCF,SAAgB,cAAc,OAAmC;AAC/D,KAAI,CAAC,QAAQ,KAAK,MAAM,UAAU,CAChC,OAAM,IAAI,UACR,iDAAiD,MAAM,UAAU,GAClE;AAEH,KACE,MAAM,mBAAmB,QACzB,OAAO,MAAM,mBAAmB,SAEhC,OAAM,IAAI,UACR,0DACD;CAGH,MAAM,KAAK,MAAM;CACjB,MAAM,eACJ,OAAO,GAAG,kBAAkB,YAAY,GAAG,cAAc,SAAS,IAC9D,GAAG,gBACH;CACN,MAAM,YAAY,gBAAgB,GAAG,MAAM;CAE3C,MAAM,YADY,wBAAwB,GAAG,
|
|
1
|
+
{"version":3,"file":"format-success.js","names":["decodeLeaseState"],"sources":["../../src/internals/format-success.ts"],"sourcesContent":["import {\n extractRunningEndpoints,\n formatEndpointAsIngress,\n normalizeFredUrl,\n type RunningEndpoint,\n} from './connection.js';\nimport { decode as decodeLeaseState } from './lease-state.js';\n\n/**\n * Render the user-facing \"Deployed.\" block for a successful `deployApp` run.\n *\n * Takes `lease-uuid` and `deploy_response` via the typed `FormatSuccessInput`.\n *\n * Output is plain text suitable for direct chat display. Designed to be\n * printed verbatim by `deployApp` (and downstream renderers) — no\n * paraphrasing or surrounding prose.\n *\n * **Lease-state decoding:** `deploy_response.state` may be an integer (raw\n * chain emit) or a `LEASE_STATE_*` string (codec.toJSON form). Both flow\n * through `lease-state.ts:decode`. Unknown values render as\n * `UNKNOWN(<raw>)` so the raw remains visible.\n *\n * **Multi-instance / multi-service stacks** emit `Ingresses:` followed by\n * one bare FQDN per UNIQUE FQDN across running instances. Instances\n * sharing an FQDN (e.g. replicas behind one subdomain) are deduped by\n * `extractRunningEndpoints`.\n *\n * **Custom-domain line** is emitted BEFORE the Ingress block when the\n * deploy response carries a `custom_domain` (the set-domain tx confirmed\n * alongside create-lease). The \"(provisioning)\" qualifier reflects that\n * the chain tx confirmed but the provider may still be issuing the cert.\n *\n * **Provider rendering**: the CJS once attempted to resolve a friendly\n * provider name via `browse_catalog` and dropped it when upstream's\n * catalog shape carried no `name` field. The TS port keeps the\n * `provider_uuid` rendering for the same reason — if upstream later adds\n * `name`, restore via a thin helper.\n */\n\n/** RFC 4122 UUID — 36 chars, hex + 4 hyphens, lowercase or upper. */\nconst UUID_RE =\n /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;\n\n/** Deploy response shape consumed by `formatSuccess`. Subset of fred's `mcp__manifest-fred__deploy_app` response. */\nexport interface DeployResponse {\n /** Chain lease state — int (raw) or `LEASE_STATE_*` string (codec). */\n state?: number | string;\n /** Provider UUID — rendered as-is (catalog has no friendly-name field). */\n provider_uuid?: string;\n /** Custom domain attached to the lease item; populated when set-domain confirmed. */\n custom_domain?: string;\n /** Provider connection payload — walked by `connection.ts`. */\n connection?: unknown;\n /**\n * Top-level URL — legacy fallback when provider reports\n * `connection.host` / `connection.ports` shape rather than\n * `connection.instances`. Used when no FQDN can be extracted from\n * `connection`. Defensive: `classify-deploy-response.ts:43-51,76-80`\n * already defends against the same legacy shape; mirroring here keeps\n * the renderer consistent with the classifier so a fred response of\n * `{ url: 'https://app.example.com/' }` renders an Ingress line\n * rather than `(none …)`.\n */\n url?: string;\n}\n\nexport interface FormatSuccessInput {\n /** Validated lease UUID (RFC 4122 v1-v5). */\n leaseUuid: string;\n /** Deploy response from `deploy_app` (or equivalent atomic broadcast). */\n deployResponse: DeployResponse;\n}\n\nexport function formatSuccess(input: FormatSuccessInput): string {\n if (!UUID_RE.test(input.leaseUuid)) {\n throw new TypeError(\n `formatSuccess: leaseUuid must be a UUID; got \"${input.leaseUuid}\"`,\n );\n }\n if (\n input.deployResponse === null ||\n typeof input.deployResponse !== 'object'\n ) {\n throw new TypeError(\n 'formatSuccess: deployResponse must be a non-null object',\n );\n }\n\n const dr = input.deployResponse;\n const providerName =\n typeof dr.provider_uuid === 'string' && dr.provider_uuid.length > 0\n ? dr.provider_uuid\n : '(unknown)';\n const stateName = decodeStateName(dr.state);\n const endpoints = extractRunningEndpoints(dr.connection);\n const ingresses = endpoints\n .map(formatEndpointAsIngress)\n .filter((s): s is string => typeof s === 'string' && s.length > 0);\n // Copilot review fix (PR #58 r3250192778): the custom-domain block's\n // TLS note (\\\"the Ingress URL below works immediately\\\") promises a\n // URL that may not exist when `connection.instances` is empty AND\n // there's no top-level `url`. Compute ingress availability up-front\n // so the custom-domain block can branch its second line accordingly.\n const hasIngress =\n ingresses.length > 0 || (typeof dr.url === 'string' && dr.url.length > 0);\n\n const lines: string[] = [\n 'Deployed.',\n ` Provider: ${providerName}`,\n ` Lease UUID: ${input.leaseUuid}`,\n ` Lease Status: ${stateName}`,\n ];\n\n // Custom-domain block — chain tx confirmed, provider may still be\n // provisioning. Present BEFORE Ingress so the user sees the requested\n // endpoint first, alongside the immediately-working provider FQDN (if\n // any). The TLS note's \"Ingress URL below works immediately\" promise\n // only fires when an Ingress is actually present (r3250192778).\n if (typeof dr.custom_domain === 'string' && dr.custom_domain.length > 0) {\n lines.push(` Custom domain (provisioning): https://${dr.custom_domain}/`);\n lines.push(\n hasIngress\n ? ' — TLS may take a few minutes; the Ingress URL below works immediately.'\n : ' — TLS may take a few minutes.',\n );\n }\n\n if (ingresses.length === 0) {\n // Legacy fallback: when no FQDN can be extracted from `connection`\n // (e.g. providers reporting the older `connection.host` / `ports`\n // shape rather than `connection.instances`), fred may still surface\n // the URL at the top level. `normalizeFredUrl` is the shared helper\n // (mirrored across `classify-deploy-response.ts`, this renderer,\n // and `deploy-app.ts`'s `DeployResult.urls` fallback). The\n // `(none …)` fallback stays for the truly-empty case.\n if (typeof dr.url === 'string' && dr.url.length > 0) {\n lines.push(` Ingress: ${normalizeFredUrl(dr.url)}`);\n } else {\n lines.push(\n ' Ingress: (none — service is internal or no FQDN reported)',\n );\n }\n } else if (ingresses.length === 1) {\n lines.push(` Ingress: ${ingresses[0]}`);\n } else {\n lines.push(' Ingresses:');\n for (const fqdn of ingresses) {\n lines.push(` - ${fqdn}`);\n }\n }\n lines.push('');\n lines.push(\n `For logs / status: /manifest-agent:troubleshoot-deployment ${input.leaseUuid}`,\n );\n\n return lines.join('\\n');\n}\n\n/**\n * Return the user-facing form of a lease state. The `LEASE_STATE_` prefix\n * is stripped for display (e.g. `LEASE_STATE_ACTIVE` → `ACTIVE`). Unknown\n * decodes render as `UNKNOWN(<raw>)` so the raw remains visible; absent\n * state renders as `(unknown)`.\n */\nfunction decodeStateName(state: number | string | undefined): string {\n if (state === undefined) return '(unknown)';\n const canonical = decodeLeaseState(state);\n if (canonical !== undefined) {\n return canonical.slice('LEASE_STATE_'.length);\n }\n return `UNKNOWN(${String(state)})`;\n}\n\n// Re-export for callers that want to walk endpoints themselves without\n// re-importing from `./connection.js`. Keeps `format-success.ts` the\n// single consumer-facing entry for success-rendering plumbing.\nexport type { RunningEndpoint };\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwCA,MAAM,UACJ;AAgCF,SAAgB,cAAc,OAAmC;AAC/D,KAAI,CAAC,QAAQ,KAAK,MAAM,UAAU,CAChC,OAAM,IAAI,UACR,iDAAiD,MAAM,UAAU,GAClE;AAEH,KACE,MAAM,mBAAmB,QACzB,OAAO,MAAM,mBAAmB,SAEhC,OAAM,IAAI,UACR,0DACD;CAGH,MAAM,KAAK,MAAM;CACjB,MAAM,eACJ,OAAO,GAAG,kBAAkB,YAAY,GAAG,cAAc,SAAS,IAC9D,GAAG,gBACH;CACN,MAAM,YAAY,gBAAgB,GAAG,MAAM;CAE3C,MAAM,YADY,wBAAwB,GAAG,WAClB,CACxB,IAAI,wBAAwB,CAC5B,QAAQ,MAAmB,OAAO,MAAM,YAAY,EAAE,SAAS,EAAE;CAMpE,MAAM,aACJ,UAAU,SAAS,KAAM,OAAO,GAAG,QAAQ,YAAY,GAAG,IAAI,SAAS;CAEzE,MAAM,QAAkB;EACtB;EACA,oBAAoB;EACpB,oBAAoB,MAAM;EAC1B,oBAAoB;EACrB;AAOD,KAAI,OAAO,GAAG,kBAAkB,YAAY,GAAG,cAAc,SAAS,GAAG;AACvE,QAAM,KAAK,4CAA4C,GAAG,cAAc,GAAG;AAC3E,QAAM,KACJ,aACI,+EACA,oCACL;;AAGH,KAAI,UAAU,WAAW,EAQvB,KAAI,OAAO,GAAG,QAAQ,YAAY,GAAG,IAAI,SAAS,EAChD,OAAM,KAAK,oBAAoB,iBAAiB,GAAG,IAAI,GAAG;KAE1D,OAAM,KACJ,oEACD;UAEM,UAAU,WAAW,EAC9B,OAAM,KAAK,oBAAoB,UAAU,KAAK;MACzC;AACL,QAAM,KAAK,eAAe;AAC1B,OAAK,MAAM,QAAQ,UACjB,OAAM,KAAK,SAAS,OAAO;;AAG/B,OAAM,KAAK,GAAG;AACd,OAAM,KACJ,+DAA+D,MAAM,YACtE;AAED,QAAO,MAAM,KAAK,KAAK;;;;;;;;AASzB,SAAS,gBAAgB,OAA4C;AACnE,KAAI,UAAU,KAAA,EAAW,QAAO;CAChC,MAAM,YAAYA,OAAiB,MAAM;AACzC,KAAI,cAAc,KAAA,EAChB,QAAO,UAAU,MAAM,GAAsB;AAE/C,QAAO,WAAW,OAAO,MAAM,CAAC"}
|
|
@@ -1,138 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* SSRF-guarded `fetch` factory. A Node-native undici Dispatcher that
|
|
4
|
-
* DNS-resolves once at connect time and rejects any address whose
|
|
5
|
-
* `ipaddr.js` range is not `'unicast'`.
|
|
6
|
-
*
|
|
7
|
-
* Why DIY rather than `request-filtering-agent`: the library only works with
|
|
8
|
-
* `http`/`https`.Agent (legacy http API) and explicitly does NOT plug into
|
|
9
|
-
* undici / native `fetch` per its v3.2.0 README. Re-routing the same
|
|
10
|
-
* blocking semantics through undici's Dispatcher hook lets agent-core's
|
|
11
|
-
* `inspectImage` (and future consumers) use native `fetch` while preserving
|
|
12
|
-
* the same SSRF posture.
|
|
13
|
-
*
|
|
14
|
-
* Design (architect-blessed):
|
|
15
|
-
* - **`ipaddr.js`'s `range()` is the source of truth.** Same approach as
|
|
16
|
-
* `request-filtering-agent`: block any IP whose range is not `'unicast'`.
|
|
17
|
-
* This covers loopback / private / link-local / multicast / broadcast /
|
|
18
|
-
* reserved / carrier-grade-NAT / unspecified / ipv4Mapped / etc. via the
|
|
19
|
-
* library's well-maintained RFC-classification table.
|
|
20
|
-
* - **IPv4-mapped IPv6 normalization** (security-critical). An attacker
|
|
21
|
-
* writing `::ffff:127.0.0.1` would otherwise sit in `ipaddr.js`'s
|
|
22
|
-
* `'ipv4Mapped'` IPv6 range — coincidentally blocked, but for the
|
|
23
|
-
* structural reason ("v4-mapped form") rather than the security reason
|
|
24
|
-
* ("loopback target"). We normalize first so the block is justified.
|
|
25
|
-
* Without this step, a v4-mapped form of a PUBLIC IPv4 (`::ffff:8.8.8.8`)
|
|
26
|
-
* would also be blocked (wrong outcome — public IP is fine via
|
|
27
|
-
* v4-mapped). Normalization gets both cases right.
|
|
28
|
-
* - **DNS-resolve INSIDE the connect hook** to close the TOCTOU window
|
|
29
|
-
* between resolve and TCP connect. The resolved IP gets substituted as
|
|
30
|
-
* the connect hostname so the kernel doesn't re-resolve.
|
|
31
|
-
* - **Module-level singleton Dispatcher**, lazy-instantiated on first
|
|
32
|
-
* `createGuardedFetch()` invocation. Mirrors the CJS singleton-agent
|
|
33
|
-
* pattern; avoids the aggressive `setGlobalDispatcher()` side-effect.
|
|
34
|
-
* - **Construction-time runtime check** (`typeof process === 'undefined'`)
|
|
35
|
-
* throws a clear error on browser/Deno so the failure is actionable, not
|
|
36
|
-
* a confusing mid-fetch module-resolution error.
|
|
37
|
-
* - **Redirect safety:** undici re-fires the connect hook on every cross-
|
|
38
|
-
* host redirect; same-host redirects reuse the checked socket. The fetch
|
|
39
|
-
* closure does NOT need `redirect: 'manual'` — default `follow` is safe
|
|
40
|
-
* by construction.
|
|
41
|
-
*
|
|
42
|
-
* Blocked-range exports (`BLOCKED_RANGES_IPV4`, `BLOCKED_RANGES_IPV6`) are
|
|
43
|
-
* provided for audit + test purposes: they enumerate the `ipaddr.js`
|
|
44
|
-
* `range()` classifications we treat as non-`unicast` with their RFC
|
|
45
|
-
* citations, so a reviewer can grep the code without consulting the
|
|
46
|
-
* `ipaddr.js` source.
|
|
47
|
-
*
|
|
48
|
-
* Cross-platform note: agent-core's `tsdown.config.ts` targets
|
|
49
|
-
* `platform: 'neutral'`. `ipaddr.js` is isomorphic (pure JS, no node:*
|
|
50
|
-
* imports), so the static import is fine. `undici` and `node:dns/promises`
|
|
51
|
-
* + `node:net` are Node-only — dynamic-imported INSIDE the lazy singleton
|
|
52
|
-
* creation so the package stays importable from browsers / Deno (calling
|
|
53
|
-
* `createGuardedFetch()` from those throws the construction-time error
|
|
54
|
-
* with actionable guidance).
|
|
55
|
-
*/
|
|
56
|
-
type GuardedFetch = typeof fetch;
|
|
57
|
-
/**
|
|
58
|
-
* `ipaddr.js`-classified IPv4 range labels we block (i.e., everything
|
|
59
|
-
* except `'unicast'`). Exposed as a module-level constant so the audit
|
|
60
|
-
* trail is greppable and a future range-list update is a focused edit.
|
|
61
|
-
*
|
|
62
|
-
* RFC citations included for each label for audit visibility — `ipaddr.js`
|
|
63
|
-
* owns the actual CIDR tables that map IPs to these labels.
|
|
64
|
-
*/
|
|
65
|
-
declare const BLOCKED_RANGES_IPV4: ReadonlyArray<{
|
|
66
|
-
readonly range: string;
|
|
67
|
-
readonly rfc: string;
|
|
68
|
-
}>;
|
|
69
|
-
/**
|
|
70
|
-
* `ipaddr.js`-classified IPv6 range labels we block. Note: `'ipv4Mapped'`
|
|
71
|
-
* is NOT included here because we normalize IPv4-mapped IPv6 addresses to
|
|
72
|
-
* their underlying IPv4 form BEFORE the range check — otherwise a v4-
|
|
73
|
-
* mapped form of a public IP (`::ffff:8.8.8.8`) would be wrongly blocked,
|
|
74
|
-
* and a v4-mapped form of a private IP (`::ffff:127.0.0.1`) would be
|
|
75
|
-
* blocked only structurally (not for the security reason).
|
|
76
|
-
*/
|
|
77
|
-
declare const BLOCKED_RANGES_IPV6: ReadonlyArray<{
|
|
78
|
-
readonly range: string;
|
|
79
|
-
readonly rfc: string;
|
|
80
|
-
}>;
|
|
81
|
-
/**
|
|
82
|
-
* SSRF block-check for a single IP string. **Allow-list policy:** only
|
|
83
|
-
* ipaddr.js's `'unicast'` classification is permitted; every other range
|
|
84
|
-
* label is blocked.
|
|
85
|
-
*
|
|
86
|
-
* The prior deny-list implementation iterated BLOCKED_RANGES_* and let
|
|
87
|
-
* anything not explicitly enumerated fall through as "allowed" — a
|
|
88
|
-
* security-critical bias error. IPv6 categories like `6to4` (which can
|
|
89
|
-
* wrap loopback or RFC 1918 IPs as `2002:7f00::/24` etc.), `teredo`,
|
|
90
|
-
* `rfc6052` (NAT64), and `discard` were ALL un-named and therefore
|
|
91
|
-
* allowed-by-omission. Under the allow-list policy, these all
|
|
92
|
-
* default-deny along with any future ipaddr.js classification we
|
|
93
|
-
* haven't audited.
|
|
94
|
-
*
|
|
95
|
-
* Returned `{range, rfc}` descriptor sources:
|
|
96
|
-
* - **Named in BLOCKED_RANGES_IPV4 / BLOCKED_RANGES_IPV6** → returns
|
|
97
|
-
* that entry verbatim (carries the audited RFC citation).
|
|
98
|
-
* - **Unknown non-unicast label** → synthesizes
|
|
99
|
-
* `{range: <label>, rfc: 'ipaddr.js classification (default-deny non-unicast)'}`.
|
|
100
|
-
* The audit string is generic but the block decision is correct;
|
|
101
|
-
* a future PR can promote frequently-seen labels into
|
|
102
|
-
* BLOCKED_RANGES_* with proper RFC citations.
|
|
103
|
-
*
|
|
104
|
-
* IPv4-mapped IPv6 addresses (`::ffff:1.2.3.4`) are normalized to their
|
|
105
|
-
* IPv4 form before the range check so the security verdict tracks the
|
|
106
|
-
* underlying IP, not the structural wrapping.
|
|
107
|
-
*
|
|
108
|
-
* Throws `Error` on unparseable input — callers should catch and treat
|
|
109
|
-
* "unparseable" as "block" (defense-in-depth — better to refuse than to
|
|
110
|
-
* pass through to network on garbage input).
|
|
111
|
-
*/
|
|
112
|
-
declare function isBlocked(ipString: string): {
|
|
113
|
-
range: string;
|
|
114
|
-
rfc: string;
|
|
115
|
-
} | null;
|
|
116
|
-
/**
|
|
117
|
-
* Build the SSRF-guarded fetch closure. Construction-time runtime check
|
|
118
|
-
* gates Node-only — browser / Deno consumers either pass their own
|
|
119
|
-
* `opts.fetch` to consumers like `inspectImage` or accept this error.
|
|
120
|
-
*
|
|
121
|
-
* The returned function matches `typeof fetch` and lazy-instantiates the
|
|
122
|
-
* undici Dispatcher on first invocation. Subsequent calls share the
|
|
123
|
-
* cached singleton.
|
|
124
|
-
*
|
|
125
|
-
* **Important: uses undici's own `fetch`**, not Node's built-in. Node's
|
|
126
|
-
* built-in fetch is backed by its bundled undici, which is pinned to
|
|
127
|
-
* Node's release-cycle version (Node 22 → undici 6.x). The npm-installed
|
|
128
|
-
* `undici` package may be newer, and the Dispatcher protocol between
|
|
129
|
-
* versions isn't guaranteed compatible (we observed "invalid
|
|
130
|
-
* onRequestStart method" when mixing Node 22's fetch with undici@8 Agent).
|
|
131
|
-
* Routing through undici's own fetch (same package version as the Agent)
|
|
132
|
-
* sidesteps the mismatch. The function signature stays identical to
|
|
133
|
-
* Node's `fetch` so consumers can't tell the difference.
|
|
134
|
-
*/
|
|
135
|
-
declare function createGuardedFetch(): GuardedFetch;
|
|
136
|
-
//#endregion
|
|
137
|
-
export { BLOCKED_RANGES_IPV4, BLOCKED_RANGES_IPV6, GuardedFetch, createGuardedFetch, isBlocked };
|
|
138
|
-
//# sourceMappingURL=guarded-fetch.d.ts.map
|
|
1
|
+
import { BLOCKED_RANGES_IPV4, BLOCKED_RANGES_IPV6, GuardedFetch, createGuardedFetch, isBlocked } from "@manifest-network/manifest-mcp-core";
|
|
2
|
+
export { BLOCKED_RANGES_IPV4, BLOCKED_RANGES_IPV6, type GuardedFetch, createGuardedFetch, isBlocked };
|
|
@@ -1,242 +1,2 @@
|
|
|
1
|
-
import
|
|
2
|
-
//#region src/internals/guarded-fetch.ts
|
|
3
|
-
/**
|
|
4
|
-
* `ipaddr.js`-classified IPv4 range labels we block (i.e., everything
|
|
5
|
-
* except `'unicast'`). Exposed as a module-level constant so the audit
|
|
6
|
-
* trail is greppable and a future range-list update is a focused edit.
|
|
7
|
-
*
|
|
8
|
-
* RFC citations included for each label for audit visibility — `ipaddr.js`
|
|
9
|
-
* owns the actual CIDR tables that map IPs to these labels.
|
|
10
|
-
*/
|
|
11
|
-
const BLOCKED_RANGES_IPV4 = [
|
|
12
|
-
{
|
|
13
|
-
range: "unspecified",
|
|
14
|
-
rfc: "RFC 1122 §3.2.1.3 — 0.0.0.0/8 (this network / meta)"
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
range: "private",
|
|
18
|
-
rfc: "RFC 1918 — 10/8, 172.16/12, 192.168/16 (private)"
|
|
19
|
-
},
|
|
20
|
-
{
|
|
21
|
-
range: "loopback",
|
|
22
|
-
rfc: "RFC 5735 — 127/8 (loopback)"
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
range: "linkLocal",
|
|
26
|
-
rfc: "RFC 3927 — 169.254/16 (link-local, incl. AWS/GCP/Azure metadata at 169.254.169.254)"
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
range: "carrierGradeNat",
|
|
30
|
-
rfc: "RFC 6598 — 100.64/10 (carrier-grade NAT)"
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
range: "broadcast",
|
|
34
|
-
rfc: "RFC 919 — 255.255.255.255 (limited broadcast)"
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
range: "multicast",
|
|
38
|
-
rfc: "RFC 5771 — 224/4 (multicast)"
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
range: "reserved",
|
|
42
|
-
rfc: "RFC 1112 / 6890 — 240/4, 192.0.0/24, 198.18/15 etc. (reserved)"
|
|
43
|
-
}
|
|
44
|
-
];
|
|
45
|
-
/**
|
|
46
|
-
* `ipaddr.js`-classified IPv6 range labels we block. Note: `'ipv4Mapped'`
|
|
47
|
-
* is NOT included here because we normalize IPv4-mapped IPv6 addresses to
|
|
48
|
-
* their underlying IPv4 form BEFORE the range check — otherwise a v4-
|
|
49
|
-
* mapped form of a public IP (`::ffff:8.8.8.8`) would be wrongly blocked,
|
|
50
|
-
* and a v4-mapped form of a private IP (`::ffff:127.0.0.1`) would be
|
|
51
|
-
* blocked only structurally (not for the security reason).
|
|
52
|
-
*/
|
|
53
|
-
const BLOCKED_RANGES_IPV6 = [
|
|
54
|
-
{
|
|
55
|
-
range: "unspecified",
|
|
56
|
-
rfc: "RFC 4291 — :: (unspecified)"
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
range: "loopback",
|
|
60
|
-
rfc: "RFC 4291 — ::1/128 (loopback)"
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
range: "linkLocal",
|
|
64
|
-
rfc: "RFC 4291 — fe80::/10 (link-local)"
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
range: "uniqueLocal",
|
|
68
|
-
rfc: "RFC 4193 — fc00::/7 (unique local / private)"
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
range: "multicast",
|
|
72
|
-
rfc: "RFC 4291 — ff00::/8 (multicast)"
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
range: "reserved",
|
|
76
|
-
rfc: "RFC 4291 / 5156 — various reserved blocks"
|
|
77
|
-
}
|
|
78
|
-
];
|
|
79
|
-
/**
|
|
80
|
-
* SSRF block-check for a single IP string. **Allow-list policy:** only
|
|
81
|
-
* ipaddr.js's `'unicast'` classification is permitted; every other range
|
|
82
|
-
* label is blocked.
|
|
83
|
-
*
|
|
84
|
-
* The prior deny-list implementation iterated BLOCKED_RANGES_* and let
|
|
85
|
-
* anything not explicitly enumerated fall through as "allowed" — a
|
|
86
|
-
* security-critical bias error. IPv6 categories like `6to4` (which can
|
|
87
|
-
* wrap loopback or RFC 1918 IPs as `2002:7f00::/24` etc.), `teredo`,
|
|
88
|
-
* `rfc6052` (NAT64), and `discard` were ALL un-named and therefore
|
|
89
|
-
* allowed-by-omission. Under the allow-list policy, these all
|
|
90
|
-
* default-deny along with any future ipaddr.js classification we
|
|
91
|
-
* haven't audited.
|
|
92
|
-
*
|
|
93
|
-
* Returned `{range, rfc}` descriptor sources:
|
|
94
|
-
* - **Named in BLOCKED_RANGES_IPV4 / BLOCKED_RANGES_IPV6** → returns
|
|
95
|
-
* that entry verbatim (carries the audited RFC citation).
|
|
96
|
-
* - **Unknown non-unicast label** → synthesizes
|
|
97
|
-
* `{range: <label>, rfc: 'ipaddr.js classification (default-deny non-unicast)'}`.
|
|
98
|
-
* The audit string is generic but the block decision is correct;
|
|
99
|
-
* a future PR can promote frequently-seen labels into
|
|
100
|
-
* BLOCKED_RANGES_* with proper RFC citations.
|
|
101
|
-
*
|
|
102
|
-
* IPv4-mapped IPv6 addresses (`::ffff:1.2.3.4`) are normalized to their
|
|
103
|
-
* IPv4 form before the range check so the security verdict tracks the
|
|
104
|
-
* underlying IP, not the structural wrapping.
|
|
105
|
-
*
|
|
106
|
-
* Throws `Error` on unparseable input — callers should catch and treat
|
|
107
|
-
* "unparseable" as "block" (defense-in-depth — better to refuse than to
|
|
108
|
-
* pass through to network on garbage input).
|
|
109
|
-
*/
|
|
110
|
-
function isBlocked(ipString) {
|
|
111
|
-
let parsed = ipaddr.parse(ipString);
|
|
112
|
-
if (parsed.kind() === "ipv6") {
|
|
113
|
-
const v6 = parsed;
|
|
114
|
-
if (v6.isIPv4MappedAddress()) parsed = v6.toIPv4Address();
|
|
115
|
-
}
|
|
116
|
-
const rangeLabel = parsed.range();
|
|
117
|
-
if (rangeLabel === "unicast") return null;
|
|
118
|
-
const named = (parsed.kind() === "ipv4" ? BLOCKED_RANGES_IPV4 : BLOCKED_RANGES_IPV6).find((r) => r.range === rangeLabel);
|
|
119
|
-
if (named) return named;
|
|
120
|
-
return {
|
|
121
|
-
range: rangeLabel,
|
|
122
|
-
rfc: "ipaddr.js classification (default-deny non-unicast)"
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Cache the in-flight Promise (not the resolved value) so concurrent first-
|
|
127
|
-
* call racers share the same construction and don't double-build the
|
|
128
|
-
* undici Agent. Resolves to the singleton DispatcherCache. After
|
|
129
|
-
* resolution, subsequent calls await the already-settled Promise (cheap).
|
|
130
|
-
*/
|
|
131
|
-
let cachedP;
|
|
132
|
-
/**
|
|
133
|
-
* Build the SSRF-guarded fetch closure. Construction-time runtime check
|
|
134
|
-
* gates Node-only — browser / Deno consumers either pass their own
|
|
135
|
-
* `opts.fetch` to consumers like `inspectImage` or accept this error.
|
|
136
|
-
*
|
|
137
|
-
* The returned function matches `typeof fetch` and lazy-instantiates the
|
|
138
|
-
* undici Dispatcher on first invocation. Subsequent calls share the
|
|
139
|
-
* cached singleton.
|
|
140
|
-
*
|
|
141
|
-
* **Important: uses undici's own `fetch`**, not Node's built-in. Node's
|
|
142
|
-
* built-in fetch is backed by its bundled undici, which is pinned to
|
|
143
|
-
* Node's release-cycle version (Node 22 → undici 6.x). The npm-installed
|
|
144
|
-
* `undici` package may be newer, and the Dispatcher protocol between
|
|
145
|
-
* versions isn't guaranteed compatible (we observed "invalid
|
|
146
|
-
* onRequestStart method" when mixing Node 22's fetch with undici@8 Agent).
|
|
147
|
-
* Routing through undici's own fetch (same package version as the Agent)
|
|
148
|
-
* sidesteps the mismatch. The function signature stays identical to
|
|
149
|
-
* Node's `fetch` so consumers can't tell the difference.
|
|
150
|
-
*/
|
|
151
|
-
function createGuardedFetch() {
|
|
152
|
-
if (typeof process === "undefined" || !process.versions?.node) throw new Error("createGuardedFetch requires a Node.js runtime. On browser/Deno consumers, pass `opts.fetch` directly with your own SSRF-guarded implementation. See agent-core README.");
|
|
153
|
-
return async (input, init) => {
|
|
154
|
-
if (!cachedP) cachedP = buildSsrfDispatcher().catch((err) => {
|
|
155
|
-
cachedP = void 0;
|
|
156
|
-
throw err;
|
|
157
|
-
});
|
|
158
|
-
const c = await cachedP;
|
|
159
|
-
const initWithDispatcher = {
|
|
160
|
-
...init ?? {},
|
|
161
|
-
dispatcher: c.dispatcher
|
|
162
|
-
};
|
|
163
|
-
return c.fetch(input, initWithDispatcher);
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
/**
|
|
167
|
-
* Lazy dynamic-import of Node-only modules so the package stays importable
|
|
168
|
-
* from non-Node consumers. The runtime check in `createGuardedFetch`
|
|
169
|
-
* already gates Node-only — this function is only reached on Node.
|
|
170
|
-
*
|
|
171
|
-
* Returns BOTH the dispatcher and undici's own `fetch` so the
|
|
172
|
-
* `createGuardedFetch` closure can route through undici directly (avoiding
|
|
173
|
-
* Node-bundled-undici vs npm-undici Dispatcher protocol mismatches).
|
|
174
|
-
*/
|
|
175
|
-
async function buildSsrfDispatcher() {
|
|
176
|
-
const [undici, dnsModule, netModule] = await Promise.all([
|
|
177
|
-
import("undici"),
|
|
178
|
-
import("node:dns/promises"),
|
|
179
|
-
import("node:net")
|
|
180
|
-
]);
|
|
181
|
-
const baseConnect = undici.buildConnector({});
|
|
182
|
-
return {
|
|
183
|
-
dispatcher: new undici.Agent({ connect: (options, callback) => {
|
|
184
|
-
const hostname = options.hostname ?? "";
|
|
185
|
-
resolveAndCheck(hostname, netModule, dnsModule).then((resolved) => {
|
|
186
|
-
if (resolved.blocked) {
|
|
187
|
-
callback(/* @__PURE__ */ new Error(`SSRF blocked: ${hostname} resolves to ${resolved.ip} which is in blocked range '${resolved.blocked.range}' (${resolved.blocked.rfc})`), null);
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
baseConnect({
|
|
191
|
-
...options,
|
|
192
|
-
hostname: resolved.ip
|
|
193
|
-
}, callback);
|
|
194
|
-
}).catch((err) => {
|
|
195
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
196
|
-
callback(/* @__PURE__ */ new Error(`SSRF blocked: refused to connect to ${hostname}: ${msg}`), null);
|
|
197
|
-
});
|
|
198
|
-
} }),
|
|
199
|
-
fetch: undici.fetch
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* Resolve the connection target's IP and check against the blocked-range
|
|
204
|
-
* sets. Handles three input cases:
|
|
205
|
-
* 1. Hostname is already an IP literal → check directly (no DNS lookup).
|
|
206
|
-
* 2. Hostname is an FQDN → resolve via `dns.lookup` (returns first
|
|
207
|
-
* address; matches Node's default connection behavior).
|
|
208
|
-
* 3. Resolution failure → throws, which the caller's `.catch` translates
|
|
209
|
-
* into a fail-closed SSRF-block error.
|
|
210
|
-
*
|
|
211
|
-
* **DELIBERATE DIVERGENCE FROM SPEC** — architect's spec said
|
|
212
|
-
* `dns.resolve4` / `dns.resolve6`; this implementation uses `dns.lookup`.
|
|
213
|
-
* Rationale: `dns.lookup` matches the kernel's actual connection-time
|
|
214
|
-
* resolution path (hosts file + nsswitch.conf + DNS in order), so the IP
|
|
215
|
-
* we check IS the IP the kernel would connect to. Using `resolve4/6`
|
|
216
|
-
* would consult DNS only and miss the hosts-file path — if an attacker
|
|
217
|
-
* could write to `/etc/hosts` (root only) the check would be incomplete
|
|
218
|
-
* because the kernel's actual connect would use a different address than
|
|
219
|
-
* the one we checked. Per threat model: hosts-file writes require root,
|
|
220
|
-
* so an attacker capable of writing there already owns the machine; this
|
|
221
|
-
* is "fixing the right problem" — the check should track what the kernel
|
|
222
|
-
* does, not its own model of resolution.
|
|
223
|
-
*
|
|
224
|
-
* Documented in PR 2 description for reviewer awareness. If the threat
|
|
225
|
-
* model expands to include shared-host scenarios where attacker-controlled
|
|
226
|
-
* hosts entries are realistic, switch to `resolve4/6` and accept the
|
|
227
|
-
* connect-time-mismatch risk.
|
|
228
|
-
*/
|
|
229
|
-
async function resolveAndCheck(hostname, netModule, dnsModule) {
|
|
230
|
-
let ip;
|
|
231
|
-
if (netModule.isIP(hostname) !== 0) ip = hostname;
|
|
232
|
-
else ip = (await dnsModule.lookup(hostname, { verbatim: true })).address;
|
|
233
|
-
const blocked = isBlocked(ip);
|
|
234
|
-
return {
|
|
235
|
-
ip,
|
|
236
|
-
blocked
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
//#endregion
|
|
1
|
+
import { BLOCKED_RANGES_IPV4, BLOCKED_RANGES_IPV6, createGuardedFetch, isBlocked } from "@manifest-network/manifest-mcp-core";
|
|
240
2
|
export { BLOCKED_RANGES_IPV4, BLOCKED_RANGES_IPV6, createGuardedFetch, isBlocked };
|
|
241
|
-
|
|
242
|
-
//# sourceMappingURL=guarded-fetch.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"humanize-denom.js","names":[],"sources":["../../src/internals/humanize-denom.ts"],"sourcesContent":["/**\n * Convert chain-side coin amounts (always in the smallest unit) into the\n * human-readable display the user actually wants to see — e.g.\n * `1800000 factory/.../upwr` → `1.8 PWR`, `0.057738 PWR` built from\n * `57738 factory/.../upwr`, etc.\n *\n * The denom → symbol mapping is sourced from a chain registry JSON file\n * (`{ feeTokens: [{ denom, symbol, ... }] }` — every token the chain\n * accepts as gas). Callers pass the chain-data file path and forward the\n * resulting `DenomMap` to whichever helper renders balances; this module\n * just reads, parses, and looks up.\n *\n * Conversion factor: cosmos convention is 6 decimals for `u`-prefixed\n * tokens (umfx, upwr — including factory-wrapped variants). Anything else\n * is rendered untouched (denom kept as-is, amount printed as integer)\n * because we can't safely guess its exponent.\n *\n * **Dynamic node-import discipline** (mirrors `guarded-fetch.ts` +\n * `save-manifest.ts`): the `node:fs` import is deferred to call time so\n * module load doesn't violate the package's `platform: 'neutral'` build\n * target. `loadChainDenomMap` is therefore async; consumers must\n * `await` the result. The other 3 exports (`humanizeCoin`,\n * `humanizeBalances`, `denomToSymbol`) remain pure-sync since they take\n * a pre-loaded `DenomMap` as input.\n *\n * Exports (all 4 preserved per qa-engineer's review pin — PR 2's internal\n * callers use a subset; PR 3 will surface the rest):\n * - `loadChainDenomMap(chainDataFilePath?)` (ASYNC) — returns\n * `Promise<DenomMap>`. Missing / unreadable path → no-op map\n * (lookup always returns `null`). Read failures emit `console.warn`\n * matching the connection.ts precedent from PR 1.\n * - `humanizeCoin(amount, denom, denomMap)` — `\"<amount> <symbol>\"` or\n * `\"<amount> <denom>\"` on unknown denom.\n * - `humanizeBalances(coins, denomMap)` — joins multiple coins with\n * `\", \"`. Empty array → `\"(empty)\"` literal.\n * - `denomToSymbol(denom, denomMap)` — bare symbol or raw denom fallback.\n */\n\nimport type { DenomLookup, DenomMap } from '../types.js';\n\n// Re-export the public types for convenience to existing internal consumers\n// (this file's pre-PR-3 history exported DenomLookup + DenomMap directly).\n// Public consumers should import from `@manifest-network/manifest-agent-core`\n// (which re-exports `../types.js`); internal consumers can use either path.\nexport type { DenomLookup, DenomMap };\n\nconst KNOWN_EXPONENT = 6;\n\n/**\n * No-op `DenomMap` for callers without chain-data context. All lookups\n * return `null`; `humanizeCoin` falls back to raw on-chain denoms.\n * Exported so synchronous decision functions (e.g. `evaluateReadiness`)\n * can default to it without needing to invoke the async loader.\n */\nexport const EMPTY_DENOM_MAP: DenomMap = { lookup: () => null, raw: null };\n\nexport async function loadChainDenomMap(\n chainDataFilePath?: string,\n): Promise<DenomMap> {\n if (!chainDataFilePath) return EMPTY_DENOM_MAP;\n if (\n typeof process === 'undefined' ||\n typeof process.versions?.node !== 'string'\n ) {\n // Lazy node-only dep — refuse outside Node-like runtimes rather than\n // silently no-op'ing (which would hide a misconfiguration).\n throw new Error(\n 'loadChainDenomMap: chainDataFilePath requires a Node.js runtime (node:fs unavailable in this environment)',\n );\n }\n let raw: unknown;\n try {\n const { readFileSync } = await import('node:fs');\n raw = JSON.parse(readFileSync(chainDataFilePath, 'utf8'));\n } catch (err) {\n // CJS parity: warn loudly when a path was passed but read/parse failed.\n // A corrupted chain file silently downgrades all balance/fee rendering to\n // raw chain denoms across the package, and the user only notices because\n // the DeploymentPlan looks weird (\"0.000037 PWR\" vs \"37 upwr\"). Matches\n // connection.ts's `console.warn` default established in PR 1.\n const message = err instanceof Error ? err.message : String(err);\n console.warn(\n `humanize-denom: failed to load ${chainDataFilePath}: ${message}; ` +\n `balances and fees will render with raw on-chain denoms.`,\n );\n return EMPTY_DENOM_MAP;\n }\n\n // Normalize the feeTokens list into a denom → { symbol, exponent } map.\n // Every Manifest fee token uses 6 decimals (the leading `u` is the micro\n // prefix). Tokens not in feeTokens are unknown to us; the fallback branch\n // in humanizeCoin handles them.\n const map = new Map<string, DenomLookup>();\n if (raw !== null && typeof raw === 'object') {\n const feeTokens = (raw as { feeTokens?: unknown }).feeTokens;\n if (Array.isArray(feeTokens)) {\n for (const t of feeTokens) {\n if (\n t !== null &&\n typeof t === 'object' &&\n typeof (t as { denom?: unknown }).denom === 'string' &&\n typeof (t as { symbol?: unknown }).symbol === 'string'\n ) {\n const token = t as { denom: string; symbol: string };\n map.set(token.denom, {\n symbol: token.symbol,\n exponent: KNOWN_EXPONENT,\n });\n }\n }\n }\n }\n\n return {\n lookup: (denom) => {\n if (typeof denom !== 'string') return null;\n return map.get(denom) ?? null;\n },\n raw,\n };\n}\n\n/**\n * Convert a smallest-unit amount string → human decimal string with up to\n * `exponent` decimals, trimming trailing zeros for readability. Uses BigInt\n * for the integer part so precision survives large balances; only the\n * fractional remainder is divided.\n *\n * Exported for unit testing of the scaling logic in isolation (mirrors the\n * CJS's `_fmtScaledAmount` test hook).\n */\nexport function _fmtScaledAmount(amount: string, exponent: number): string {\n let digits: bigint;\n try {\n digits = BigInt(amount);\n } catch {\n return String(amount);\n }\n const negative = digits < 0n;\n if (negative) digits = -digits;\n const divisor = 10n ** BigInt(exponent);\n const whole = digits / divisor;\n const frac = digits % divisor;\n const fracStr = frac.toString().padStart(exponent, '0').replace(/0+$/, '');\n let out = fracStr.length > 0 ? `${whole}.${fracStr}` : `${whole}`;\n if (negative) out = `-${out}`;\n return out;\n}\n\n/**\n * Render a single coin as `\"<amount> <symbol>\"` (when the denom is in the\n * map) or `\"<amount> <denom>\"` verbatim (when unknown). Falls back to\n * `\"<amount>\"` only when `denom` is null/undefined.\n */\nexport function humanizeCoin(\n amount: string,\n denom: string | null | undefined,\n denomMap: DenomMap,\n): string {\n if (denom === undefined || denom === null) return `${amount}`;\n const lookup = denomMap.lookup(denom);\n if (lookup) {\n return `${_fmtScaledAmount(amount, lookup.exponent)} ${lookup.symbol}`;\n }\n // Best-effort unknown-denom rendering — keep the raw denom so the user\n // can still identify it, and don't guess at scaling.\n return `${amount} ${denom}`;\n}\n\n/**\n * Join multiple coins with `\", \"` (space after comma). Empty array →\n * literal `\"(empty)\"` per CJS parity.\n */\nexport function humanizeBalances(\n balances: ReadonlyArray<{ denom?: string; amount?: string | null }> | unknown,\n denomMap: DenomMap,\n): string {\n if (!Array.isArray(balances) || balances.length === 0) return '(empty)';\n return balances\n .map((b) => {\n const amount =\n b !== null && typeof b === 'object' && 'amount' in b && b.amount != null\n ? String(b.amount)\n : '0';\n const denom =\n b !== null && typeof b === 'object' && 'denom' in b\n ? (b.denom as string | null | undefined)\n : undefined;\n return humanizeCoin(amount, denom, denomMap);\n })\n .join(', ');\n}\n\n/**\n * Return the friendly symbol for a chain denom (`\"umfx\"` → `\"MFX\"`) via\n * the same lookup `humanizeCoin` uses. Falls back to the raw denom on\n * unknown input. Avoids the brittle pattern of formatting `\"0 MFX\"` and\n * string-splitting to recover `\"MFX\"`.\n */\nexport function denomToSymbol(\n denom: string | null | undefined,\n denomMap: DenomMap,\n): string {\n if (!denom) return String(denom ?? '');\n const lookup = denomMap.lookup(denom);\n return lookup?.symbol ?? denom;\n}\n"],"mappings":";AA8CA,MAAM,iBAAiB;;;;;;;AAQvB,MAAa,kBAA4B;CAAE,cAAc;CAAM,KAAK;CAAM;AAE1E,eAAsB,kBACpB,mBACmB;AACnB,KAAI,CAAC,kBAAmB,QAAO;AAC/B,KACE,OAAO,YAAY,eACnB,OAAO,QAAQ,UAAU,SAAS,SAIlC,OAAM,IAAI,MACR,4GACD;CAEH,IAAI;AACJ,KAAI;EACF,MAAM,EAAE,iBAAiB,MAAM,OAAO;AACtC,QAAM,KAAK,MAAM,aAAa,mBAAmB,OAAO,CAAC;UAClD,KAAK;EAMZ,MAAM,UAAU,eAAe,QAAQ,IAAI,UAAU,OAAO,IAAI;AAChE,UAAQ,KACN,kCAAkC,kBAAkB,IAAI,QAAQ,2DAEjE;AACD,SAAO;;CAOT,MAAM,sBAAM,IAAI,KAA0B;AAC1C,KAAI,QAAQ,QAAQ,OAAO,QAAQ,UAAU;EAC3C,MAAM,YAAa,IAAgC;AACnD,MAAI,MAAM,QAAQ,UAAU;QACrB,MAAM,KAAK,UACd,KACE,MAAM,QACN,OAAO,MAAM,YACb,OAAQ,EAA0B,UAAU,YAC5C,OAAQ,EAA2B,WAAW,UAC9C;IACA,MAAM,QAAQ;AACd,QAAI,IAAI,MAAM,OAAO;KACnB,QAAQ,MAAM;KACd,UAAU;KACX,CAAC;;;;AAMV,QAAO;EACL,SAAS,UAAU;AACjB,OAAI,OAAO,UAAU,SAAU,QAAO;AACtC,UAAO,IAAI,IAAI,MAAM,IAAI;;EAE3B;EACD;;;;;;;;;;;AAYH,SAAgB,iBAAiB,QAAgB,UAA0B;CACzE,IAAI;AACJ,KAAI;AACF,WAAS,OAAO,OAAO;SACjB;AACN,SAAO,OAAO,OAAO;;CAEvB,MAAM,WAAW,SAAS;AAC1B,KAAI,SAAU,UAAS,CAAC;CACxB,MAAM,UAAU,OAAO,OAAO,SAAS;CACvC,MAAM,QAAQ,SAAS;CAEvB,MAAM,WADO,SAAS,SACD,UAAU,CAAC,SAAS,UAAU,IAAI,CAAC,QAAQ,OAAO,GAAG;CAC1E,IAAI,MAAM,QAAQ,SAAS,IAAI,GAAG,MAAM,GAAG,YAAY,GAAG;AAC1D,KAAI,SAAU,OAAM,IAAI;AACxB,QAAO;;;;;;;AAQT,SAAgB,aACd,QACA,OACA,UACQ;AACR,KAAI,UAAU,KAAA,KAAa,UAAU,KAAM,QAAO,GAAG;CACrD,MAAM,SAAS,SAAS,OAAO,MAAM;AACrC,KAAI,OACF,QAAO,GAAG,iBAAiB,QAAQ,OAAO,SAAS,CAAC,GAAG,OAAO;AAIhE,QAAO,GAAG,OAAO,GAAG;;;;;;AAOtB,SAAgB,iBACd,UACA,UACQ;AACR,KAAI,CAAC,MAAM,QAAQ,SAAS,IAAI,SAAS,WAAW,EAAG,QAAO;AAC9D,QAAO,SACJ,KAAK,MAAM;AASV,SAAO,aAPL,MAAM,QAAQ,OAAO,MAAM,YAAY,YAAY,KAAK,EAAE,UAAU,OAChE,OAAO,EAAE,OAAO,GAChB,KAEJ,MAAM,QAAQ,OAAO,MAAM,YAAY,WAAW,IAC7C,EAAE,QACH,KAAA,GAC6B,SAAS;GAC5C,CACD,KAAK,KAAK;;;;;;;;AASf,SAAgB,cACd,OACA,UACQ;AACR,KAAI,CAAC,MAAO,QAAO,OAAO,SAAS,GAAG;AAEtC,QADe,SAAS,OAAO,MAAM,EACtB,UAAU"}
|
|
1
|
+
{"version":3,"file":"humanize-denom.js","names":[],"sources":["../../src/internals/humanize-denom.ts"],"sourcesContent":["/**\n * Convert chain-side coin amounts (always in the smallest unit) into the\n * human-readable display the user actually wants to see — e.g.\n * `1800000 factory/.../upwr` → `1.8 PWR`, `0.057738 PWR` built from\n * `57738 factory/.../upwr`, etc.\n *\n * The denom → symbol mapping is sourced from a chain registry JSON file\n * (`{ feeTokens: [{ denom, symbol, ... }] }` — every token the chain\n * accepts as gas). Callers pass the chain-data file path and forward the\n * resulting `DenomMap` to whichever helper renders balances; this module\n * just reads, parses, and looks up.\n *\n * Conversion factor: cosmos convention is 6 decimals for `u`-prefixed\n * tokens (umfx, upwr — including factory-wrapped variants). Anything else\n * is rendered untouched (denom kept as-is, amount printed as integer)\n * because we can't safely guess its exponent.\n *\n * **Dynamic node-import discipline** (mirrors `guarded-fetch.ts` +\n * `save-manifest.ts`): the `node:fs` import is deferred to call time so\n * module load doesn't violate the package's `platform: 'neutral'` build\n * target. `loadChainDenomMap` is therefore async; consumers must\n * `await` the result. The other 3 exports (`humanizeCoin`,\n * `humanizeBalances`, `denomToSymbol`) remain pure-sync since they take\n * a pre-loaded `DenomMap` as input.\n *\n * Exports (all 4 preserved per qa-engineer's review pin — PR 2's internal\n * callers use a subset; PR 3 will surface the rest):\n * - `loadChainDenomMap(chainDataFilePath?)` (ASYNC) — returns\n * `Promise<DenomMap>`. Missing / unreadable path → no-op map\n * (lookup always returns `null`). Read failures emit `console.warn`\n * matching the connection.ts precedent from PR 1.\n * - `humanizeCoin(amount, denom, denomMap)` — `\"<amount> <symbol>\"` or\n * `\"<amount> <denom>\"` on unknown denom.\n * - `humanizeBalances(coins, denomMap)` — joins multiple coins with\n * `\", \"`. Empty array → `\"(empty)\"` literal.\n * - `denomToSymbol(denom, denomMap)` — bare symbol or raw denom fallback.\n */\n\nimport type { DenomLookup, DenomMap } from '../types.js';\n\n// Re-export the public types for convenience to existing internal consumers\n// (this file's pre-PR-3 history exported DenomLookup + DenomMap directly).\n// Public consumers should import from `@manifest-network/manifest-agent-core`\n// (which re-exports `../types.js`); internal consumers can use either path.\nexport type { DenomLookup, DenomMap };\n\nconst KNOWN_EXPONENT = 6;\n\n/**\n * No-op `DenomMap` for callers without chain-data context. All lookups\n * return `null`; `humanizeCoin` falls back to raw on-chain denoms.\n * Exported so synchronous decision functions (e.g. `evaluateReadiness`)\n * can default to it without needing to invoke the async loader.\n */\nexport const EMPTY_DENOM_MAP: DenomMap = { lookup: () => null, raw: null };\n\nexport async function loadChainDenomMap(\n chainDataFilePath?: string,\n): Promise<DenomMap> {\n if (!chainDataFilePath) return EMPTY_DENOM_MAP;\n if (\n typeof process === 'undefined' ||\n typeof process.versions?.node !== 'string'\n ) {\n // Lazy node-only dep — refuse outside Node-like runtimes rather than\n // silently no-op'ing (which would hide a misconfiguration).\n throw new Error(\n 'loadChainDenomMap: chainDataFilePath requires a Node.js runtime (node:fs unavailable in this environment)',\n );\n }\n let raw: unknown;\n try {\n const { readFileSync } = await import('node:fs');\n raw = JSON.parse(readFileSync(chainDataFilePath, 'utf8'));\n } catch (err) {\n // CJS parity: warn loudly when a path was passed but read/parse failed.\n // A corrupted chain file silently downgrades all balance/fee rendering to\n // raw chain denoms across the package, and the user only notices because\n // the DeploymentPlan looks weird (\"0.000037 PWR\" vs \"37 upwr\"). Matches\n // connection.ts's `console.warn` default established in PR 1.\n const message = err instanceof Error ? err.message : String(err);\n console.warn(\n `humanize-denom: failed to load ${chainDataFilePath}: ${message}; ` +\n `balances and fees will render with raw on-chain denoms.`,\n );\n return EMPTY_DENOM_MAP;\n }\n\n // Normalize the feeTokens list into a denom → { symbol, exponent } map.\n // Every Manifest fee token uses 6 decimals (the leading `u` is the micro\n // prefix). Tokens not in feeTokens are unknown to us; the fallback branch\n // in humanizeCoin handles them.\n const map = new Map<string, DenomLookup>();\n if (raw !== null && typeof raw === 'object') {\n const feeTokens = (raw as { feeTokens?: unknown }).feeTokens;\n if (Array.isArray(feeTokens)) {\n for (const t of feeTokens) {\n if (\n t !== null &&\n typeof t === 'object' &&\n typeof (t as { denom?: unknown }).denom === 'string' &&\n typeof (t as { symbol?: unknown }).symbol === 'string'\n ) {\n const token = t as { denom: string; symbol: string };\n map.set(token.denom, {\n symbol: token.symbol,\n exponent: KNOWN_EXPONENT,\n });\n }\n }\n }\n }\n\n return {\n lookup: (denom) => {\n if (typeof denom !== 'string') return null;\n return map.get(denom) ?? null;\n },\n raw,\n };\n}\n\n/**\n * Convert a smallest-unit amount string → human decimal string with up to\n * `exponent` decimals, trimming trailing zeros for readability. Uses BigInt\n * for the integer part so precision survives large balances; only the\n * fractional remainder is divided.\n *\n * Exported for unit testing of the scaling logic in isolation (mirrors the\n * CJS's `_fmtScaledAmount` test hook).\n */\nexport function _fmtScaledAmount(amount: string, exponent: number): string {\n let digits: bigint;\n try {\n digits = BigInt(amount);\n } catch {\n return String(amount);\n }\n const negative = digits < 0n;\n if (negative) digits = -digits;\n const divisor = 10n ** BigInt(exponent);\n const whole = digits / divisor;\n const frac = digits % divisor;\n const fracStr = frac.toString().padStart(exponent, '0').replace(/0+$/, '');\n let out = fracStr.length > 0 ? `${whole}.${fracStr}` : `${whole}`;\n if (negative) out = `-${out}`;\n return out;\n}\n\n/**\n * Render a single coin as `\"<amount> <symbol>\"` (when the denom is in the\n * map) or `\"<amount> <denom>\"` verbatim (when unknown). Falls back to\n * `\"<amount>\"` only when `denom` is null/undefined.\n */\nexport function humanizeCoin(\n amount: string,\n denom: string | null | undefined,\n denomMap: DenomMap,\n): string {\n if (denom === undefined || denom === null) return `${amount}`;\n const lookup = denomMap.lookup(denom);\n if (lookup) {\n return `${_fmtScaledAmount(amount, lookup.exponent)} ${lookup.symbol}`;\n }\n // Best-effort unknown-denom rendering — keep the raw denom so the user\n // can still identify it, and don't guess at scaling.\n return `${amount} ${denom}`;\n}\n\n/**\n * Join multiple coins with `\", \"` (space after comma). Empty array →\n * literal `\"(empty)\"` per CJS parity.\n */\nexport function humanizeBalances(\n balances: ReadonlyArray<{ denom?: string; amount?: string | null }> | unknown,\n denomMap: DenomMap,\n): string {\n if (!Array.isArray(balances) || balances.length === 0) return '(empty)';\n return balances\n .map((b) => {\n const amount =\n b !== null && typeof b === 'object' && 'amount' in b && b.amount != null\n ? String(b.amount)\n : '0';\n const denom =\n b !== null && typeof b === 'object' && 'denom' in b\n ? (b.denom as string | null | undefined)\n : undefined;\n return humanizeCoin(amount, denom, denomMap);\n })\n .join(', ');\n}\n\n/**\n * Return the friendly symbol for a chain denom (`\"umfx\"` → `\"MFX\"`) via\n * the same lookup `humanizeCoin` uses. Falls back to the raw denom on\n * unknown input. Avoids the brittle pattern of formatting `\"0 MFX\"` and\n * string-splitting to recover `\"MFX\"`.\n */\nexport function denomToSymbol(\n denom: string | null | undefined,\n denomMap: DenomMap,\n): string {\n if (!denom) return String(denom ?? '');\n const lookup = denomMap.lookup(denom);\n return lookup?.symbol ?? denom;\n}\n"],"mappings":";AA8CA,MAAM,iBAAiB;;;;;;;AAQvB,MAAa,kBAA4B;CAAE,cAAc;CAAM,KAAK;CAAM;AAE1E,eAAsB,kBACpB,mBACmB;AACnB,KAAI,CAAC,kBAAmB,QAAO;AAC/B,KACE,OAAO,YAAY,eACnB,OAAO,QAAQ,UAAU,SAAS,SAIlC,OAAM,IAAI,MACR,4GACD;CAEH,IAAI;AACJ,KAAI;EACF,MAAM,EAAE,iBAAiB,MAAM,OAAO;AACtC,QAAM,KAAK,MAAM,aAAa,mBAAmB,OAAO,CAAC;UAClD,KAAK;EAMZ,MAAM,UAAU,eAAe,QAAQ,IAAI,UAAU,OAAO,IAAI;AAChE,UAAQ,KACN,kCAAkC,kBAAkB,IAAI,QAAQ,2DAEjE;AACD,SAAO;;CAOT,MAAM,sBAAM,IAAI,KAA0B;AAC1C,KAAI,QAAQ,QAAQ,OAAO,QAAQ,UAAU;EAC3C,MAAM,YAAa,IAAgC;AACnD,MAAI,MAAM,QAAQ,UAAU;QACrB,MAAM,KAAK,UACd,KACE,MAAM,QACN,OAAO,MAAM,YACb,OAAQ,EAA0B,UAAU,YAC5C,OAAQ,EAA2B,WAAW,UAC9C;IACA,MAAM,QAAQ;AACd,QAAI,IAAI,MAAM,OAAO;KACnB,QAAQ,MAAM;KACd,UAAU;KACX,CAAC;;;;AAMV,QAAO;EACL,SAAS,UAAU;AACjB,OAAI,OAAO,UAAU,SAAU,QAAO;AACtC,UAAO,IAAI,IAAI,MAAM,IAAI;;EAE3B;EACD;;;;;;;;;;;AAYH,SAAgB,iBAAiB,QAAgB,UAA0B;CACzE,IAAI;AACJ,KAAI;AACF,WAAS,OAAO,OAAO;SACjB;AACN,SAAO,OAAO,OAAO;;CAEvB,MAAM,WAAW,SAAS;AAC1B,KAAI,SAAU,UAAS,CAAC;CACxB,MAAM,UAAU,OAAO,OAAO,SAAS;CACvC,MAAM,QAAQ,SAAS;CAEvB,MAAM,WADO,SAAS,SACD,UAAU,CAAC,SAAS,UAAU,IAAI,CAAC,QAAQ,OAAO,GAAG;CAC1E,IAAI,MAAM,QAAQ,SAAS,IAAI,GAAG,MAAM,GAAG,YAAY,GAAG;AAC1D,KAAI,SAAU,OAAM,IAAI;AACxB,QAAO;;;;;;;AAQT,SAAgB,aACd,QACA,OACA,UACQ;AACR,KAAI,UAAU,KAAA,KAAa,UAAU,KAAM,QAAO,GAAG;CACrD,MAAM,SAAS,SAAS,OAAO,MAAM;AACrC,KAAI,OACF,QAAO,GAAG,iBAAiB,QAAQ,OAAO,SAAS,CAAC,GAAG,OAAO;AAIhE,QAAO,GAAG,OAAO,GAAG;;;;;;AAOtB,SAAgB,iBACd,UACA,UACQ;AACR,KAAI,CAAC,MAAM,QAAQ,SAAS,IAAI,SAAS,WAAW,EAAG,QAAO;AAC9D,QAAO,SACJ,KAAK,MAAM;AASV,SAAO,aAPL,MAAM,QAAQ,OAAO,MAAM,YAAY,YAAY,KAAK,EAAE,UAAU,OAChE,OAAO,EAAE,OAAO,GAChB,KAEJ,MAAM,QAAQ,OAAO,MAAM,YAAY,WAAW,IAC7C,EAAE,QACH,KAAA,GAC6B,SAAS;GAC5C,CACD,KAAK,KAAK;;;;;;;;AASf,SAAgB,cACd,OACA,UACQ;AACR,KAAI,CAAC,MAAO,QAAO,OAAO,SAAS,GAAG;AAEtC,QADe,SAAS,OAAO,MAClB,EAAE,UAAU"}
|