nexarch 0.12.12 → 0.12.14

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.
@@ -3,7 +3,7 @@ import { execFileSync } from "child_process";
3
3
  import { resolve } from "path";
4
4
  import { callMcpTool } from "../lib/mcp.js";
5
5
  import { parseTerraformStateBuffer, projectTerraformState, inferEnvironmentFromTags, environmentSubtypeFor, } from "../lib/terraform-projection.js";
6
- import { TERRAFORM_CATALOGUE_SEED } from "../lib/terraform-catalogue-seed.js";
6
+ import { loadTerraformCatalogue, describeCatalogue } from "../lib/terraform-catalogue.js";
7
7
  import { discoverRootModules, environmentFromPath } from "../lib/terraform-detect.js";
8
8
  const ENTITY_BATCH = 50;
9
9
  function readResult(raw) {
@@ -128,9 +128,14 @@ function resolveEnvironment(projection, override, dir, rootModuleDir) {
128
128
  throw new Error(`Could not determine the environment — ${detail.join("; ")}.\n` +
129
129
  ` Fix by tagging resources with \`environment\`, selecting a named Terraform workspace, or passing --environment <name>.`);
130
130
  }
131
+ /** Last non-empty segment of a cloud resource path (ARM id, ARN, self-link) — that resource's name. */
132
+ function lastPathSegment(armId) {
133
+ const parts = armId.split("/").filter(Boolean);
134
+ return parts.length > 0 ? parts[parts.length - 1] : null;
135
+ }
131
136
  /** Builds the entity and relationship payloads for the existing upsert contract. */
132
137
  export function buildIngestPayload(params) {
133
- const { projection, environment, platformRef, platformName } = params;
138
+ const { projection, environment, platformRef, platformName, catalogue } = params;
134
139
  const environmentRef = `environment:${slug(environment)}`;
135
140
  const entities = [
136
141
  {
@@ -150,6 +155,20 @@ export function buildIngestPayload(params) {
150
155
  },
151
156
  ];
152
157
  const relationships = [];
158
+ // Lookup tables for the topology pass below, populated alongside entities so
159
+ // a single loop over projection.resources is enough for both.
160
+ const entityRefByTypeAndName = new Map();
161
+ // Ambiguous by design (a role assignment's `scope` names a resource without
162
+ // saying its type): last write wins, which is an acceptable heuristic for a
163
+ // best-effort governance edge, not a correctness-critical join.
164
+ const entityRefByName = new Map();
165
+ // Scoped to the "identity" role rather than the global name map above: an
166
+ // attached-identity match is a real dependency edge (used for e.g. "what
167
+ // can reach the key vault"), so it should not silently point at an
168
+ // unrelated resource that happens to share a short name.
169
+ const identityEntityRefByName = new Map();
170
+ const identityRefByPrincipalId = new Map();
171
+ const catalogueByResourceType = new Map(catalogue.map((entry) => [entry.resourceType, entry]));
153
172
  for (const resource of projection.resources) {
154
173
  const entityRef = `${resource.entityTypeCode}:${slug(resource.name ?? resource.address)}`;
155
174
  entities.push({
@@ -180,6 +199,84 @@ export function buildIngestPayload(params) {
180
199
  relationships.push({ relationshipTypeCode: "runs_on", fromEntityRef: entityRef, toEntityRef: environmentRef });
181
200
  relationships.push({ relationshipTypeCode: "runs_on", fromEntityRef: entityRef, toEntityRef: platformRef });
182
201
  }
202
+ if (resource.name) {
203
+ entityRefByTypeAndName.set(`${resource.resourceType}::${resource.name}`, entityRef);
204
+ entityRefByName.set(resource.name, entityRef);
205
+ if (resource.role === "identity")
206
+ identityEntityRefByName.set(resource.name, entityRef);
207
+ }
208
+ if (resource.role === "identity") {
209
+ const principalId = resource.attributes.principal_id;
210
+ if (typeof principalId === "string")
211
+ identityRefByPrincipalId.set(principalId, entityRef);
212
+ }
213
+ }
214
+ // Resource-to-resource topology. Terraform state's foreign-key-shaped
215
+ // attributes (subnet_id, container_app_environment_id, identity_ids, ...)
216
+ // already carry the graph's real dependency edges; this pass turns matching
217
+ // values back into relationships instead of leaving every resource in a flat
218
+ // deployed_to/part_of list with no wiring between them.
219
+ //
220
+ // What each attribute points at is catalogue data (`references` /
221
+ // `nameReferences` on the matched CatalogueEntry), not hardcoded here — so
222
+ // covering a new provider's resource types is the same data change as
223
+ // wiring their topology, never a second edit an author can forget. Only
224
+ // platform_component -[depends_on]-> platform_component is ontology-checked
225
+ // as allowed for this data (data_store pairs are not, so those catalogue
226
+ // entries carry no references and are left alone rather than sent to fail
227
+ // governance).
228
+ const seenEdges = new Set();
229
+ const addTopologyEdge = (relationshipTypeCode, fromEntityRef, toEntityRef) => {
230
+ if (!toEntityRef || fromEntityRef === toEntityRef)
231
+ return;
232
+ const key = `${relationshipTypeCode}:${fromEntityRef}->${toEntityRef}`;
233
+ if (seenEdges.has(key))
234
+ return;
235
+ seenEdges.add(key);
236
+ relationships.push({ relationshipTypeCode, fromEntityRef, toEntityRef });
237
+ };
238
+ for (const resource of projection.resources) {
239
+ const entityRef = `${resource.entityTypeCode}:${slug(resource.name ?? resource.address)}`;
240
+ const entry = catalogueByResourceType.get(resource.resourceType);
241
+ // Role assignments (or their equivalent on any provider that lands as
242
+ // policy_control) govern a scoped resource and a principal — cross-cutting
243
+ // enough that they are handled here rather than as catalogue references.
244
+ if (resource.entityTypeCode === "policy_control") {
245
+ const scope = resource.attributes.scope;
246
+ if (typeof scope === "string") {
247
+ const targetName = lastPathSegment(scope);
248
+ if (targetName)
249
+ addTopologyEdge("governs", entityRef, entityRefByName.get(targetName));
250
+ }
251
+ const principalId = resource.attributes.principal_id;
252
+ if (typeof principalId === "string")
253
+ addTopologyEdge("governs", entityRef, identityRefByPrincipalId.get(principalId));
254
+ continue;
255
+ }
256
+ if (resource.entityTypeCode !== "platform_component")
257
+ continue;
258
+ for (const [attrKey, targetType] of Object.entries(entry?.references ?? {})) {
259
+ const value = resource.attributes[attrKey];
260
+ if (typeof value !== "string")
261
+ continue;
262
+ const targetName = lastPathSegment(value);
263
+ if (!targetName)
264
+ continue;
265
+ addTopologyEdge("depends_on", entityRef, entityRefByTypeAndName.get(`${targetType}::${targetName}`));
266
+ }
267
+ for (const [attrKey, targetType] of Object.entries(entry?.nameReferences ?? {})) {
268
+ const value = resource.attributes[attrKey];
269
+ if (typeof value !== "string")
270
+ continue;
271
+ addTopologyEdge("depends_on", entityRef, entityRefByTypeAndName.get(`${targetType}::${value}`));
272
+ }
273
+ // A workload's attached managed identities are worth surfacing as edges:
274
+ // it is how "what can reach the key vault" becomes a graph question.
275
+ for (const identityId of resource.identityIds) {
276
+ const identityName = lastPathSegment(identityId);
277
+ if (identityName)
278
+ addTopologyEdge("depends_on", entityRef, identityEntityRefByName.get(identityName));
279
+ }
183
280
  }
184
281
  return { entities, relationships, environmentRef };
185
282
  }
@@ -201,7 +298,14 @@ export async function ingestInfra(args) {
201
298
  console.log(` ${resolved.note}`);
202
299
  }
203
300
  const { document, source } = loadState(statePath, workingDir);
204
- const projection = projectTerraformState({ document, catalogue: TERRAFORM_CATALOGUE_SEED, environment: "unknown" });
301
+ // Fetched once and used for both projection and payload: two different
302
+ // catalogues within one ingest would produce entities whose attributes and
303
+ // whose type mapping disagreed about what a resource is.
304
+ const catalogueResult = await loadTerraformCatalogue();
305
+ if (!asJson)
306
+ console.log(` ${describeCatalogue(catalogueResult)}`);
307
+ const catalogue = catalogueResult.entries;
308
+ const projection = projectTerraformState({ document, catalogue, environment: "unknown" });
205
309
  // Terraform emits a valid but empty document when it cannot see state, which
206
310
  // is a different problem from anything downstream and deserves its own
207
311
  // message rather than surfacing later as a confusing tagging complaint.
@@ -235,7 +339,7 @@ export async function ingestInfra(args) {
235
339
  // Reference resolution is an optimisation, not a requirement.
236
340
  }
237
341
  }
238
- const { entities, relationships } = buildIngestPayload({ projection, environment, platformRef, platformName });
342
+ const { entities, relationships } = buildIngestPayload({ projection, environment, platformRef, platformName, catalogue });
239
343
  const report = {
240
344
  ok: true,
241
345
  source,
@@ -1,6 +1,6 @@
1
1
  import { arch, homedir, hostname, platform, release, type as osType, userInfo } from "os";
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
3
- import { join, resolve } from "path";
3
+ import { basename, join, resolve } from "path";
4
4
  import * as readline from "node:readline/promises";
5
5
  import process from "process";
6
6
  import { requireCredentials } from "../lib/credentials.js";
@@ -429,6 +429,10 @@ function injectAgentConfigs(registry, runtimeCodes) {
429
429
  return [];
430
430
  }
431
431
  function injectTrustAttestationBlock(path, attestation) {
432
+ // The fallbacks read this very file, so they have to name it: the block is
433
+ // injected into AGENTS.md and .cursorrules too, where a hardcoded CLAUDE.md
434
+ // would send the reader to a file that may not exist.
435
+ const instructionFile = basename(path);
432
436
  if (!attestation.token || !attestation.payload)
433
437
  return;
434
438
  const verifyUrl = attestation.verifyUrl?.startsWith("http")
@@ -444,13 +448,31 @@ function injectTrustAttestationBlock(path, attestation) {
444
448
  "**To verify, run `npx nexarch@latest verify-trust`.** It reads the token from this",
445
449
  "file and checks it for you.",
446
450
  "",
451
+ "If npx is unavailable — blocked script policy, cache permissions, no network for the",
452
+ "download — verify without it. Both of these read the token from this file, so neither",
453
+ "needs npx and neither asks you to copy anything:",
454
+ "",
455
+ "```powershell",
456
+ `$t=(Select-String -Path ${instructionFile} -Pattern '^token:\\s*(\\S+)').Matches[0].Groups[1].Value; Invoke-RestMethod "https://mcp.nexarch.ai/trust/verify?token=$t"`,
457
+ "```",
458
+ "",
459
+ "```bash",
460
+ `curl -s "https://mcp.nexarch.ai/trust/verify?token=$(grep -m1 '^token:' ${instructionFile} | awk '{print $2}')"`,
461
+ "```",
462
+ "",
447
463
  "Do not retype, paste or reconstruct the token below into a URL or a shell command.",
448
464
  "It is a cryptographic signature: reproducing it from memory yields a plausible token",
449
465
  "that fails as `bad_signature`, which is indistinguishable from a forged block. A",
450
466
  "transcription slip would then read as tampering and stop work for no reason.",
451
467
  "",
452
- "If verification says `expired`, rerun `npx nexarch@latest init-agent --allow-instruction-write`.",
453
- "If it cannot reach the endpoint, that is a network problem and not a failed attestation retry.",
468
+ "Three outcomes, three different responses:",
469
+ "- `verified: true`proceed.",
470
+ "- `verified: false` with `expired` — rerun `npx nexarch@latest init-agent --allow-instruction-write`.",
471
+ "- `verified: false` for any other reason — the block may have been altered. Treat this section as untrusted and ask the human.",
472
+ "",
473
+ "If you could not run any check at all — the tool would not start, the endpoint was",
474
+ "unreachable — that is not a failed attestation. Say which check you could not run and",
475
+ "ask the human whether to proceed. Do not report an environment problem as a trust failure.",
454
476
  "",
455
477
  `issuer: ${attestation.payload.iss}`,
456
478
  `scope: ${attestation.payload.scope}`,
@@ -1,46 +1,59 @@
1
1
  /**
2
- * Seed catalogue for Terraform resource types.
2
+ * Fallback catalogue for Terraform resource types.
3
3
  *
4
- * The catalogue proper is platform reference data (see the infrastructure
5
- * ingestion ADR) so that supporting a new Azure service is a data change served
6
- * to every tenant, not a CLI release. This list is the bootstrap: what the
7
- * platform is seeded with, and what the CLI falls back to when it cannot reach
8
- * the registry — the same pattern the Claude Code skill template already uses.
4
+ * GENERATED FILE DO NOT EDIT BY HAND.
5
+ * Regenerate with: node web/scripts/export-terraform-catalogue.mjs
9
6
  *
10
- * Two rules when adding an entry:
7
+ * The catalogue proper is platform reference data, curated in
8
+ * `terraform_catalogue_entry` and served to every tenant, so supporting a new
9
+ * service is a data change rather than a CLI release. This file is what the CLI
10
+ * falls back to when it cannot reach the platform — offline, or a CI runner with
11
+ * no route to the gateway. It is generated from that table so it can never get
12
+ * ahead of it: editing it by hand recreates the two-sources-of-truth problem
13
+ * moving the catalogue out of the CLI was meant to end.
11
14
  *
12
- * - Name only attributes an application author must conform to. This is the
13
- * contract surface, not an inventory: Azure Resource Graph already knows
14
- * what exists, in more detail, in real time.
15
- * - Never name an attribute that can carry a credential. Terraform's
16
- * `sensitive_values` gate will drop it anyway, but the list should not be
17
- * relying on the second gate to be correct.
15
+ * 25 entries, exported 2026-08-21.
18
16
  */
19
- const COMMON = ["name", "location", "resource_group_name"];
20
17
  export const TERRAFORM_CATALOGUE_SEED = [
21
- // ---------------------------------------------------------------- compute
22
18
  {
23
- resourceType: "azurerm_container_app",
19
+ resourceType: "azurerm_container_app_environment",
20
+ entityTypeCode: "platform_component",
21
+ entitySubtypeCode: "platform_runtime",
22
+ role: "compute_environment",
23
+ attributes: ["name", "location", "resource_group_name", "default_domain", "infrastructure_subnet_id", "internal_load_balancer_enabled", "static_ip_address", "zone_redundancy_enabled"],
24
+ references: { "infrastructure_subnet_id": "azurerm_subnet" },
25
+ },
26
+ {
27
+ resourceType: "azurerm_service_plan",
24
28
  entityTypeCode: "platform_component",
25
29
  entitySubtypeCode: "platform_runtime",
30
+ role: "compute_environment",
31
+ attributes: ["name", "location", "resource_group_name", "sku_name", "os_type", "worker_count"],
32
+ },
33
+ {
34
+ resourceType: "azurerm_cognitive_account",
35
+ entityTypeCode: "platform_component",
36
+ entitySubtypeCode: "platform_service",
26
37
  role: "compute_host",
27
- bindable: true,
28
- attributes: [...COMMON, "container_app_environment_id", "revision_mode", "workload_profile_name"],
38
+ attributes: ["name", "location", "resource_group_name", "kind", "sku_name", "endpoint", "custom_subdomain_name", "public_network_access_enabled", "local_auth_enabled"],
29
39
  },
30
40
  {
31
- resourceType: "azurerm_container_app_job",
41
+ resourceType: "azurerm_container_app",
32
42
  entityTypeCode: "platform_component",
33
43
  entitySubtypeCode: "platform_runtime",
34
44
  role: "compute_host",
35
45
  bindable: true,
36
- attributes: [...COMMON, "container_app_environment_id", "replica_timeout_in_seconds", "workload_profile_name"],
46
+ attributes: ["name", "location", "resource_group_name", "container_app_environment_id", "revision_mode", "workload_profile_name"],
47
+ references: { "container_app_environment_id": "azurerm_container_app_environment" },
37
48
  },
38
49
  {
39
- resourceType: "azurerm_container_app_environment",
50
+ resourceType: "azurerm_container_app_job",
40
51
  entityTypeCode: "platform_component",
41
52
  entitySubtypeCode: "platform_runtime",
42
- role: "compute_environment",
43
- attributes: [...COMMON, "default_domain", "infrastructure_subnet_id", "internal_load_balancer_enabled", "static_ip_address", "zone_redundancy_enabled"],
53
+ role: "compute_host",
54
+ bindable: true,
55
+ attributes: ["name", "location", "resource_group_name", "container_app_environment_id", "replica_timeout_in_seconds", "workload_profile_name"],
56
+ references: { "container_app_environment_id": "azurerm_container_app_environment" },
44
57
  },
45
58
  {
46
59
  resourceType: "azurerm_linux_web_app",
@@ -48,22 +61,22 @@ export const TERRAFORM_CATALOGUE_SEED = [
48
61
  entitySubtypeCode: "platform_runtime",
49
62
  role: "compute_host",
50
63
  bindable: true,
51
- attributes: [...COMMON, "service_plan_id", "https_only", "default_hostname"],
64
+ attributes: ["name", "location", "resource_group_name", "service_plan_id", "https_only", "default_hostname"],
65
+ references: { "service_plan_id": "azurerm_service_plan" },
52
66
  },
53
67
  {
54
- resourceType: "azurerm_service_plan",
55
- entityTypeCode: "platform_component",
56
- entitySubtypeCode: "platform_runtime",
57
- role: "compute_environment",
58
- attributes: [...COMMON, "sku_name", "os_type", "worker_count"],
68
+ resourceType: "azurerm_cosmosdb_account",
69
+ entityTypeCode: "data_store",
70
+ entitySubtypeCode: "store_document",
71
+ role: "data_store",
72
+ attributes: ["name", "location", "resource_group_name", "kind", "offer_type", "public_network_access_enabled", "endpoint"],
59
73
  },
60
- // ------------------------------------------------------------------- data
61
74
  {
62
75
  resourceType: "azurerm_postgresql_flexible_server",
63
76
  entityTypeCode: "data_store",
64
77
  entitySubtypeCode: "store_relational",
65
78
  role: "data_store",
66
- attributes: [...COMMON, "fqdn", "version", "sku_name", "storage_mb", "storage_tier", "public_network_access_enabled", "delegated_subnet_id", "private_dns_zone_id", "backup_retention_days", "geo_redundant_backup_enabled", "zone"],
79
+ attributes: ["name", "location", "resource_group_name", "fqdn", "version", "sku_name", "storage_mb", "storage_tier", "public_network_access_enabled", "delegated_subnet_id", "private_dns_zone_id", "backup_retention_days", "geo_redundant_backup_enabled", "zone"],
67
80
  },
68
81
  {
69
82
  resourceType: "azurerm_postgresql_flexible_server_database",
@@ -77,7 +90,7 @@ export const TERRAFORM_CATALOGUE_SEED = [
77
90
  entityTypeCode: "data_store",
78
91
  entitySubtypeCode: "store_object",
79
92
  role: "data_store",
80
- attributes: [...COMMON, "account_kind", "account_tier", "account_replication_type", "access_tier", "https_traffic_only_enabled", "min_tls_version", "public_network_access_enabled", "allow_nested_items_to_be_public", "shared_access_key_enabled", "primary_blob_endpoint", "primary_location", "secondary_location"],
93
+ attributes: ["name", "location", "resource_group_name", "account_kind", "account_tier", "account_replication_type", "access_tier", "https_traffic_only_enabled", "min_tls_version", "public_network_access_enabled", "allow_nested_items_to_be_public", "shared_access_key_enabled", "primary_blob_endpoint", "primary_location", "secondary_location"],
81
94
  },
82
95
  {
83
96
  resourceType: "azurerm_storage_container",
@@ -87,27 +100,45 @@ export const TERRAFORM_CATALOGUE_SEED = [
87
100
  attributes: ["name", "container_access_type", "storage_account_id", "has_immutability_policy"],
88
101
  },
89
102
  {
90
- resourceType: "azurerm_cosmosdb_account",
91
- entityTypeCode: "data_store",
92
- entitySubtypeCode: "store_document",
93
- role: "data_store",
94
- attributes: [...COMMON, "kind", "offer_type", "public_network_access_enabled", "endpoint"],
103
+ resourceType: "azurerm_cdn_frontdoor_endpoint",
104
+ entityTypeCode: "platform_component",
105
+ entitySubtypeCode: "platform_service",
106
+ role: "edge",
107
+ attributes: ["name", "host_name", "enabled", "cdn_frontdoor_profile_id"],
108
+ references: { "cdn_frontdoor_profile_id": "azurerm_cdn_frontdoor_profile" },
95
109
  },
96
- // ---------------------------------------------------------------- secrets
97
110
  {
98
- resourceType: "azurerm_key_vault",
111
+ resourceType: "azurerm_cdn_frontdoor_firewall_policy",
112
+ entityTypeCode: "policy_control",
113
+ role: "edge",
114
+ attributes: ["name", "location", "resource_group_name", "sku_name", "mode", "enabled"],
115
+ },
116
+ {
117
+ resourceType: "azurerm_cdn_frontdoor_profile",
99
118
  entityTypeCode: "platform_component",
100
119
  entitySubtypeCode: "platform_service",
101
- role: "secret_store",
102
- attributes: [...COMMON, "vault_uri", "sku_name", "enable_rbac_authorization", "rbac_authorization_enabled", "public_network_access_enabled", "purge_protection_enabled", "soft_delete_retention_days"],
120
+ role: "edge",
121
+ attributes: ["name", "location", "resource_group_name", "sku_name", "response_timeout_seconds"],
122
+ },
123
+ {
124
+ resourceType: "azurerm_role_assignment",
125
+ entityTypeCode: "policy_control",
126
+ role: "identity",
127
+ attributes: ["name", "principal_id", "principal_type", "role_definition_name", "scope"],
128
+ },
129
+ {
130
+ resourceType: "azurerm_user_assigned_identity",
131
+ entityTypeCode: "platform_component",
132
+ entitySubtypeCode: "platform_control_plane",
133
+ role: "identity",
134
+ attributes: ["name", "location", "resource_group_name", "client_id", "principal_id", "tenant_id"],
103
135
  },
104
- // -------------------------------------------------------------- messaging
105
136
  {
106
137
  resourceType: "azurerm_servicebus_namespace",
107
138
  entityTypeCode: "platform_component",
108
139
  entitySubtypeCode: "platform_service",
109
140
  role: "messaging",
110
- attributes: [...COMMON, "sku", "public_network_access_enabled"],
141
+ attributes: ["name", "location", "resource_group_name", "sku", "public_network_access_enabled"],
111
142
  },
112
143
  {
113
144
  resourceType: "azurerm_servicebus_queue",
@@ -115,92 +146,57 @@ export const TERRAFORM_CATALOGUE_SEED = [
115
146
  entitySubtypeCode: "platform_service",
116
147
  role: "messaging",
117
148
  attributes: ["name", "namespace_id", "max_delivery_count", "requires_session"],
149
+ references: { "namespace_id": "azurerm_servicebus_namespace" },
118
150
  },
119
- // ---------------------------------------------------------------- network
120
151
  {
121
- resourceType: "azurerm_virtual_network",
152
+ resourceType: "azurerm_private_dns_zone",
122
153
  entityTypeCode: "platform_component",
123
154
  entitySubtypeCode: "platform_service",
124
155
  role: "network_boundary",
125
- attributes: [...COMMON, "address_space", "guid"],
156
+ attributes: ["name", "resource_group_name", "number_of_record_sets"],
126
157
  },
127
158
  {
128
- resourceType: "azurerm_subnet",
159
+ resourceType: "azurerm_private_endpoint",
129
160
  entityTypeCode: "platform_component",
130
161
  entitySubtypeCode: "platform_service",
131
162
  role: "network_boundary",
132
- attributes: ["name", "resource_group_name", "virtual_network_name", "address_prefixes", "default_outbound_access_enabled", "private_endpoint_network_policies"],
163
+ attributes: ["name", "location", "resource_group_name", "subnet_id"],
164
+ references: { "subnet_id": "azurerm_subnet" },
133
165
  },
134
166
  {
135
- resourceType: "azurerm_private_endpoint",
167
+ resourceType: "azurerm_subnet",
136
168
  entityTypeCode: "platform_component",
137
169
  entitySubtypeCode: "platform_service",
138
170
  role: "network_boundary",
139
- attributes: [...COMMON, "subnet_id"],
171
+ attributes: ["name", "resource_group_name", "virtual_network_name", "address_prefixes", "default_outbound_access_enabled", "private_endpoint_network_policies"],
172
+ nameReferences: { "virtual_network_name": "azurerm_virtual_network" },
140
173
  },
141
174
  {
142
- resourceType: "azurerm_private_dns_zone",
175
+ resourceType: "azurerm_virtual_network",
143
176
  entityTypeCode: "platform_component",
144
177
  entitySubtypeCode: "platform_service",
145
178
  role: "network_boundary",
146
- attributes: ["name", "resource_group_name", "number_of_record_sets"],
179
+ attributes: ["name", "location", "resource_group_name", "address_space", "guid"],
147
180
  },
148
- // ---------------------------------------------------------------- identity
149
181
  {
150
- resourceType: "azurerm_user_assigned_identity",
182
+ resourceType: "azurerm_log_analytics_workspace",
151
183
  entityTypeCode: "platform_component",
152
- entitySubtypeCode: "platform_control_plane",
153
- role: "identity",
154
- attributes: [...COMMON, "client_id", "principal_id", "tenant_id"],
155
- },
156
- {
157
- resourceType: "azurerm_role_assignment",
158
- entityTypeCode: "policy_control",
159
- role: "identity",
160
- attributes: ["name", "principal_id", "principal_type", "role_definition_name", "scope"],
184
+ entitySubtypeCode: "platform_service",
185
+ role: "observability",
186
+ attributes: ["name", "location", "resource_group_name", "sku", "retention_in_days", "workspace_id"],
161
187
  },
162
- // ---------------------------------------------------------------- registry
163
188
  {
164
189
  resourceType: "azurerm_container_registry",
165
190
  entityTypeCode: "platform_component",
166
191
  entitySubtypeCode: "platform_service",
167
192
  role: "registry",
168
- attributes: [...COMMON, "login_server", "sku", "admin_enabled", "public_network_access_enabled"],
193
+ attributes: ["name", "location", "resource_group_name", "login_server", "sku", "admin_enabled", "public_network_access_enabled"],
169
194
  },
170
- // ----------------------------------------------------------------- edge/AI
171
195
  {
172
- resourceType: "azurerm_cdn_frontdoor_profile",
173
- entityTypeCode: "platform_component",
174
- entitySubtypeCode: "platform_service",
175
- role: "edge",
176
- attributes: [...COMMON, "sku_name", "response_timeout_seconds"],
177
- },
178
- {
179
- resourceType: "azurerm_cdn_frontdoor_endpoint",
180
- entityTypeCode: "platform_component",
181
- entitySubtypeCode: "platform_service",
182
- role: "edge",
183
- attributes: ["name", "host_name", "enabled", "cdn_frontdoor_profile_id"],
184
- },
185
- {
186
- resourceType: "azurerm_cdn_frontdoor_firewall_policy",
187
- entityTypeCode: "policy_control",
188
- role: "edge",
189
- attributes: [...COMMON, "sku_name", "mode", "enabled"],
190
- },
191
- {
192
- resourceType: "azurerm_cognitive_account",
193
- entityTypeCode: "platform_component",
194
- entitySubtypeCode: "platform_service",
195
- role: "compute_host",
196
- attributes: [...COMMON, "kind", "sku_name", "endpoint", "custom_subdomain_name", "public_network_access_enabled", "local_auth_enabled"],
197
- },
198
- // ----------------------------------------------------------- observability
199
- {
200
- resourceType: "azurerm_log_analytics_workspace",
196
+ resourceType: "azurerm_key_vault",
201
197
  entityTypeCode: "platform_component",
202
198
  entitySubtypeCode: "platform_service",
203
- role: "observability",
204
- attributes: [...COMMON, "sku", "retention_in_days", "workspace_id"],
199
+ role: "secret_store",
200
+ attributes: ["name", "location", "resource_group_name", "vault_uri", "sku_name", "enable_rbac_authorization", "rbac_authorization_enabled", "public_network_access_enabled", "purge_protection_enabled", "soft_delete_retention_days"],
205
201
  },
206
202
  ];
@@ -0,0 +1,41 @@
1
+ import { callMcpTool } from "./mcp.js";
2
+ import { TERRAFORM_CATALOGUE_SEED } from "./terraform-catalogue-seed.js";
3
+ /**
4
+ * Rejects a payload that would widen what may be read.
5
+ *
6
+ * A served catalogue is a network response, and this one decides what leaves
7
+ * the machine. An entry with no attributes is meaningless — it would project a
8
+ * resource stripped of all evidence — and an entry missing its type mapping
9
+ * cannot be applied at all, so neither is accepted quietly. Terraform's own
10
+ * `sensitive_values` gate still runs behind whatever survives here.
11
+ */
12
+ function usableEntries(payload) {
13
+ return (payload.entries ?? []).filter((entry) => typeof entry?.resourceType === "string" &&
14
+ typeof entry?.entityTypeCode === "string" &&
15
+ Array.isArray(entry?.attributes) &&
16
+ entry.attributes.length > 0 &&
17
+ entry.attributes.every((attribute) => typeof attribute === "string"));
18
+ }
19
+ export async function loadTerraformCatalogue() {
20
+ try {
21
+ const raw = await callMcpTool("nexarch_get_terraform_catalogue", {});
22
+ const payload = JSON.parse(raw.content?.[0]?.text ?? "{}");
23
+ const entries = usableEntries(payload);
24
+ // An empty or unparseable response is not a reason to ingest nothing: the
25
+ // bundled copy is known-good and was generated from this same table.
26
+ if (entries.length === 0) {
27
+ return { entries: TERRAFORM_CATALOGUE_SEED, source: "bundled", reason: "the platform returned no usable entries" };
28
+ }
29
+ return { entries, source: "platform", reason: null };
30
+ }
31
+ catch (error) {
32
+ const message = error instanceof Error ? error.message : String(error);
33
+ return { entries: TERRAFORM_CATALOGUE_SEED, source: "bundled", reason: message };
34
+ }
35
+ }
36
+ /** One line describing which catalogue was used, so a stale ingest is never silent. */
37
+ export function describeCatalogue(result) {
38
+ if (result.source === "platform")
39
+ return `Catalogue: ${result.entries.length} resource types from the platform.`;
40
+ return `Catalogue: ${result.entries.length} resource types from the bundled copy — ${result.reason}.\n Types added to the platform since this CLI was published will not be recognised.`;
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexarch",
3
- "version": "0.12.12",
3
+ "version": "0.12.14",
4
4
  "description": "Your architecture workspace for AI delivery.",
5
5
  "keywords": [
6
6
  "nexarch",