fullstackgtm 0.47.0 → 0.49.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 (100) hide show
  1. package/CHANGELOG.md +76 -6
  2. package/CONTRIBUTING.md +23 -4
  3. package/DATA-FLOWS.md +7 -2
  4. package/INSTALL_FOR_AGENTS.md +15 -6
  5. package/README.md +28 -9
  6. package/SECURITY.md +27 -3
  7. package/dist/audit.js +7 -0
  8. package/dist/backfill.js +2 -16
  9. package/dist/cli/audit.js +10 -7
  10. package/dist/cli/auth.js +8 -4
  11. package/dist/cli/fix.d.ts +3 -0
  12. package/dist/cli/fix.js +90 -8
  13. package/dist/cli/help.d.ts +6 -0
  14. package/dist/cli/help.js +81 -3
  15. package/dist/cli/market.js +180 -2
  16. package/dist/cli/plans.js +56 -5
  17. package/dist/cli/suggest.d.ts +2 -1
  18. package/dist/cli/suggest.js +49 -3
  19. package/dist/cli/ui.js +2 -0
  20. package/dist/cli.js +41 -16
  21. package/dist/config.d.ts +13 -1
  22. package/dist/config.js +23 -2
  23. package/dist/connectors/hubspot.d.ts +2 -1
  24. package/dist/connectors/prospectSources.js +4 -11
  25. package/dist/connectors/salesforce.d.ts +2 -1
  26. package/dist/connectors/salesforce.js +12 -5
  27. package/dist/connectors/salesforceAuth.d.ts +9 -0
  28. package/dist/connectors/salesforceAuth.js +47 -5
  29. package/dist/connectors/signalSources.js +2 -11
  30. package/dist/connectors/theirstack.d.ts +0 -19
  31. package/dist/connectors/theirstack.js +3 -10
  32. package/dist/credentials.js +36 -26
  33. package/dist/format.js +31 -5
  34. package/dist/freeEmailDomains.d.ts +1 -0
  35. package/dist/freeEmailDomains.js +14 -0
  36. package/dist/hierarchy.d.ts +29 -0
  37. package/dist/hierarchy.js +164 -0
  38. package/dist/index.d.ts +9 -4
  39. package/dist/index.js +8 -3
  40. package/dist/market.d.ts +1 -1
  41. package/dist/market.js +7 -127
  42. package/dist/marketClassify.d.ts +10 -0
  43. package/dist/marketClassify.js +52 -1
  44. package/dist/marketSourcing.js +7 -45
  45. package/dist/mcp.js +86 -130
  46. package/dist/planStore.d.ts +28 -1
  47. package/dist/planStore.js +195 -49
  48. package/dist/providerError.d.ts +21 -0
  49. package/dist/providerError.js +30 -0
  50. package/dist/publicHttp.d.ts +28 -0
  51. package/dist/publicHttp.js +143 -0
  52. package/dist/relationships.d.ts +39 -0
  53. package/dist/relationships.js +101 -0
  54. package/dist/route.d.ts +46 -0
  55. package/dist/route.js +228 -0
  56. package/dist/rules.d.ts +1 -0
  57. package/dist/rules.js +172 -12
  58. package/dist/secureFile.d.ts +15 -0
  59. package/dist/secureFile.js +164 -0
  60. package/dist/types.d.ts +16 -1
  61. package/docs/api.md +49 -5
  62. package/docs/architecture.md +18 -4
  63. package/llms.txt +7 -0
  64. package/package.json +5 -3
  65. package/skills/fullstackgtm/SKILL.md +9 -3
  66. package/src/audit.ts +7 -0
  67. package/src/backfill.ts +2 -17
  68. package/src/cli/audit.ts +10 -7
  69. package/src/cli/auth.ts +8 -4
  70. package/src/cli/fix.ts +96 -8
  71. package/src/cli/help.ts +88 -3
  72. package/src/cli/market.ts +181 -2
  73. package/src/cli/plans.ts +66 -5
  74. package/src/cli/suggest.ts +58 -4
  75. package/src/cli/ui.ts +1 -0
  76. package/src/cli.ts +39 -14
  77. package/src/config.ts +39 -1
  78. package/src/connectors/hubspot.ts +3 -1
  79. package/src/connectors/prospectSources.ts +4 -11
  80. package/src/connectors/salesforce.ts +15 -6
  81. package/src/connectors/salesforceAuth.ts +46 -6
  82. package/src/connectors/signalSources.ts +2 -12
  83. package/src/connectors/theirstack.ts +3 -10
  84. package/src/credentials.ts +47 -28
  85. package/src/format.ts +33 -5
  86. package/src/freeEmailDomains.ts +14 -0
  87. package/src/hierarchy.ts +185 -0
  88. package/src/index.ts +29 -0
  89. package/src/market.ts +7 -111
  90. package/src/marketClassify.ts +61 -1
  91. package/src/marketSourcing.ts +7 -43
  92. package/src/mcp.ts +115 -152
  93. package/src/planStore.ts +235 -57
  94. package/src/providerError.ts +37 -0
  95. package/src/publicHttp.ts +147 -0
  96. package/src/relationships.ts +115 -0
  97. package/src/route.ts +278 -0
  98. package/src/rules.ts +192 -12
  99. package/src/secureFile.ts +169 -0
  100. package/src/types.ts +18 -0
@@ -3,17 +3,20 @@ import {
3
3
  existsSync,
4
4
  mkdirSync,
5
5
  readdirSync,
6
- readFileSync,
7
- statSync,
8
6
  unlinkSync,
9
- writeFileSync,
10
7
  } from "node:fs";
11
8
  import { createHash } from "node:crypto";
12
9
  import { homedir } from "node:os";
13
10
  import { join } from "node:path";
14
11
  import { refreshHubspotToken } from "./connectors/hubspotAuth.ts";
15
- import { refreshSalesforceToken } from "./connectors/salesforceAuth.ts";
12
+ import { refreshSalesforceToken, validateSalesforceOrigin } from "./connectors/salesforceAuth.ts";
16
13
  import { detectKeychainBackend } from "./keychain.ts";
14
+ import {
15
+ assertNoSymlinkComponents,
16
+ readSecureRegularFile,
17
+ UnsafeManagedPathError,
18
+ writeSecureFileAtomic,
19
+ } from "./secureFile.ts";
17
20
 
18
21
  /**
19
22
  * Local CLI credential store: ~/.fullstackgtm/credentials.json (0600), or
@@ -126,7 +129,9 @@ export function ensureSecureHomeDir(): string {
126
129
  const levels =
127
130
  dir === baseHomeDir() ? [dir] : [baseHomeDir(), join(baseHomeDir(), "profiles"), dir];
128
131
  for (const level of levels) {
132
+ assertNoSymlinkComponents(level);
129
133
  mkdirSync(level, { recursive: true, mode: 0o700 });
134
+ assertNoSymlinkComponents(level);
130
135
  try {
131
136
  chmodSync(level, 0o700);
132
137
  } catch {
@@ -138,12 +143,7 @@ export function ensureSecureHomeDir(): string {
138
143
 
139
144
  /** Write a 0600 file under the home, enforcing the mode even on rewrite. */
140
145
  export function writeSecureFile(path: string, contents: string) {
141
- writeFileSync(path, contents, { mode: 0o600 });
142
- try {
143
- chmodSync(path, 0o600);
144
- } catch {
145
- // Non-POSIX filesystems ignore chmod.
146
- }
146
+ writeSecureFileAtomic(path, contents);
147
147
  }
148
148
 
149
149
  /**
@@ -153,19 +153,14 @@ export function writeSecureFile(path: string, contents: string) {
153
153
  * mode on read too — re-tighten to 0600 and warn once — so a world-readable
154
154
  * credential store can't sit there silently leaking the token to other users.
155
155
  */
156
- function enforceCredentialFileMode(path: string): void {
157
- try {
158
- const mode = statSync(path).mode & 0o777;
159
- if ((mode & 0o077) !== 0) {
160
- chmodSync(path, 0o600);
161
- console.error(
162
- `fullstackgtm: tightened ${path} from ${mode.toString(8).padStart(3, "0")} to 600 ` +
163
- "(it was readable or writable by other users).",
164
- );
165
- }
166
- } catch {
167
- // Missing file or non-POSIX filesystem: nothing to enforce.
168
- }
156
+ function readCredentialFile(path: string): string {
157
+ return readSecureRegularFile(path, {
158
+ tightenMode: 0o600,
159
+ onModeTightened: (mode) => console.error(
160
+ `fullstackgtm: tightened ${path} from ${mode.toString(8).padStart(3, "0")} to 600 ` +
161
+ "(it was readable or writable by other users).",
162
+ ),
163
+ });
169
164
  }
170
165
 
171
166
  /**
@@ -190,7 +185,7 @@ function activeKeychain(): { account: string; backend: NonNullable<ReturnType<ty
190
185
  function migratePlaintextToKeychain(keychain: NonNullable<ReturnType<typeof activeKeychain>>): void {
191
186
  if (!existsSync(credentialsPath())) return;
192
187
  try {
193
- const fileParsed = JSON.parse(readFileSync(credentialsPath(), "utf8"));
188
+ const fileParsed = JSON.parse(readCredentialFile(credentialsPath()));
194
189
  if (fileParsed && fileParsed.version === 1 && fileParsed.providers) {
195
190
  const current = keychain.backend.get(keychain.account);
196
191
  const existing = current ? (JSON.parse(current).providers ?? {}) : {};
@@ -209,14 +204,15 @@ function readFile(): CredentialsFile {
209
204
  const keychain = activeKeychain();
210
205
  if (keychain) migratePlaintextToKeychain(keychain);
211
206
  try {
212
- const raw = keychain ? keychain.backend.get(keychain.account) : (enforceCredentialFileMode(credentialsPath()), readFileSync(credentialsPath(), "utf8"));
207
+ const raw = keychain ? keychain.backend.get(keychain.account) : readCredentialFile(credentialsPath());
213
208
  if (raw) {
214
209
  const parsed = JSON.parse(raw);
215
210
  if (parsed && typeof parsed === "object" && parsed.version === 1 && parsed.providers) {
216
211
  return parsed as CredentialsFile;
217
212
  }
218
213
  }
219
- } catch {
214
+ } catch (error) {
215
+ if (error instanceof UnsafeManagedPathError) throw error;
220
216
  // Missing or unreadable store falls through to an empty one.
221
217
  }
222
218
  return { version: 1, providers: {} };
@@ -238,6 +234,18 @@ export function getCredential(provider: string): StoredCredential | null {
238
234
  }
239
235
 
240
236
  export function storeCredential(provider: string, credential: StoredCredential) {
237
+ if (provider === "salesforce") {
238
+ if (!credential.instanceUrl) {
239
+ throw new Error("Salesforce credentials require an instance URL.");
240
+ }
241
+ credential = {
242
+ ...credential,
243
+ instanceUrl: validateSalesforceOrigin(credential.instanceUrl, "Salesforce credential instance URL"),
244
+ ...(credential.loginUrl
245
+ ? { loginUrl: validateSalesforceOrigin(credential.loginUrl, "Salesforce credential login URL") }
246
+ : {}),
247
+ };
248
+ }
241
249
  const file = readFile();
242
250
  file.providers[provider] = credential;
243
251
  persist(file);
@@ -315,12 +323,19 @@ export async function resolveSalesforceConnection(
315
323
  "Stored Salesforce credential has no instance URL. Run `fullstackgtm login salesforce` again.",
316
324
  );
317
325
  }
326
+ const instanceUrl = validateSalesforceOrigin(
327
+ credential.instanceUrl,
328
+ "Stored Salesforce credential instance URL",
329
+ );
330
+ if (credential.loginUrl) {
331
+ validateSalesforceOrigin(credential.loginUrl, "Stored Salesforce credential login URL");
332
+ }
318
333
  const needsRefresh =
319
334
  credential.kind === "oauth" &&
320
335
  credential.expiresAt !== undefined &&
321
336
  Date.now() > credential.expiresAt - REFRESH_SKEW_MS;
322
337
  if (!needsRefresh) {
323
- return { accessToken: credential.accessToken, instanceUrl: credential.instanceUrl };
338
+ return { accessToken: credential.accessToken, instanceUrl };
324
339
  }
325
340
  if (!credential.refreshToken || !credential.clientId) {
326
341
  throw new Error(
@@ -349,9 +364,13 @@ export async function resolveSalesforceConnection(
349
364
  if (!minted.instanceUrl) {
350
365
  throw new Error("The hosted deployment returned no Salesforce instance URL.");
351
366
  }
367
+ const instanceUrl = validateSalesforceOrigin(
368
+ minted.instanceUrl,
369
+ "Hosted Salesforce credential instance URL",
370
+ );
352
371
  return {
353
372
  accessToken: minted.accessToken,
354
- instanceUrl: minted.instanceUrl,
373
+ instanceUrl,
355
374
  fieldMappings: minted.fieldMappings,
356
375
  };
357
376
  }
package/src/format.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  import type { PatchPlan, PatchPlanRun } from "./types.ts";
2
2
 
3
- // `summary: true` renders only the header + the Findings-by-Rule table + an
4
- // operation count, for read verbs like `audit` where the full per-operation
5
- // dump (1,400+ lines on a real portal) buries the signal. Defaults to the full
6
- // view so write-preview callers (bulk-update, fix, dedupe, plans show, MCP) —
7
- // where you approve specific operations — keep every operation's detail.
3
+ // `summary: true` renders the compact audit view: header, findings summary,
4
+ // assumptions/open questions, and an operation count. Defaults to the full view
5
+ // so write-preview callers (bulk-update, fix, dedupe, plans show, MCP) where
6
+ // you approve specific operations keep every operation's detail.
8
7
  export function patchPlanToMarkdown(plan: PatchPlan, opts: { summary?: boolean } = {}) {
9
8
  const lines = [
10
9
  `# ${plan.title}`,
@@ -29,6 +28,8 @@ export function patchPlanToMarkdown(plan: PatchPlan, opts: { summary?: boolean }
29
28
  lines.push("");
30
29
  }
31
30
 
31
+ appendAssumptionsAndDecisionPoints(lines, plan);
32
+
32
33
  if (opts.summary) {
33
34
  const ops = plan.operations.length;
34
35
  lines.push(
@@ -155,6 +156,33 @@ export function formatPatchPlanRun(run: PatchPlanRun) {
155
156
  return `${lines.join("\n")}\n`;
156
157
  }
157
158
 
159
+
160
+ function appendAssumptionsAndDecisionPoints(lines: string[], plan: PatchPlan) {
161
+ const assumptions = [...(plan.assumptions ?? [])].sort((left, right) => {
162
+ if (left.confidence === "guess" && right.confidence !== "guess") return -1;
163
+ if (left.confidence !== "guess" && right.confidence === "guess") return 1;
164
+ return 0;
165
+ });
166
+ const openQuestions = plan.openQuestions ?? [];
167
+ if (assumptions.length === 0 && openQuestions.length === 0) return;
168
+
169
+ lines.push("## Assumptions & decision points", "");
170
+ for (const assumption of assumptions) {
171
+ const warning = assumption.confidence === "guess" ? "⚠️ " : "";
172
+ lines.push(`- ${warning}${assumption.text}${assumption.source ? ` _(source: ${assumption.source})_` : ""}`);
173
+ }
174
+
175
+ if (openQuestions.length > 0) {
176
+ if (assumptions.length > 0) lines.push("");
177
+ lines.push("### Open questions", "");
178
+ for (const question of openQuestions) {
179
+ lines.push(`- ${question}`);
180
+ }
181
+ }
182
+
183
+ lines.push("");
184
+ }
185
+
158
186
  function formatValue(value: unknown) {
159
187
  if (value === undefined || value === null || value === "") return "unset";
160
188
  if (typeof value === "string") return value;
@@ -0,0 +1,14 @@
1
+ // Shared free-mail domains that should never be treated as company domains.
2
+ export const FREE_EMAIL_DOMAINS = new Set([
3
+ "gmail.com",
4
+ "googlemail.com",
5
+ "yahoo.com",
6
+ "outlook.com",
7
+ "hotmail.com",
8
+ "live.com",
9
+ "icloud.com",
10
+ "me.com",
11
+ "aol.com",
12
+ "proton.me",
13
+ "protonmail.com",
14
+ ]);
@@ -0,0 +1,185 @@
1
+ import { normalizeDomain } from "./merge.ts";
2
+ import type { CanonicalAccount, CanonicalGtmSnapshot } from "./types.ts";
3
+
4
+ export type AccountHierarchyNode = {
5
+ accountId: string;
6
+ name: string;
7
+ domain?: string;
8
+ parentAccountId?: string;
9
+ parentReason?: string;
10
+ children: AccountHierarchyNode[];
11
+ };
12
+
13
+ export type AccountHierarchyConflict = {
14
+ type: "duplicate_domain" | "ambiguous_parent" | "cycle";
15
+ accountIds: string[];
16
+ key: string;
17
+ detail: string;
18
+ };
19
+
20
+ export type AccountHierarchyReport = {
21
+ generatedAt: string;
22
+ roots: AccountHierarchyNode[];
23
+ orphanAccounts: string[];
24
+ conflicts: AccountHierarchyConflict[];
25
+ counts: { accounts: number; roots: number; linkedChildren: number; conflicts: number };
26
+ };
27
+
28
+ function rawString(account: CanonicalAccount, keys: string[]): string | undefined {
29
+ if (!account.raw || typeof account.raw !== "object") return undefined;
30
+ const raw = account.raw as Record<string, unknown>;
31
+ for (const key of keys) {
32
+ const value = raw[key];
33
+ if (typeof value === "string" && value.trim()) return value.trim();
34
+ if (value && typeof value === "object") {
35
+ const nested = value as Record<string, unknown>;
36
+ for (const nestedKey of ["id", "value", "name"]) {
37
+ if (typeof nested[nestedKey] === "string" && String(nested[nestedKey]).trim()) return String(nested[nestedKey]).trim();
38
+ }
39
+ }
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ function parentHint(account: CanonicalAccount): string | undefined {
45
+ return rawString(account, ["parentAccountId", "parent_account_id", "ParentId", "parentCompanyId", "hs_parent_company_id"]);
46
+ }
47
+
48
+ function cloneNode(account: CanonicalAccount): AccountHierarchyNode {
49
+ return { accountId: String(account.id), name: account.name, ...(account.domain ? { domain: account.domain } : {}), children: [] };
50
+ }
51
+
52
+ function looksLikePublicSuffix(domain: string): boolean {
53
+ const parts = domain.split(".");
54
+ if (parts.length !== 2) return false;
55
+ const [label, tld] = parts;
56
+ return tld.length === 2 && ["ac", "co", "com", "edu", "gov", "net", "org"].includes(label);
57
+ }
58
+
59
+ function domainParent(childDomain: string, domains: Map<string, CanonicalAccount[]>): CanonicalAccount | "ambiguous" | null {
60
+ const parts = childDomain.split(".");
61
+ for (let i = 1; i < parts.length - 1; i++) {
62
+ const candidate = parts.slice(i).join(".");
63
+ if (candidate.split(".").length < 2 || looksLikePublicSuffix(candidate)) continue;
64
+ const unique = new Map((domains.get(candidate) ?? []).map((account) => [String(account.id), account]));
65
+ if (unique.size > 1) return "ambiguous";
66
+ if (unique.size === 1) return [...unique.values()][0];
67
+ }
68
+ return null;
69
+ }
70
+
71
+ function detectCycles(parentByChild: Map<string, { parentId: string; reason: string }>): { accountIds: Set<string>; cycles: string[][] } {
72
+ const cyclic = new Set<string>();
73
+ const cycles: string[][] = [];
74
+ const emitted = new Set<string>();
75
+
76
+ for (const start of parentByChild.keys()) {
77
+ const path: string[] = [];
78
+ const indexById = new Map<string, number>();
79
+ let cursor: string | undefined = start;
80
+ while (cursor && parentByChild.has(cursor)) {
81
+ const seenAt = indexById.get(cursor);
82
+ if (seenAt !== undefined) {
83
+ const cycle = path.slice(seenAt);
84
+ for (const id of cycle) cyclic.add(id);
85
+ const key = [...cycle].sort().join("|");
86
+ if (!emitted.has(key)) {
87
+ emitted.add(key);
88
+ cycles.push(cycle);
89
+ }
90
+ break;
91
+ }
92
+ indexById.set(cursor, path.length);
93
+ path.push(cursor);
94
+ cursor = parentByChild.get(cursor)?.parentId;
95
+ }
96
+ }
97
+
98
+ return { accountIds: cyclic, cycles };
99
+ }
100
+
101
+ export function buildAccountHierarchy(snapshot: CanonicalGtmSnapshot): AccountHierarchyReport {
102
+ const byId = new Map(snapshot.accounts.map((account) => [String(account.id), account]));
103
+ const accounts = [...byId.values()];
104
+ const domains = new Map<string, CanonicalAccount[]>();
105
+ for (const account of accounts) {
106
+ const domain = normalizeDomain(account.domain);
107
+ if (domain) domains.set(domain, [...(domains.get(domain) ?? []), account]);
108
+ }
109
+
110
+ const conflicts: AccountHierarchyConflict[] = [];
111
+ for (const [domain, accounts] of domains) {
112
+ const ids = [...new Set(accounts.map((account) => String(account.id)))];
113
+ if (ids.length > 1) conflicts.push({ type: "duplicate_domain", accountIds: ids.sort(), key: domain, detail: `${ids.length} accounts share domain ${domain}; hierarchy inference will not choose between them.` });
114
+ }
115
+
116
+ const nodes = new Map(accounts.map((account) => [String(account.id), cloneNode(account)]));
117
+ const parentByChild = new Map<string, { parentId: string; reason: string }>();
118
+ for (const account of accounts) {
119
+ const id = String(account.id);
120
+ const hint = parentHint(account);
121
+ if (hint && byId.has(hint) && hint !== id) {
122
+ parentByChild.set(id, { parentId: hint, reason: "provider-parent" });
123
+ continue;
124
+ }
125
+ const domain = normalizeDomain(account.domain);
126
+ if (!domain) continue;
127
+ const inferred = domainParent(domain, domains);
128
+ if (inferred === "ambiguous") conflicts.push({ type: "ambiguous_parent", accountIds: [id], key: domain, detail: `Multiple possible parent accounts found for ${domain}; leaving account as a root.` });
129
+ else if (inferred && String(inferred.id) !== id) parentByChild.set(id, { parentId: String(inferred.id), reason: "domain-subdomain" });
130
+ }
131
+
132
+ const cyclicAccounts = detectCycles(parentByChild);
133
+ for (const cycle of cyclicAccounts.cycles) {
134
+ const ordered = [...cycle].sort();
135
+ const key = ordered.join(" -> ");
136
+ conflicts.push({
137
+ type: "cycle",
138
+ accountIds: ordered,
139
+ key,
140
+ detail: `Cyclic parent hints detected among ${ordered.join(", ")}; leaving those accounts as roots instead of hiding them in a loop.`,
141
+ });
142
+ }
143
+ for (const accountId of cyclicAccounts.accountIds) parentByChild.delete(accountId);
144
+
145
+ const roots: AccountHierarchyNode[] = [];
146
+ const orphanAccounts: string[] = [];
147
+ const linkedChildren = new Set<string>();
148
+ for (const account of accounts) {
149
+ const id = String(account.id);
150
+ const node = nodes.get(id)!;
151
+ const parent = parentByChild.get(id);
152
+ if (parent && nodes.has(parent.parentId)) {
153
+ node.parentAccountId = parent.parentId;
154
+ node.parentReason = parent.reason;
155
+ nodes.get(parent.parentId)!.children.push(node);
156
+ linkedChildren.add(id);
157
+ } else {
158
+ roots.push(node);
159
+ if (!account.domain && !parentHint(account)) orphanAccounts.push(id);
160
+ }
161
+ }
162
+ const sortTree = (list: AccountHierarchyNode[]) => {
163
+ list.sort((a, b) => a.name.localeCompare(b.name) || a.accountId.localeCompare(b.accountId));
164
+ for (const node of list) sortTree(node.children);
165
+ };
166
+ sortTree(roots);
167
+ return { generatedAt: snapshot.generatedAt, roots, orphanAccounts: orphanAccounts.sort(), conflicts, counts: { accounts: accounts.length, roots: roots.length, linkedChildren: linkedChildren.size, conflicts: conflicts.length } };
168
+ }
169
+
170
+ export function accountHierarchyToMarkdown(report: AccountHierarchyReport): string {
171
+ const lines = ["# Account hierarchy", "", `${report.counts.accounts} account(s), ${report.counts.roots} root(s), ${report.counts.linkedChildren} inferred child link(s), ${report.counts.conflicts} conflict(s).`, ""];
172
+ const walk = (nodes: AccountHierarchyNode[], depth: number) => {
173
+ for (const node of nodes) {
174
+ const suffix = [node.domain, node.parentReason ? `via ${node.parentReason}` : undefined].filter(Boolean).join("; ");
175
+ lines.push(`${" ".repeat(depth)}- ${node.name} (${node.accountId}${suffix ? ` — ${suffix}` : ""})`);
176
+ walk(node.children, depth + 1);
177
+ }
178
+ };
179
+ walk(report.roots, 0);
180
+ if (report.conflicts.length) {
181
+ lines.push("", "## Conflicts");
182
+ for (const conflict of report.conflicts) lines.push(`- ${conflict.type} ${conflict.key}: ${conflict.detail}`);
183
+ }
184
+ return lines.join("\n");
185
+ }
package/src/index.ts CHANGED
@@ -12,13 +12,36 @@ export {
12
12
  export { buildBulkUpdatePlan, isFilterableField, parseWhere, type BulkUpdateOptions } from "./bulkUpdate.ts";
13
13
  export { buildDedupePlan, dedupeKey, type DedupeOptions } from "./dedupe.ts";
14
14
  export { buildReassignPlans, type ReassignObjectType, type ReassignOptions } from "./reassign.ts";
15
+ export {
16
+ buildLeadRoutePlan,
17
+ type LeadRouteCounts,
18
+ type LeadRouteOptions,
19
+ type LeadRouteResult,
20
+ } from "./route.ts";
21
+ export {
22
+ accountHierarchyToMarkdown,
23
+ buildAccountHierarchy,
24
+ type AccountHierarchyConflict,
25
+ type AccountHierarchyNode,
26
+ type AccountHierarchyReport,
27
+ } from "./hierarchy.ts";
28
+ export {
29
+ buildRelationshipMap,
30
+ relationshipMapToMarkdown,
31
+ type RelationshipMap,
32
+ type StakeholderNode,
33
+ type StakeholderRole,
34
+ type StakeholderSentiment,
35
+ } from "./relationships.ts";
15
36
  export {
16
37
  CONFIG_FILE_NAME,
17
38
  loadConfig,
18
39
  mergePolicy,
19
40
  resolveConfiguredRules,
41
+ rulePackageTrustFromCli,
20
42
  type FullstackgtmConfig,
21
43
  type LoadedConfig,
44
+ type RulePackageTrust,
22
45
  } from "./config.ts";
23
46
  export { applyPatchPlan, type ApplyPatchPlanOptions } from "./connector.ts";
24
47
  export {
@@ -55,6 +78,7 @@ export {
55
78
  pollSalesforceDeviceLogin,
56
79
  refreshSalesforceToken,
57
80
  startSalesforceDeviceLogin,
81
+ validateSalesforceOrigin,
58
82
  validateSalesforceToken,
59
83
  type SalesforceDeviceAuthorization,
60
84
  type SalesforceTokenSet,
@@ -209,6 +233,7 @@ export {
209
233
  type AuditLogExport,
210
234
  type AuditLogVerification,
211
235
  } from "./auditLog.ts";
236
+ export { FREE_EMAIL_DOMAINS } from "./freeEmailDomains.ts";
212
237
  export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
213
238
  export {
214
239
  computeHealth,
@@ -236,6 +261,7 @@ export {
236
261
  buildSnapshotIndex,
237
262
  builtinAuditRules,
238
263
  closingSoonInactiveRule,
264
+ isFindingsOnlyRule,
239
265
  duplicateAccountDomainRule,
240
266
  duplicateContactEmailRule,
241
267
  duplicateOpenDealRule,
@@ -540,6 +566,8 @@ export type {
540
566
  PatchOperationResult,
541
567
  PatchOperationType,
542
568
  PatchPlan,
569
+ PatchPlanAssumption,
570
+ PatchPlanAssumptionConfidence,
543
571
  PatchPlanRun,
544
572
  PatchPlanRunStatus,
545
573
  PatchVerification,
@@ -550,3 +578,4 @@ export type {
550
578
  RiskLevel,
551
579
  SourceFreshness,
552
580
  } from "./types.ts";
581
+ export { ProviderHttpError } from "./providerError.ts";
package/src/market.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
- import { lookup } from "node:dns/promises";
3
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
4
- import { isIP } from "node:net";
5
3
  import { join } from "node:path";
6
4
  import { credentialsDir } from "./credentials.ts";
7
5
  import { MARKET_CAPTURE_STAGES, nullProgressEmitter, type ProgressEmitter } from "./progress.ts";
8
6
  import type { GtmEvidence } from "./types.ts";
7
+ import { assertPublicUrl, publicHttpGet } from "./publicHttp.ts";
8
+ export { assertPublicUrl } from "./publicHttp.ts";
9
9
 
10
10
  /**
11
11
  * The Market Map: a live model of the competitive category a company sells
@@ -328,121 +328,17 @@ export type FetchPage = (url: string) => Promise<{ status: number; body: string
328
328
  * any host that is or resolves to a private/loopback/link-local/metadata
329
329
  * address, and (3) follow redirects manually, re-validating each hop.
330
330
  *
331
- * Residual gap (documented, not defended here): TOCTOU DNS rebinding between
332
- * our lookup and fetch's own resolution. Out of scope for fetching public
333
- * competitor pages; a hardened deployment should fetch through an egress proxy.
331
+ * The default transport resolves once, rejects mixed public/private answers,
332
+ * and pins a validated address into socket creation to prevent DNS rebinding.
334
333
  */
335
334
  const MAX_REDIRECTS = 5;
336
335
  const FETCH_TIMEOUT_MS = 15_000;
337
336
  const MAX_BODY_BYTES = 5_000_000;
338
337
 
339
- function ipv4IsPrivate(ip: string): boolean {
340
- const parts = ip.split(".").map((n) => Number(n));
341
- if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
342
- const [a, b] = parts;
343
- if (a === 0 || a === 127) return true; // this-host, loopback
344
- if (a === 10) return true; // private
345
- if (a === 172 && b >= 16 && b <= 31) return true; // private
346
- if (a === 192 && b === 168) return true; // private
347
- if (a === 169 && b === 254) return true; // link-local incl. 169.254.169.254 metadata
348
- if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
349
- if (a >= 224) return true; // multicast / reserved
350
- return false;
351
- }
352
-
353
- function ipIsPrivate(ip: string): boolean {
354
- const family = isIP(ip);
355
- if (family === 4) return ipv4IsPrivate(ip);
356
- if (family === 6) {
357
- const lower = ip.toLowerCase();
358
- if (lower === "::1" || lower === "::") return true; // loopback / unspecified
359
- // IPv4-mapped (::ffff:…) — Node normalizes ::ffff:127.0.0.1 to ::ffff:7f00:1,
360
- // so accept both the dotted and the hex-pair forms, unwrap, check the v4.
361
- const mapped = lower.match(/^::ffff:(.+)$/);
362
- if (mapped) {
363
- const rest = mapped[1];
364
- if (rest.includes(".")) return ipv4IsPrivate(rest);
365
- const groups = rest.split(":");
366
- if (groups.length === 2) {
367
- const hi = parseInt(groups[0], 16);
368
- const lo = parseInt(groups[1], 16);
369
- if (Number.isNaN(hi) || Number.isNaN(lo)) return true;
370
- return ipv4IsPrivate(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
371
- }
372
- return true; // unrecognized mapped form → refuse
373
- }
374
- if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true; // link-local fe80::/10
375
- if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // unique-local fc00::/7
376
- return false;
377
- }
378
- return true; // not a recognizable IP literal → refuse
379
- }
380
-
381
- export async function assertPublicUrl(rawUrl: string): Promise<URL> {
382
- let url: URL;
383
- try {
384
- url = new URL(rawUrl);
385
- } catch {
386
- throw new Error(`market capture: "${rawUrl}" is not a valid URL.`);
387
- }
388
- if (url.protocol !== "http:" && url.protocol !== "https:") {
389
- throw new Error(`market capture refuses ${url.protocol} URLs (only http/https): ${rawUrl}`);
390
- }
391
- const host = url.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
392
- if (isIP(host)) {
393
- if (ipIsPrivate(host)) throw new Error(`market capture refuses private/loopback address ${host} (SSRF guard).`);
394
- return url;
395
- }
396
- // Hostname: resolve and refuse if ANY address is private.
397
- const addrs = await lookup(host, { all: true });
398
- for (const { address } of addrs) {
399
- if (ipIsPrivate(address)) {
400
- throw new Error(`market capture refuses ${host} — it resolves to private/internal address ${address} (SSRF guard).`);
401
- }
402
- }
403
- return url;
404
- }
405
-
406
338
  const defaultFetchPage: FetchPage = async (url) => {
407
- let current = url;
408
- for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
409
- await assertPublicUrl(current);
410
- const controller = new AbortController();
411
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
412
- let response: Response;
413
- try {
414
- response = await fetch(current, {
415
- headers: {
416
- "User-Agent": "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)",
417
- "Accept-Language": "en-US",
418
- },
419
- redirect: "manual",
420
- signal: controller.signal,
421
- });
422
- } finally {
423
- clearTimeout(timer);
424
- }
425
- if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
426
- current = new URL(response.headers.get("location") as string, current).toString();
427
- continue; // re-validate the redirect target on the next iteration
428
- }
429
- const reader = response.body?.getReader();
430
- if (!reader) return { status: response.status, body: await response.text() };
431
- const chunks: Uint8Array[] = [];
432
- let total = 0;
433
- for (;;) {
434
- const { done, value } = await reader.read();
435
- if (done) break;
436
- total += value.length;
437
- if (total > MAX_BODY_BYTES) {
438
- await reader.cancel();
439
- break;
440
- }
441
- chunks.push(value);
442
- }
443
- return { status: response.status, body: Buffer.concat(chunks).toString("utf8") };
444
- }
445
- throw new Error(`market capture: too many redirects (>${MAX_REDIRECTS}) for ${url}`);
339
+ const response = await publicHttpGet(url, { maxBytes: MAX_BODY_BYTES, maxRedirects: MAX_REDIRECTS,
340
+ timeoutMs: FETCH_TIMEOUT_MS, headers: { "User-Agent": "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)", "Accept-Language": "en-US" } });
341
+ return { status: response.status, body: Buffer.from(response.body).toString("utf8") };
446
342
  };
447
343
 
448
344
  export type CaptureOptions = {