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
@@ -12,7 +12,14 @@
12
12
  // itself; this holds uniformly for read and write verbs, so a typo can never
13
13
  // silently change what a write-shaped invocation stages.
14
14
 
15
- import { HELP, usage } from "./help.ts";
15
+ import {
16
+ COMMAND_FLAGS,
17
+ FLAGS_WITH_VALUES,
18
+ GLOBAL_FLAGS,
19
+ GLOBAL_SHORT_FLAGS,
20
+ HELP,
21
+ usage,
22
+ } from "./help.ts";
16
23
 
17
24
  export function levenshtein(a: string, b: string): number {
18
25
  const dp = Array.from({ length: a.length + 1 }, () => Array<number>(b.length + 1).fill(0));
@@ -65,7 +72,7 @@ export function documentedFlags(): Set<string> {
65
72
  export type FlagTypo = {
66
73
  given: string;
67
74
  /** Human-readable correction, e.g. `--json` or `--rules missing-deal-owner`. */
68
- suggestion: string;
75
+ suggestion: string | null;
69
76
  /** The argv tokens that replace `given` in the corrected command. */
70
77
  replacement: string[];
71
78
  };
@@ -108,6 +115,53 @@ export function detectFlagTypo(args: string[]): FlagTypo | null {
108
115
  return null;
109
116
  }
110
117
 
118
+ function isFlagShaped(token: string): boolean {
119
+ return token !== "-" && token.startsWith("-");
120
+ }
121
+
122
+ function suggestCommandFlag(unknown: string, allowed: Set<string>): string | null {
123
+ const candidates = [...allowed].filter((flag) => flag.startsWith("--"));
124
+ const prefix = candidates
125
+ .filter((flag) => unknown.startsWith(`${flag}-`) || flag.startsWith(unknown))
126
+ .sort((a, b) => b.length - a.length)[0];
127
+ if (prefix) return prefix;
128
+ return nearest(unknown.toLowerCase().replace(/_/g, "-"), candidates, 3);
129
+ }
130
+
131
+ export function detectUnknownFlag(command: string, args: string[]): FlagTypo | null {
132
+ const commandFlags = COMMAND_FLAGS[command];
133
+ if (!commandFlags) return null;
134
+ const allowed = new Set([...GLOBAL_FLAGS, ...commandFlags]);
135
+ for (let index = 0; index < args.length; index += 1) {
136
+ const token = args[index];
137
+ if (token === "--") break;
138
+ if (GLOBAL_SHORT_FLAGS.includes(token)) continue;
139
+ if (!token.startsWith("-")) continue;
140
+
141
+ if (!token.startsWith("--")) {
142
+ return { given: token, suggestion: null, replacement: [] };
143
+ }
144
+
145
+ const equalsIndex = token.indexOf("=");
146
+ const flag = equalsIndex === -1 ? token : token.slice(0, equalsIndex);
147
+ if (allowed.has(flag)) {
148
+ if (equalsIndex !== -1) {
149
+ const value = token.slice(equalsIndex + 1);
150
+ const replacement = value === "" ? [flag] : [flag, value];
151
+ return { given: token, suggestion: replacement.join(" "), replacement };
152
+ }
153
+ if (FLAGS_WITH_VALUES.has(flag) && args[index + 1] !== undefined && !isFlagShaped(args[index + 1])) {
154
+ index += 1;
155
+ }
156
+ continue;
157
+ }
158
+
159
+ const suggestion = suggestCommandFlag(flag, allowed);
160
+ return { given: flag, suggestion, replacement: suggestion ? [suggestion] : [] };
161
+ }
162
+ return null;
163
+ }
164
+
111
165
  /** Nearest known command, derived from the same HELP table. */
112
166
  export function suggestCommand(command: string): string | null {
113
167
  if (!command) return null;
@@ -143,8 +197,8 @@ export function unknownFlagEnvelope(command: string, args: string[], typo: FlagT
143
197
  code: "UNKNOWN_FLAG" as const,
144
198
  message: `Unknown flag: ${typo.given}`,
145
199
  hints: [
146
- `Did you mean: ${typo.suggestion}`,
147
- `Try: ${correctedCommand(command, args, typo)}`,
200
+ ...(typo.suggestion ? [`Did you mean: ${typo.suggestion}`] : []),
201
+ ...(typo.replacement.length > 0 ? [`Try: ${correctedCommand(command, args, typo)}`] : []),
148
202
  ],
149
203
  },
150
204
  };
package/src/cli/ui.ts CHANGED
@@ -449,6 +449,7 @@ export function severityWord(word: string, p: Paint): string {
449
449
  export function planStatusWord(status: string, p: Paint): string {
450
450
  if (status === "needs_approval") return p.yellow(status);
451
451
  if (status === "approved" || status === "applied") return p.green(status);
452
+ if (status === "applying") return p.yellow(status);
452
453
  if (status === "rejected") return p.red(status);
453
454
  return status;
454
455
  }
package/src/cli.ts CHANGED
@@ -2,7 +2,7 @@ import { activeProfile, listProfiles, setActiveProfile } from "./credentials.ts"
2
2
  import { capabilitiesCommand, printCommandHelpJson, robotDocsCommand, unknownCommandEnvelope } from "./cli/capabilities.ts";
3
3
  import { BESPOKE_HELP, commandHelp, HELP, shortUsage, stylizeShortUsage, usage } from "./cli/help.ts";
4
4
  import { readPackageInfo } from "./cli/shared.ts";
5
- import { correctedCommand, detectFlagTypo, suggestCommand, unknownFlagEnvelope } from "./cli/suggest.ts";
5
+ import { correctedCommand, detectFlagTypo, detectUnknownFlag, suggestCommand, unknownFlagEnvelope } from "./cli/suggest.ts";
6
6
  import { colorEnabled, paint } from "./cli/ui.ts";
7
7
 
8
8
  // Verb modules load lazily inside their dispatch branch below. The dispatcher
@@ -62,13 +62,19 @@ export async function runCli(argv: string[]) {
62
62
  }
63
63
  if (command === "help") {
64
64
  const [topic] = args;
65
- if (topic && topic !== "--full" && !topic.startsWith("-")) {
65
+ if (topic && !topic.startsWith("-") && args.includes("--json")) {
66
66
  // `help <cmd> --json` → machine-readable help derived from the same
67
- // HELP entry the plain text renders from. Plain output is unchanged.
68
- if (args.includes("--json")) printCommandHelpJson(topic);
69
- else console.log(commandHelp(topic));
67
+ // HELP entry the plain text renders from. JSON takes precedence over
68
+ // --full so automated callers get a stable shape.
69
+ printCommandHelpJson(topic);
70
+ } else if (args.includes("--full")) {
71
+ // --full is the global escape hatch to the complete reference, even
72
+ // when a topic is given (help <cmd> --full), unless --json is requested.
73
+ console.log(usage());
74
+ } else if (topic && !topic.startsWith("-")) {
75
+ console.log(commandHelp(topic));
70
76
  } else {
71
- console.log(args.includes("--full") || topic === "--full" ? usage() : styledShortUsage());
77
+ console.log(styledShortUsage());
72
78
  }
73
79
  return;
74
80
  }
@@ -85,7 +91,7 @@ export async function runCli(argv: string[]) {
85
91
  }
86
92
  // Commands without bespoke help get focused per-command help on --help
87
93
  // instead of executing (audit used to silently run the sample audit) or
88
- // dumping the whole surface. call/market/enrich/bulk-update/schedule print
94
+ // dumping the whole surface. call/market/enrich/schedule print
89
95
  // their own richer help. `--full` always escapes to the complete reference.
90
96
  if (!BESPOKE_HELP.includes(command) && (args.includes("--help") || args.includes("-h"))) {
91
97
  console.log(args.includes("--full") ? usage() : commandHelp(command));
@@ -94,19 +100,26 @@ export async function runCli(argv: string[]) {
94
100
 
95
101
  // Flag typos used to be silently dropped by the per-verb parsers —
96
102
  // `audit --demo --jsn` exited 0 and printed markdown where the agent asked
97
- // for JSON. Near-miss unknown flags (≤ 1 edit from a flag documented in
98
- // help.ts) now stop with the exact corrected command. Suggest-only: never
99
- // auto-execute the correction, so a typo can never change what a
100
- // write-shaped invocation stages.
103
+ // for JSON. Every flag-shaped token is now checked against the per-command
104
+ // registry in help.ts, so unknown flags fail closed instead of being ignored.
105
+ // Suggest-only: never auto-execute the correction, so a typo can never
106
+ // silently change what a write-shaped invocation stages.
101
107
  if (command in HELP) {
102
- const typo = detectFlagTypo(args);
108
+ const typo = detectUnknownFlag(command, args);
103
109
  if (typo) {
104
110
  if (args.includes("--json")) {
105
111
  console.log(JSON.stringify(unknownFlagEnvelope(command, args, typo), null, 2));
106
112
  } else {
113
+ console.error(`Unknown flag for ${command}: ${typo.given}`);
114
+ // Keep the old near-miss shape too; existing agents key off it.
107
115
  console.error(`Unknown flag: ${typo.given}`);
108
- console.error(`Did you mean: ${typo.suggestion}`);
109
- console.error(`Try: ${correctedCommand(command, args, typo)}`);
116
+ if (typo.suggestion) {
117
+ console.error(`Did you mean ${typo.suggestion}?`);
118
+ console.error(`Did you mean: ${typo.suggestion}`);
119
+ if (typo.replacement.length > 0) {
120
+ console.error(`Try: ${correctedCommand(command, args, typo)}`);
121
+ }
122
+ }
110
123
  }
111
124
  process.exitCode = 1;
112
125
  return;
@@ -165,6 +178,18 @@ export async function runCli(argv: string[]) {
165
178
  await (await import("./cli/fix.ts")).resolveCommand(args);
166
179
  return;
167
180
  }
181
+ if (command === "route") {
182
+ await (await import("./cli/fix.ts")).routeCommand(args);
183
+ return;
184
+ }
185
+ if (command === "hierarchy") {
186
+ await (await import("./cli/fix.ts")).hierarchyCommand(args);
187
+ return;
188
+ }
189
+ if (command === "relationships") {
190
+ await (await import("./cli/fix.ts")).relationshipsCommand(args);
191
+ return;
192
+ }
168
193
  if (command === "bulk-update") {
169
194
  await (await import("./cli/fix.ts")).bulkUpdateCommand(args);
170
195
  return;
package/src/config.ts CHANGED
@@ -35,6 +35,35 @@ export type LoadedConfig = {
35
35
  path: string;
36
36
  };
37
37
 
38
+ export type RulePackageTrust = {
39
+ /** Execute rule-package modules. Only set after an explicit trust decision. */
40
+ allowRulePackages?: boolean;
41
+ /** Deliberately ignore rule packages while still applying declarative config. */
42
+ disableRulePackages?: boolean;
43
+ };
44
+
45
+ /**
46
+ * Translate the CLI's explicit trust flags into the library trust contract.
47
+ * Plugin execution requires both an explicit config path and --allow-plugins;
48
+ * discovering a config in the current directory is never a trust decision.
49
+ */
50
+ export function rulePackageTrustFromCli(
51
+ args: string[],
52
+ explicitConfigPath?: string,
53
+ ): RulePackageTrust {
54
+ const allow = args.includes("--allow-plugins");
55
+ const disable = args.includes("--no-plugins");
56
+ if (allow && disable) {
57
+ throw new Error("--allow-plugins and --no-plugins cannot be used together.");
58
+ }
59
+ if (allow && !explicitConfigPath) {
60
+ throw new Error(
61
+ "--allow-plugins requires --config <path>. Plugin code is never trusted from an implicitly discovered config.",
62
+ );
63
+ }
64
+ return { allowRulePackages: allow, disableRulePackages: disable };
65
+ }
66
+
38
67
  export function loadConfig(
39
68
  explicitPath?: string,
40
69
  cwd = process.cwd(),
@@ -73,10 +102,19 @@ export function mergePolicy(base: GtmPolicy, config?: FullstackgtmConfig): GtmPo
73
102
  export async function resolveConfiguredRules(
74
103
  loaded?: LoadedConfig | null,
75
104
  baseRules: GtmAuditRule[] = builtinAuditRules,
105
+ trust: RulePackageTrust = {},
76
106
  ): Promise<GtmAuditRule[]> {
77
107
  let rules = [...baseRules];
78
108
 
79
- for (const specifier of loaded?.config.rulePackages ?? []) {
109
+ const packages = loaded?.config.rulePackages ?? [];
110
+ if (packages.length && !trust.allowRulePackages && !trust.disableRulePackages) {
111
+ throw new Error(
112
+ `Config ${loaded!.path} declares executable rulePackages. Refusing to load plugin code without explicit trust. ` +
113
+ "For the CLI, pass --config <path> --allow-plugins after reviewing the modules, or pass --no-plugins to use only declarative policy and built-in rules.",
114
+ );
115
+ }
116
+
117
+ for (const specifier of trust.disableRulePackages ? [] : packages) {
80
118
  const resolvedSpecifier =
81
119
  specifier.startsWith(".") || isAbsolute(specifier)
82
120
  ? pathToFileURL(resolve(dirname(loaded!.path), specifier)).href
@@ -24,6 +24,8 @@ import { SNAPSHOT_PULL_STAGES, type ProgressEmitter } from "../progress.ts";
24
24
 
25
25
  const DEFAULT_API_BASE_URL = "https://api.hubapi.com";
26
26
 
27
+ export type HubspotWritableConnector = GtmConnector & Required<Pick<GtmConnector, "applyOperation" | "readField">>;
28
+
27
29
  export type HubspotConnectorOptions = {
28
30
  /** Returns a HubSpot access token (private app token or OAuth access token). */
29
31
  getAccessToken: () => string | Promise<string>;
@@ -71,7 +73,7 @@ const PULL_STAGE_BY_TYPE: Record<SnapshotProgress["objectType"], (typeof SNAPSHO
71
73
  * amountless deals — so audit rules can surface the gaps instead of hiding
72
74
  * them.
73
75
  */
74
- export function createHubspotConnector(options: HubspotConnectorOptions): GtmConnector {
76
+ export function createHubspotConnector(options: HubspotConnectorOptions): HubspotWritableConnector {
75
77
  const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
76
78
  const fetchImpl = options.fetchImpl ?? fetch;
77
79
  const mappings = options.fieldMappings;
@@ -14,6 +14,7 @@
14
14
  * env/credential store, never argv.
15
15
  */
16
16
  import type { CanonicalGtmSnapshot } from "../types.ts";
17
+ import { ProviderHttpError } from "../providerError.ts";
17
18
 
18
19
  export type Prospect = {
19
20
  firstName?: string;
@@ -79,7 +80,7 @@ export async function fetchExploriumProspects(opts: {
79
80
  body: JSON.stringify({ mode: "full", size, page_size: size, page: 1, filters: opts.filters }),
80
81
  });
81
82
  if (!response.ok) {
82
- throw new Error(`Explorium /v1/prospects failed: HTTP ${response.status} ${await safeText(response)}`);
83
+ throw new ProviderHttpError("Explorium", "prospect search", response.status);
83
84
  }
84
85
  const data = (await response.json()) as { data?: ExploriumRow[] };
85
86
  return (data.data ?? []).map((row) => ({
@@ -132,7 +133,7 @@ export async function fetchPipe0CrustdataProspects(opts: {
132
133
  }),
133
134
  });
134
135
  if (!response.ok) {
135
- throw new Error(`pipe0 /v1/searches/run/sync failed: HTTP ${response.status} ${await safeText(response)}`);
136
+ throw new ProviderHttpError("pipe0", "prospect search", response.status);
136
137
  }
137
138
  const body = (await response.json()) as {
138
139
  results?: Array<Record<string, { value?: unknown }>>;
@@ -214,7 +215,7 @@ export async function probeExploriumBusinessCount(opts: {
214
215
  body: JSON.stringify({ mode: "full", page_size: 1, page: 1, filters: opts.filters }),
215
216
  });
216
217
  if (!response.ok) {
217
- throw new Error(`Explorium /v1/businesses count failed: HTTP ${response.status} ${await safeText(response)}`);
218
+ throw new ProviderHttpError("Explorium", "business count", response.status);
218
219
  }
219
220
  const body = (await response.json()) as { total_results?: unknown };
220
221
  const total = body.total_results;
@@ -427,14 +428,6 @@ function fieldValue(field: Pipe0Field | undefined): string | undefined {
427
428
  return typeof v === "string" && v.trim() ? v : undefined;
428
429
  }
429
430
 
430
- async function safeText(response: Response): Promise<string> {
431
- try {
432
- return (await response.text()).slice(0, 300);
433
- } catch {
434
- return "";
435
- }
436
- }
437
-
438
431
  // ---------------------------------------------------------------------------
439
432
  // Pre-email dedup: identity keys shared between prospects and CRM contacts, so
440
433
  // we can drop already-in-CRM / already-seen people BEFORE paying for emails.
@@ -19,6 +19,7 @@ import type {
19
19
  PatchOperationResult,
20
20
  SnapshotProgress,
21
21
  } from "../types.ts";
22
+ import { validateSalesforceOrigin } from "./salesforceAuth.ts";
22
23
  import { SNAPSHOT_PULL_STAGES, type ProgressEmitter } from "../progress.ts";
23
24
 
24
25
  const DEFAULT_API_VERSION = "v59.0";
@@ -29,6 +30,8 @@ export type SalesforceConnection = {
29
30
  instanceUrl: string;
30
31
  };
31
32
 
33
+ export type SalesforceWritableConnector = GtmConnector & Required<Pick<GtmConnector, "applyOperation" | "readField">>;
34
+
32
35
  export type SalesforceConnectorOptions = {
33
36
  /** Returns an access token plus the instance URL it belongs to. */
34
37
  getConnection: () => SalesforceConnection | Promise<SalesforceConnection>;
@@ -86,7 +89,7 @@ const PULL_STAGE_BY_TYPE: Record<SnapshotProgress["objectType"], (typeof SNAPSHO
86
89
  */
87
90
  export function createSalesforceConnector(
88
91
  options: SalesforceConnectorOptions,
89
- ): GtmConnector {
92
+ ): SalesforceWritableConnector {
90
93
  const apiVersion = options.apiVersion ?? DEFAULT_API_VERSION;
91
94
  const fetchImpl = options.fetchImpl ?? fetch;
92
95
  const mappings = options.fieldMappings;
@@ -150,13 +153,17 @@ export function createSalesforceConnector(
150
153
 
151
154
  async function request(path: string, init: RequestInit = {}): Promise<any> {
152
155
  const connection = await options.getConnection();
153
- const url = path.startsWith("http")
154
- ? path
155
- : `${connection.instanceUrl.replace(/\/$/, "")}${path}`;
156
+ const instanceUrl = validateSalesforceOrigin(connection.instanceUrl, "Salesforce connection instance URL");
157
+ const resolved = new URL(path, `${instanceUrl}/`);
158
+ if (resolved.origin !== instanceUrl) {
159
+ throw new Error("Salesforce response attempted to send credentials to a different origin.");
160
+ }
161
+ const url = resolved.href;
156
162
  let response: Response;
157
163
  try {
158
164
  response = await fetchImpl(url, {
159
165
  ...init,
166
+ redirect: "manual",
160
167
  headers: {
161
168
  Authorization: `Bearer ${connection.accessToken}`,
162
169
  "Content-Type": "application/json",
@@ -166,7 +173,7 @@ export function createSalesforceConnector(
166
173
  } catch (error) {
167
174
  const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
168
175
  throw new Error(
169
- `Cannot reach Salesforce at ${connection.instanceUrl}${cause}. Check SALESFORCE_INSTANCE_URL (your My Domain URL, e.g. https://yourco.my.salesforce.com) and network access.`,
176
+ `Cannot reach Salesforce at ${instanceUrl}${cause}. Check SALESFORCE_INSTANCE_URL (your My Domain URL, e.g. https://yourco.my.salesforce.com) and network access.`,
170
177
  );
171
178
  }
172
179
  if (!response.ok) {
@@ -642,8 +649,9 @@ export function createSalesforceConnector(
642
649
  loserIds: string[],
643
650
  ): Promise<{ ok: boolean; detail?: string }> {
644
651
  const connection = await options.getConnection();
652
+ const instanceUrl = validateSalesforceOrigin(connection.instanceUrl, "Salesforce connection instance URL");
645
653
  const version = apiVersion.replace(/^v/, ""); // SOAP path uses "59.0", not "v59.0"
646
- const url = `${connection.instanceUrl.replace(/\/$/, "")}/services/Soap/u/${version}`;
654
+ const url = `${instanceUrl}/services/Soap/u/${version}`;
647
655
  const toMerge = loserIds.map((id) => `<urn:recordToMergeIds>${escapeXml(id)}</urn:recordToMergeIds>`).join("");
648
656
  const envelope =
649
657
  `<?xml version="1.0" encoding="UTF-8"?>` +
@@ -657,6 +665,7 @@ export function createSalesforceConnector(
657
665
  try {
658
666
  response = await fetchImpl(url, {
659
667
  method: "POST",
668
+ redirect: "manual",
660
669
  headers: { "Content-Type": "text/xml; charset=UTF-8", SOAPAction: '""' },
661
670
  body: envelope,
662
671
  });
@@ -9,6 +9,41 @@
9
9
 
10
10
  const DEFAULT_LOGIN_URL = "https://login.salesforce.com";
11
11
 
12
+ /**
13
+ * Normalize and validate a Salesforce credential-bearing origin.
14
+ *
15
+ * Salesforce API hosts are either the standard login/sandbox hosts or a
16
+ * Salesforce-owned instance/My Domain below salesforce.com. Deliberately
17
+ * return an origin (not the input URL) so callers cannot accidentally retain
18
+ * paths, queries, credentials, or fragments from configuration.
19
+ */
20
+ export function validateSalesforceOrigin(value: string, label = "Salesforce URL"): string {
21
+ let url: URL;
22
+ try {
23
+ url = new URL(value);
24
+ } catch {
25
+ throw new Error(`${label} must be a valid HTTPS Salesforce URL.`);
26
+ }
27
+ if (url.protocol !== "https:") throw new Error(`${label} must use HTTPS.`);
28
+ if (url.username || url.password) throw new Error(`${label} must not contain user information.`);
29
+ if (url.hash) throw new Error(`${label} must not contain a fragment.`);
30
+ if (url.search) throw new Error(`${label} must not contain a query string.`);
31
+ if (url.pathname !== "/" && url.pathname !== "") throw new Error(`${label} must be an origin without a path.`);
32
+ if (url.port && url.port !== "443") throw new Error(`${label} must use the standard HTTPS port.`);
33
+
34
+ const hostname = url.hostname.toLowerCase();
35
+ const trusted =
36
+ hostname === "login.salesforce.com" ||
37
+ hostname === "test.salesforce.com" ||
38
+ (hostname.endsWith(".salesforce.com") && hostname.length > ".salesforce.com".length);
39
+ if (!trusted) {
40
+ throw new Error(
41
+ `${label} must use login.salesforce.com, test.salesforce.com, or a Salesforce instance/My Domain host.`,
42
+ );
43
+ }
44
+ return url.origin;
45
+ }
46
+
12
47
  export type SalesforceTokenSet = {
13
48
  accessToken: string;
14
49
  refreshToken?: string;
@@ -27,7 +62,7 @@ export type SalesforceDeviceAuthorization = {
27
62
  const SESSION_TTL_MS = 2 * 60 * 60 * 1000;
28
63
 
29
64
  function tokenUrl(loginUrl: string) {
30
- return `${loginUrl.replace(/\/$/, "")}/services/oauth2/token`;
65
+ return `${validateSalesforceOrigin(loginUrl, "Salesforce login URL")}/services/oauth2/token`;
31
66
  }
32
67
 
33
68
  /**
@@ -55,6 +90,7 @@ export async function startSalesforceDeviceLogin(options: {
55
90
  const fetchImpl = options.fetchImpl ?? fetch;
56
91
  const response = await fetchImpl(tokenUrl(options.loginUrl ?? DEFAULT_LOGIN_URL), {
57
92
  method: "POST",
93
+ redirect: "manual",
58
94
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
59
95
  body: new URLSearchParams({
60
96
  response_type: "device_code",
@@ -91,6 +127,7 @@ export async function pollSalesforceDeviceLogin(options: {
91
127
  await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
92
128
  const response = await fetchImpl(url, {
93
129
  method: "POST",
130
+ redirect: "manual",
94
131
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
95
132
  body: new URLSearchParams({
96
133
  grant_type: "device",
@@ -100,10 +137,11 @@ export async function pollSalesforceDeviceLogin(options: {
100
137
  });
101
138
  const data = await response.json().catch(() => ({}));
102
139
  if (response.ok && data.access_token && data.instance_url) {
140
+ const instanceUrl = validateSalesforceOrigin(String(data.instance_url), "Salesforce token instance_url");
103
141
  return {
104
142
  accessToken: data.access_token,
105
143
  refreshToken: data.refresh_token,
106
- instanceUrl: data.instance_url,
144
+ instanceUrl,
107
145
  expiresAt: Date.now() + SESSION_TTL_MS,
108
146
  };
109
147
  }
@@ -128,6 +166,7 @@ export async function refreshSalesforceToken(options: {
128
166
  const fetchImpl = options.fetchImpl ?? fetch;
129
167
  const response = await fetchImpl(tokenUrl(options.loginUrl ?? DEFAULT_LOGIN_URL), {
130
168
  method: "POST",
169
+ redirect: "manual",
131
170
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
132
171
  body: new URLSearchParams({
133
172
  grant_type: "refresh_token",
@@ -145,7 +184,7 @@ export async function refreshSalesforceToken(options: {
145
184
  return {
146
185
  accessToken: data.access_token,
147
186
  refreshToken: data.refresh_token ?? options.refreshToken,
148
- instanceUrl: data.instance_url,
187
+ instanceUrl: validateSalesforceOrigin(String(data.instance_url), "Salesforce token instance_url"),
149
188
  expiresAt: Date.now() + SESSION_TTL_MS,
150
189
  };
151
190
  }
@@ -155,17 +194,18 @@ export async function validateSalesforceToken(
155
194
  instanceUrl: string,
156
195
  fetchImpl: typeof fetch = fetch,
157
196
  ): Promise<{ ok: boolean; detail: string }> {
197
+ const trustedInstanceUrl = validateSalesforceOrigin(instanceUrl, "Salesforce instance URL");
158
198
  let response: Response;
159
199
  try {
160
200
  response = await fetchImpl(
161
- `${instanceUrl.replace(/\/$/, "")}/services/oauth2/userinfo`,
162
- { headers: { Authorization: `Bearer ${accessToken}` } },
201
+ `${trustedInstanceUrl}/services/oauth2/userinfo`,
202
+ { redirect: "manual", headers: { Authorization: `Bearer ${accessToken}` } },
163
203
  );
164
204
  } catch (error) {
165
205
  const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
166
206
  return {
167
207
  ok: false,
168
- detail: `Cannot reach Salesforce at ${instanceUrl}${cause}. Check the --instance-url (your My Domain URL, e.g. https://yourco.my.salesforce.com) and network access.`,
208
+ detail: `Cannot reach Salesforce at ${trustedInstanceUrl}${cause}. Check the --instance-url (your My Domain URL, e.g. https://yourco.my.salesforce.com) and network access.`,
169
209
  };
170
210
  }
171
211
  if (response.ok) {
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { readFileSync, statSync } from "node:fs";
23
23
  import { resolve } from "node:path";
24
+ import { FREE_EMAIL_DOMAINS } from "../freeEmailDomains.ts";
24
25
  import type { SignalBucket, StagedSignalRow } from "../signals.ts";
25
26
  import { SIGNAL_BUCKETS } from "../signals.ts";
26
27
  import { parseSpoolText, spoolFilesIn } from "../spoolFiles.ts";
@@ -294,21 +295,10 @@ function fieldValue(values: Array<{ name?: string; value?: string }> | undefined
294
295
  return "";
295
296
  }
296
297
 
297
- const FREE_MAIL = new Set([
298
- "gmail.com",
299
- "yahoo.com",
300
- "hotmail.com",
301
- "outlook.com",
302
- "icloud.com",
303
- "aol.com",
304
- "proton.me",
305
- "protonmail.com",
306
- ]);
307
-
308
298
  /** Company domain from an email, or "" for free-mail / no email. */
309
299
  function corporateDomain(email: string): string {
310
300
  if (!email.includes("@")) return "";
311
301
  const domain = email.split("@").at(-1)!.trim().toLowerCase();
312
- if (!domain || FREE_MAIL.has(domain)) return "";
302
+ if (!domain || FREE_EMAIL_DOMAINS.has(domain)) return "";
313
303
  return domain;
314
304
  }
@@ -17,6 +17,7 @@
17
17
  * `metadata.total_results` with include_total_results:true; limit must be ≥ 1
18
18
  * (limit:0 → 422, the count gotcha — cf. Explorium's `size`-caps-the-total).
19
19
  */
20
+ import { ProviderHttpError } from "../providerError.ts";
20
21
 
21
22
  type FetchImpl = typeof fetch;
22
23
 
@@ -44,14 +45,6 @@ export function theirStackPullCost(companies: number, usdPerCredit?: number): Th
44
45
  };
45
46
  }
46
47
 
47
- async function safeText(res: { text(): Promise<string> }): Promise<string> {
48
- try {
49
- return (await res.text()).slice(0, 300);
50
- } catch {
51
- return "";
52
- }
53
- }
54
-
55
48
  /** Firmographic + technographic filter for TheirStack company search. */
56
49
  export type TheirStackFilters = {
57
50
  /** technology slugs, OR-matched — e.g. ["salesforce","hubspot","pipedrive"]. */
@@ -136,7 +129,7 @@ export async function theirStackCountCompanies(opts: {
136
129
  body: buildBody(opts.filters, 1, 0, true),
137
130
  });
138
131
  if (!res.ok) {
139
- throw new Error(`TheirStack count failed: HTTP ${res.status} ${await safeText(res)}`);
132
+ throw new ProviderHttpError("TheirStack", "count", res.status);
140
133
  }
141
134
  return readTheirStackTotal(await res.json());
142
135
  }
@@ -162,7 +155,7 @@ export async function theirStackSearchCompanies(opts: {
162
155
  body: buildBody(opts.filters, limit, opts.page ?? 0, true),
163
156
  });
164
157
  if (!res.ok) {
165
- throw new Error(`TheirStack search failed: HTTP ${res.status} ${await safeText(res)}`);
158
+ throw new ProviderHttpError("TheirStack", "search", res.status);
166
159
  }
167
160
  const body = (await res.json()) as { data?: Array<Record<string, unknown>>; results?: Array<Record<string, unknown>> };
168
161
  const rows = body.data ?? body.results ?? [];