@manifest-network/manifest-agent-core 0.10.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.
Files changed (35) hide show
  1. package/dist/close-lease.d.ts +3 -2
  2. package/dist/close-lease.d.ts.map +1 -1
  3. package/dist/close-lease.js +4 -3
  4. package/dist/close-lease.js.map +1 -1
  5. package/dist/deploy-app.d.ts +3 -2
  6. package/dist/deploy-app.d.ts.map +1 -1
  7. package/dist/deploy-app.js +245 -77
  8. package/dist/deploy-app.js.map +1 -1
  9. package/dist/internals/build-fred-input.d.ts +38 -0
  10. package/dist/internals/build-fred-input.d.ts.map +1 -0
  11. package/dist/internals/build-fred-input.js +147 -0
  12. package/dist/internals/build-fred-input.js.map +1 -0
  13. package/dist/internals/evaluate-readiness-from-fred.d.ts +28 -0
  14. package/dist/internals/evaluate-readiness-from-fred.d.ts.map +1 -0
  15. package/dist/internals/evaluate-readiness-from-fred.js +94 -0
  16. package/dist/internals/evaluate-readiness-from-fred.js.map +1 -0
  17. package/dist/internals/format-success.js.map +1 -1
  18. package/dist/internals/guarded-fetch.d.ts +2 -138
  19. package/dist/internals/guarded-fetch.js +1 -241
  20. package/dist/internals/humanize-denom.js.map +1 -1
  21. package/dist/internals/inspect-image.js.map +1 -1
  22. package/dist/internals/lease-items.js +1 -4
  23. package/dist/internals/lease-items.js.map +1 -1
  24. package/dist/internals/render-deployment-plan.js.map +1 -1
  25. package/dist/internals/verify-recover.js.map +1 -1
  26. package/dist/manage-domain.d.ts +3 -2
  27. package/dist/manage-domain.d.ts.map +1 -1
  28. package/dist/manage-domain.js +4 -3
  29. package/dist/manage-domain.js.map +1 -1
  30. package/dist/troubleshoot.js.map +1 -1
  31. package/dist/types.d.ts +19 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +3 -5
  34. package/dist/internals/guarded-fetch.d.ts.map +0 -1
  35. 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,WAAW,CAErD,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
+ {"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
- //#region src/internals/guarded-fetch.d.ts
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 ipaddr from "ipaddr.js";
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"}