fullstackgtm 0.48.0 → 0.50.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 (98) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/CONTRIBUTING.md +23 -4
  3. package/DATA-FLOWS.md +8 -2
  4. package/INSTALL_FOR_AGENTS.md +13 -4
  5. package/README.md +53 -1
  6. package/SECURITY.md +27 -3
  7. package/dist/acquireCheckpoint.d.ts +33 -0
  8. package/dist/acquireCheckpoint.js +149 -0
  9. package/dist/acquireLinkedIn.d.ts +2 -0
  10. package/dist/acquireLinkedIn.js +1 -1
  11. package/dist/cli/audit.js +10 -7
  12. package/dist/cli/auth.js +8 -4
  13. package/dist/cli/enrich.js +283 -79
  14. package/dist/cli/fix.js +20 -7
  15. package/dist/cli/help.js +23 -15
  16. package/dist/cli/market.js +180 -2
  17. package/dist/cli/plans.js +154 -9
  18. package/dist/cli/shared.d.ts +3 -1
  19. package/dist/cli/shared.js +2 -2
  20. package/dist/cli/ui.d.ts +10 -5
  21. package/dist/cli/ui.js +18 -5
  22. package/dist/cli.js +1 -1
  23. package/dist/config.d.ts +13 -1
  24. package/dist/config.js +23 -2
  25. package/dist/connectors/linkedin.d.ts +2 -0
  26. package/dist/connectors/linkedin.js +5 -0
  27. package/dist/connectors/prospectSources.d.ts +23 -0
  28. package/dist/connectors/prospectSources.js +25 -14
  29. package/dist/connectors/salesforce.js +12 -5
  30. package/dist/connectors/salesforceAuth.d.ts +9 -0
  31. package/dist/connectors/salesforceAuth.js +47 -5
  32. package/dist/connectors/theirstack.d.ts +0 -19
  33. package/dist/connectors/theirstack.js +3 -10
  34. package/dist/credentials.js +36 -26
  35. package/dist/enrich.d.ts +44 -1
  36. package/dist/hostedAcquireCheckpoint.d.ts +43 -0
  37. package/dist/hostedAcquireCheckpoint.js +129 -0
  38. package/dist/hostedPatchPlan.d.ts +87 -0
  39. package/dist/hostedPatchPlan.js +270 -0
  40. package/dist/icp.js +13 -2
  41. package/dist/index.d.ts +7 -3
  42. package/dist/index.js +6 -2
  43. package/dist/market.d.ts +1 -1
  44. package/dist/market.js +7 -127
  45. package/dist/marketClassify.d.ts +10 -0
  46. package/dist/marketClassify.js +52 -1
  47. package/dist/marketSourcing.js +7 -45
  48. package/dist/mcp.js +67 -115
  49. package/dist/planStore.d.ts +42 -1
  50. package/dist/planStore.js +268 -49
  51. package/dist/progress.d.ts +2 -2
  52. package/dist/progress.js +2 -2
  53. package/dist/providerError.d.ts +21 -0
  54. package/dist/providerError.js +30 -0
  55. package/dist/publicHttp.d.ts +28 -0
  56. package/dist/publicHttp.js +143 -0
  57. package/dist/secureFile.d.ts +15 -0
  58. package/dist/secureFile.js +164 -0
  59. package/dist/types.d.ts +1 -1
  60. package/docs/api.md +53 -2
  61. package/docs/architecture.md +22 -2
  62. package/llms.txt +7 -0
  63. package/package.json +5 -3
  64. package/skills/fullstackgtm/SKILL.md +8 -2
  65. package/src/acquireCheckpoint.ts +186 -0
  66. package/src/acquireLinkedIn.ts +3 -1
  67. package/src/cli/audit.ts +10 -7
  68. package/src/cli/auth.ts +8 -4
  69. package/src/cli/enrich.ts +322 -78
  70. package/src/cli/fix.ts +28 -7
  71. package/src/cli/help.ts +24 -15
  72. package/src/cli/market.ts +181 -2
  73. package/src/cli/plans.ts +159 -9
  74. package/src/cli/shared.ts +2 -1
  75. package/src/cli/ui.ts +19 -9
  76. package/src/cli.ts +1 -1
  77. package/src/config.ts +39 -1
  78. package/src/connectors/linkedin.ts +6 -0
  79. package/src/connectors/prospectSources.ts +50 -15
  80. package/src/connectors/salesforce.ts +12 -5
  81. package/src/connectors/salesforceAuth.ts +46 -6
  82. package/src/connectors/theirstack.ts +3 -10
  83. package/src/credentials.ts +47 -28
  84. package/src/enrich.ts +47 -1
  85. package/src/hostedAcquireCheckpoint.ts +156 -0
  86. package/src/hostedPatchPlan.ts +286 -0
  87. package/src/icp.ts +14 -2
  88. package/src/index.ts +24 -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 +93 -136
  93. package/src/planStore.ts +322 -57
  94. package/src/progress.ts +2 -2
  95. package/src/providerError.ts +37 -0
  96. package/src/publicHttp.ts +147 -0
  97. package/src/secureFile.ts +169 -0
  98. package/src/types.ts +1 -0
package/dist/cli/ui.d.ts CHANGED
@@ -88,14 +88,17 @@ export declare function startElapsedStatus(label: (elapsed: string) => string, s
88
88
  };
89
89
  export type Checklist = {
90
90
  update(id: string, state: "pending" | "running" | "ok" | "warn" | "fail", note?: string): void;
91
- /** Stop animating and erase the board (callers print their own summary). */
92
- done(): void;
91
+ /** Stop animating; optionally leave the final board in the terminal history. */
92
+ done(options?: {
93
+ persist?: boolean;
94
+ }): void;
93
95
  readonly active: boolean;
94
96
  };
95
97
  /**
96
98
  * A multi-line live board — one line per item, each flipping ○ → ⠹ → ✓ as work
97
99
  * progresses (the audit rule registry renders through this). Repaints with
98
- * cursor-up; erases itself on done() so the verb's real output owns stdout.
100
+ * cursor-up; done() normally erases it, while done({ persist: true }) leaves a
101
+ * final static history for multi-phase human workflows.
99
102
  */
100
103
  export declare function createChecklist(items: Array<{
101
104
  id: string;
@@ -107,8 +110,10 @@ export type ProgressRenderer = {
107
110
  * listener via `composeListeners` when the run should also heartbeat).
108
111
  */
109
112
  listener: ProgressListener;
110
- /** Stop animating and erase the board (callers print their own summary). */
111
- done(): void;
113
+ /** Stop animating; optionally leave the completed board in terminal history. */
114
+ done(options?: {
115
+ persist?: boolean;
116
+ }): void;
112
117
  readonly active: boolean;
113
118
  };
114
119
  /**
package/dist/cli/ui.js CHANGED
@@ -241,7 +241,8 @@ const NOOP_CHECKLIST = { update() { }, done() { }, active: false };
241
241
  /**
242
242
  * A multi-line live board — one line per item, each flipping ○ → ⠹ → ✓ as work
243
243
  * progresses (the audit rule registry renders through this). Repaints with
244
- * cursor-up; erases itself on done() so the verb's real output owns stdout.
244
+ * cursor-up; done() normally erases it, while done({ persist: true }) leaves a
245
+ * final static history for multi-phase human workflows.
245
246
  */
246
247
  export function createChecklist(items, stream = process.stderr, env = process.env) {
247
248
  if (!animationEnabled(stream, env) || items.length === 0)
@@ -280,14 +281,22 @@ export function createChecklist(items, stream = process.stderr, env = process.en
280
281
  if (!entry)
281
282
  return;
282
283
  entry.state = state;
283
- entry.note = note;
284
+ if (note !== undefined)
285
+ entry.note = note;
284
286
  start();
285
287
  render();
286
288
  },
287
- done() {
289
+ done(options = {}) {
288
290
  if (timer)
289
291
  clearInterval(timer);
290
292
  timer = null;
293
+ if (options.persist) {
294
+ // The caller's final state update already painted the completed board.
295
+ // Freeze that frame in scrollback; repainting here can leave both the
296
+ // last live frame and the final frame visible in some terminals.
297
+ painted = 0;
298
+ return;
299
+ }
291
300
  if (painted > 0) {
292
301
  // Erase the board: cursor up over every painted line, clear each.
293
302
  stream.write(`\u001b[${painted}A${"\u001b[2K\n".repeat(painted)}\u001b[${painted}A`);
@@ -344,8 +353,10 @@ export function createProgressRenderer(stages, stream = process.stderr, env = pr
344
353
  if (current)
345
354
  board.update(current, "running", noteFor(event, snapshot));
346
355
  },
347
- done() {
348
- board.done();
356
+ done(options) {
357
+ if (current)
358
+ board.update(current, "ok");
359
+ board.done(options);
349
360
  },
350
361
  active: true,
351
362
  };
@@ -374,6 +385,8 @@ export function planStatusWord(status, p) {
374
385
  return p.yellow(status);
375
386
  if (status === "approved" || status === "applied")
376
387
  return p.green(status);
388
+ if (status === "applying")
389
+ return p.yellow(status);
377
390
  if (status === "rejected")
378
391
  return p.red(status);
379
392
  return status;
package/dist/cli.js CHANGED
@@ -88,7 +88,7 @@ export async function runCli(argv) {
88
88
  }
89
89
  // Commands without bespoke help get focused per-command help on --help
90
90
  // instead of executing (audit used to silently run the sample audit) or
91
- // dumping the whole surface. call/market/enrich/bulk-update/schedule print
91
+ // dumping the whole surface. call/market/enrich/schedule print
92
92
  // their own richer help. `--full` always escapes to the complete reference.
93
93
  if (!BESPOKE_HELP.includes(command) && (args.includes("--help") || args.includes("-h"))) {
94
94
  console.log(args.includes("--full") ? usage() : commandHelp(command));
package/dist/config.d.ts CHANGED
@@ -26,6 +26,18 @@ export type LoadedConfig = {
26
26
  config: FullstackgtmConfig;
27
27
  path: string;
28
28
  };
29
+ export type RulePackageTrust = {
30
+ /** Execute rule-package modules. Only set after an explicit trust decision. */
31
+ allowRulePackages?: boolean;
32
+ /** Deliberately ignore rule packages while still applying declarative config. */
33
+ disableRulePackages?: boolean;
34
+ };
35
+ /**
36
+ * Translate the CLI's explicit trust flags into the library trust contract.
37
+ * Plugin execution requires both an explicit config path and --allow-plugins;
38
+ * discovering a config in the current directory is never a trust decision.
39
+ */
40
+ export declare function rulePackageTrustFromCli(args: string[], explicitConfigPath?: string): RulePackageTrust;
29
41
  export declare function loadConfig(explicitPath?: string, cwd?: string): LoadedConfig | null;
30
42
  /** Overlay config policy values onto a base policy; defined values win. */
31
43
  export declare function mergePolicy(base: GtmPolicy, config?: FullstackgtmConfig): GtmPolicy;
@@ -33,4 +45,4 @@ export declare function mergePolicy(base: GtmPolicy, config?: FullstackgtmConfig
33
45
  * Build the effective rule set: built-ins plus rule-package exports, then
34
46
  * `enabled` (allow-list) and `disabled` filters.
35
47
  */
36
- export declare function resolveConfiguredRules(loaded?: LoadedConfig | null, baseRules?: GtmAuditRule[]): Promise<GtmAuditRule[]>;
48
+ export declare function resolveConfiguredRules(loaded?: LoadedConfig | null, baseRules?: GtmAuditRule[], trust?: RulePackageTrust): Promise<GtmAuditRule[]>;
package/dist/config.js CHANGED
@@ -18,6 +18,22 @@ import { builtinAuditRules } from "./rules.js";
18
18
  * in version control.
19
19
  */
20
20
  export const CONFIG_FILE_NAME = "fullstackgtm.config.json";
21
+ /**
22
+ * Translate the CLI's explicit trust flags into the library trust contract.
23
+ * Plugin execution requires both an explicit config path and --allow-plugins;
24
+ * discovering a config in the current directory is never a trust decision.
25
+ */
26
+ export function rulePackageTrustFromCli(args, explicitConfigPath) {
27
+ const allow = args.includes("--allow-plugins");
28
+ const disable = args.includes("--no-plugins");
29
+ if (allow && disable) {
30
+ throw new Error("--allow-plugins and --no-plugins cannot be used together.");
31
+ }
32
+ if (allow && !explicitConfigPath) {
33
+ throw new Error("--allow-plugins requires --config <path>. Plugin code is never trusted from an implicitly discovered config.");
34
+ }
35
+ return { allowRulePackages: allow, disableRulePackages: disable };
36
+ }
21
37
  export function loadConfig(explicitPath, cwd = process.cwd()) {
22
38
  const path = explicitPath ? resolve(cwd, explicitPath) : resolve(cwd, CONFIG_FILE_NAME);
23
39
  let raw;
@@ -51,9 +67,14 @@ export function mergePolicy(base, config) {
51
67
  * Build the effective rule set: built-ins plus rule-package exports, then
52
68
  * `enabled` (allow-list) and `disabled` filters.
53
69
  */
54
- export async function resolveConfiguredRules(loaded, baseRules = builtinAuditRules) {
70
+ export async function resolveConfiguredRules(loaded, baseRules = builtinAuditRules, trust = {}) {
55
71
  let rules = [...baseRules];
56
- for (const specifier of loaded?.config.rulePackages ?? []) {
72
+ const packages = loaded?.config.rulePackages ?? [];
73
+ if (packages.length && !trust.allowRulePackages && !trust.disableRulePackages) {
74
+ throw new Error(`Config ${loaded.path} declares executable rulePackages. Refusing to load plugin code without explicit trust. ` +
75
+ "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.");
76
+ }
77
+ for (const specifier of trust.disableRulePackages ? [] : packages) {
57
78
  const resolvedSpecifier = specifier.startsWith(".") || isAbsolute(specifier)
58
79
  ? pathToFileURL(resolve(dirname(loaded.path), specifier)).href
59
80
  : specifier;
@@ -38,6 +38,8 @@ export type FetchProspectsOptions = {
38
38
  sourceId?: string;
39
39
  /** Hard cap on prospects returned; also bounds pagination. */
40
40
  max?: number;
41
+ /** Opaque continuation cursor. HeyReach uses the decimal list offset. */
42
+ cursor?: string;
41
43
  };
42
44
  export type ConnectionStatus = {
43
45
  ok: boolean;
@@ -85,6 +85,11 @@ export function createHeyReachProvider(options) {
85
85
  const max = opts.max ?? Number.POSITIVE_INFINITY;
86
86
  const out = [];
87
87
  let offset = 0;
88
+ if (opts.cursor !== undefined) {
89
+ if (!/^\d+$/.test(opts.cursor))
90
+ throw new Error(`Invalid HeyReach cursor: ${opts.cursor}`);
91
+ offset = Number(opts.cursor);
92
+ }
88
93
  while (out.length < max) {
89
94
  const data = await call("/list/GetLeadsFromList", "POST", { listId, offset, limit: pageSize });
90
95
  const items = toArray(data);
@@ -46,6 +46,8 @@ export declare function fetchExploriumProspects(opts: {
46
46
  apiKey: string;
47
47
  filters: ExploriumFilters;
48
48
  size?: number;
49
+ /** One-based result page. */
50
+ page?: number;
49
51
  apiBaseUrl?: string;
50
52
  fetchImpl?: FetchImpl;
51
53
  }): Promise<Prospect[]>;
@@ -53,9 +55,30 @@ export declare function fetchPipe0CrustdataProspects(opts: {
53
55
  apiKey: string;
54
56
  filters: Record<string, unknown>;
55
57
  limit?: number;
58
+ /** Opaque cursor returned by a previous page. */
59
+ cursor?: string;
56
60
  apiBaseUrl?: string;
57
61
  fetchImpl?: FetchImpl;
58
62
  }): Promise<Prospect[]>;
63
+ /** A page from pipe0/Crustdata's cursor-based people search. */
64
+ export type Pipe0CrustdataProspectPage = {
65
+ prospects: Prospect[];
66
+ /** Opaque cursor for the next page, or null when the search is exhausted. */
67
+ nextCursor: string | null;
68
+ };
69
+ /**
70
+ * Fetch one page and expose its continuation cursor. The array-returning
71
+ * `fetchPipe0CrustdataProspects` remains available for existing consumers.
72
+ */
73
+ export declare function fetchPipe0CrustdataProspectPage(opts: {
74
+ apiKey: string;
75
+ filters: Record<string, unknown>;
76
+ limit?: number;
77
+ /** Opaque cursor returned by a previous page. */
78
+ cursor?: string;
79
+ apiBaseUrl?: string;
80
+ fetchImpl?: FetchImpl;
81
+ }): Promise<Pipe0CrustdataProspectPage>;
59
82
  export declare const EXPLORIUM_BUSINESS_COUNT_CAP = 60000;
60
83
  export type BusinessCountProbe = {
61
84
  /** matching companies (the account universe); == cap when saturated. */
@@ -1,3 +1,4 @@
1
+ import { ProviderHttpError } from "../providerError.js";
1
2
  function splitName(full) {
2
3
  if (!full)
3
4
  return {};
@@ -23,10 +24,10 @@ export async function fetchExploriumProspects(opts) {
23
24
  const response = await fetchImpl(`${base}/v1/prospects`, {
24
25
  method: "POST",
25
26
  headers: { "api_key": opts.apiKey, "Content-Type": "application/json" },
26
- body: JSON.stringify({ mode: "full", size, page_size: size, page: 1, filters: opts.filters }),
27
+ body: JSON.stringify({ mode: "full", size, page_size: size, page: opts.page ?? 1, filters: opts.filters }),
27
28
  });
28
29
  if (!response.ok) {
29
- throw new Error(`Explorium /v1/prospects failed: HTTP ${response.status} ${await safeText(response)}`);
30
+ throw new ProviderHttpError("Explorium", "prospect search", response.status);
30
31
  }
31
32
  const data = (await response.json());
32
33
  return (data.data ?? []).map((row) => ({
@@ -47,18 +48,28 @@ export async function fetchExploriumProspects(opts) {
47
48
  // (no separate connection needed). Returns profiles; pair with
48
49
  // pipe0ResolveWorkEmails to get real emails.
49
50
  export async function fetchPipe0CrustdataProspects(opts) {
51
+ return (await fetchPipe0CrustdataProspectPage(opts)).prospects;
52
+ }
53
+ /**
54
+ * Fetch one page and expose its continuation cursor. The array-returning
55
+ * `fetchPipe0CrustdataProspects` remains available for existing consumers.
56
+ */
57
+ export async function fetchPipe0CrustdataProspectPage(opts) {
50
58
  const fetchImpl = opts.fetchImpl ?? fetch;
51
59
  const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
52
60
  const limit = Math.min(opts.limit ?? 25, 100);
61
+ const config = { limit, filters: opts.filters };
62
+ if (opts.cursor !== undefined)
63
+ config.cursor = opts.cursor;
53
64
  const response = await fetchImpl(`${base}/v1/searches/run/sync`, {
54
65
  method: "POST",
55
66
  headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
56
67
  body: JSON.stringify({
57
- searches: [{ search_id: "people:profiles:crustdata@1", config: { limit, filters: opts.filters } }],
68
+ searches: [{ search_id: "people:profiles:crustdata@1", config }],
58
69
  }),
59
70
  });
60
71
  if (!response.ok) {
61
- throw new Error(`pipe0 /v1/searches/run/sync failed: HTTP ${response.status} ${await safeText(response)}`);
72
+ throw new ProviderHttpError("pipe0", "prospect search", response.status);
62
73
  }
63
74
  const body = (await response.json());
64
75
  // Surface upstream provider errors (e.g. CreditBalanceInsufficient) instead of
@@ -67,7 +78,7 @@ export async function fetchPipe0CrustdataProspects(opts) {
67
78
  if (upstreamErrors.length > 0 && !(body.results ?? []).length) {
68
79
  throw new Error(`pipe0/Crustdata search error: ${upstreamErrors.map((e) => `${e.code ?? "error"}: ${e.message ?? ""}`).join("; ")}`);
69
80
  }
70
- return (body.results ?? []).map((row) => {
81
+ const prospects = (body.results ?? []).map((row) => {
71
82
  const fullName = strField(row.name);
72
83
  const { firstName, lastName } = splitName(fullName);
73
84
  return {
@@ -80,6 +91,14 @@ export async function fetchPipe0CrustdataProspects(opts) {
80
91
  sourceId: strField(row.profile_url) ?? fullName,
81
92
  };
82
93
  });
94
+ // Current pipe0 responses expose `next_cursor` at the top level. Accept the
95
+ // status-envelope variants too so older deployments remain usable.
96
+ const status = body.search_statuses?.[0];
97
+ const rawCursor = body.next_cursor ?? status?.next_cursor ?? status?.cursor;
98
+ return {
99
+ prospects,
100
+ nextCursor: typeof rawCursor === "string" && rawCursor.length > 0 ? rawCursor : null,
101
+ };
83
102
  }
84
103
  function strField(field) {
85
104
  const v = field?.value;
@@ -119,7 +138,7 @@ export async function probeExploriumBusinessCount(opts) {
119
138
  body: JSON.stringify({ mode: "full", page_size: 1, page: 1, filters: opts.filters }),
120
139
  });
121
140
  if (!response.ok) {
122
- throw new Error(`Explorium /v1/businesses count failed: HTTP ${response.status} ${await safeText(response)}`);
141
+ throw new ProviderHttpError("Explorium", "business count", response.status);
123
142
  }
124
143
  const body = (await response.json());
125
144
  const total = body.total_results;
@@ -293,14 +312,6 @@ function fieldValue(field) {
293
312
  const v = field?.value;
294
313
  return typeof v === "string" && v.trim() ? v : undefined;
295
314
  }
296
- async function safeText(response) {
297
- try {
298
- return (await response.text()).slice(0, 300);
299
- }
300
- catch {
301
- return "";
302
- }
303
- }
304
315
  // ---------------------------------------------------------------------------
305
316
  // Pre-email dedup: identity keys shared between prospects and CRM contacts, so
306
317
  // we can drop already-in-CRM / already-seen people BEFORE paying for emails.
@@ -1,4 +1,5 @@
1
1
  import { SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, readMappedValue, } from "../mappings.js";
2
+ import { validateSalesforceOrigin } from "./salesforceAuth.js";
2
3
  import { SNAPSHOT_PULL_STAGES } from "../progress.js";
3
4
  const DEFAULT_API_VERSION = "v59.0";
4
5
  const SOBJECT_TYPES = {
@@ -97,13 +98,17 @@ export function createSalesforceConnector(options) {
97
98
  }
98
99
  async function request(path, init = {}) {
99
100
  const connection = await options.getConnection();
100
- const url = path.startsWith("http")
101
- ? path
102
- : `${connection.instanceUrl.replace(/\/$/, "")}${path}`;
101
+ const instanceUrl = validateSalesforceOrigin(connection.instanceUrl, "Salesforce connection instance URL");
102
+ const resolved = new URL(path, `${instanceUrl}/`);
103
+ if (resolved.origin !== instanceUrl) {
104
+ throw new Error("Salesforce response attempted to send credentials to a different origin.");
105
+ }
106
+ const url = resolved.href;
103
107
  let response;
104
108
  try {
105
109
  response = await fetchImpl(url, {
106
110
  ...init,
111
+ redirect: "manual",
107
112
  headers: {
108
113
  Authorization: `Bearer ${connection.accessToken}`,
109
114
  "Content-Type": "application/json",
@@ -113,7 +118,7 @@ export function createSalesforceConnector(options) {
113
118
  }
114
119
  catch (error) {
115
120
  const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
116
- throw new Error(`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.`);
121
+ throw new Error(`Cannot reach Salesforce at ${instanceUrl}${cause}. Check SALESFORCE_INSTANCE_URL (your My Domain URL, e.g. https://yourco.my.salesforce.com) and network access.`);
117
122
  }
118
123
  if (!response.ok) {
119
124
  // Status line only — the body echoes submitted field values and the
@@ -526,8 +531,9 @@ export function createSalesforceConnector(options) {
526
531
  */
527
532
  async function soapMerge(sobjectType, masterId, loserIds) {
528
533
  const connection = await options.getConnection();
534
+ const instanceUrl = validateSalesforceOrigin(connection.instanceUrl, "Salesforce connection instance URL");
529
535
  const version = apiVersion.replace(/^v/, ""); // SOAP path uses "59.0", not "v59.0"
530
- const url = `${connection.instanceUrl.replace(/\/$/, "")}/services/Soap/u/${version}`;
536
+ const url = `${instanceUrl}/services/Soap/u/${version}`;
531
537
  const toMerge = loserIds.map((id) => `<urn:recordToMergeIds>${escapeXml(id)}</urn:recordToMergeIds>`).join("");
532
538
  const envelope = `<?xml version="1.0" encoding="UTF-8"?>` +
533
539
  `<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:urn="urn:partner.soap.sforce.com" xmlns:urn1="urn:sobject.partner.soap.sforce.com">` +
@@ -540,6 +546,7 @@ export function createSalesforceConnector(options) {
540
546
  try {
541
547
  response = await fetchImpl(url, {
542
548
  method: "POST",
549
+ redirect: "manual",
543
550
  headers: { "Content-Type": "text/xml; charset=UTF-8", SOAPAction: '""' },
544
551
  body: envelope,
545
552
  });
@@ -6,6 +6,15 @@
6
6
  * user confirms on any device. Requires a Connected App with device flow
7
7
  * enabled; only its consumer key (client id) is needed.
8
8
  */
9
+ /**
10
+ * Normalize and validate a Salesforce credential-bearing origin.
11
+ *
12
+ * Salesforce API hosts are either the standard login/sandbox hosts or a
13
+ * Salesforce-owned instance/My Domain below salesforce.com. Deliberately
14
+ * return an origin (not the input URL) so callers cannot accidentally retain
15
+ * paths, queries, credentials, or fragments from configuration.
16
+ */
17
+ export declare function validateSalesforceOrigin(value: string, label?: string): string;
9
18
  export type SalesforceTokenSet = {
10
19
  accessToken: string;
11
20
  refreshToken?: string;
@@ -7,9 +7,46 @@
7
7
  * enabled; only its consumer key (client id) is needed.
8
8
  */
9
9
  const DEFAULT_LOGIN_URL = "https://login.salesforce.com";
10
+ /**
11
+ * Normalize and validate a Salesforce credential-bearing origin.
12
+ *
13
+ * Salesforce API hosts are either the standard login/sandbox hosts or a
14
+ * Salesforce-owned instance/My Domain below salesforce.com. Deliberately
15
+ * return an origin (not the input URL) so callers cannot accidentally retain
16
+ * paths, queries, credentials, or fragments from configuration.
17
+ */
18
+ export function validateSalesforceOrigin(value, label = "Salesforce URL") {
19
+ let url;
20
+ try {
21
+ url = new URL(value);
22
+ }
23
+ catch {
24
+ throw new Error(`${label} must be a valid HTTPS Salesforce URL.`);
25
+ }
26
+ if (url.protocol !== "https:")
27
+ throw new Error(`${label} must use HTTPS.`);
28
+ if (url.username || url.password)
29
+ throw new Error(`${label} must not contain user information.`);
30
+ if (url.hash)
31
+ throw new Error(`${label} must not contain a fragment.`);
32
+ if (url.search)
33
+ throw new Error(`${label} must not contain a query string.`);
34
+ if (url.pathname !== "/" && url.pathname !== "")
35
+ throw new Error(`${label} must be an origin without a path.`);
36
+ if (url.port && url.port !== "443")
37
+ throw new Error(`${label} must use the standard HTTPS port.`);
38
+ const hostname = url.hostname.toLowerCase();
39
+ const trusted = hostname === "login.salesforce.com" ||
40
+ hostname === "test.salesforce.com" ||
41
+ (hostname.endsWith(".salesforce.com") && hostname.length > ".salesforce.com".length);
42
+ if (!trusted) {
43
+ throw new Error(`${label} must use login.salesforce.com, test.salesforce.com, or a Salesforce instance/My Domain host.`);
44
+ }
45
+ return url.origin;
46
+ }
10
47
  const SESSION_TTL_MS = 2 * 60 * 60 * 1000;
11
48
  function tokenUrl(loginUrl) {
12
- return `${loginUrl.replace(/\/$/, "")}/services/oauth2/token`;
49
+ return `${validateSalesforceOrigin(loginUrl, "Salesforce login URL")}/services/oauth2/token`;
13
50
  }
14
51
  /**
15
52
  * OAuth error responses can echo request parameters. Surface only the
@@ -32,6 +69,7 @@ export async function startSalesforceDeviceLogin(options) {
32
69
  const fetchImpl = options.fetchImpl ?? fetch;
33
70
  const response = await fetchImpl(tokenUrl(options.loginUrl ?? DEFAULT_LOGIN_URL), {
34
71
  method: "POST",
72
+ redirect: "manual",
35
73
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
36
74
  body: new URLSearchParams({
37
75
  response_type: "device_code",
@@ -59,6 +97,7 @@ export async function pollSalesforceDeviceLogin(options) {
59
97
  await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
60
98
  const response = await fetchImpl(url, {
61
99
  method: "POST",
100
+ redirect: "manual",
62
101
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
63
102
  body: new URLSearchParams({
64
103
  grant_type: "device",
@@ -68,10 +107,11 @@ export async function pollSalesforceDeviceLogin(options) {
68
107
  });
69
108
  const data = await response.json().catch(() => ({}));
70
109
  if (response.ok && data.access_token && data.instance_url) {
110
+ const instanceUrl = validateSalesforceOrigin(String(data.instance_url), "Salesforce token instance_url");
71
111
  return {
72
112
  accessToken: data.access_token,
73
113
  refreshToken: data.refresh_token,
74
- instanceUrl: data.instance_url,
114
+ instanceUrl,
75
115
  expiresAt: Date.now() + SESSION_TTL_MS,
76
116
  };
77
117
  }
@@ -89,6 +129,7 @@ export async function refreshSalesforceToken(options) {
89
129
  const fetchImpl = options.fetchImpl ?? fetch;
90
130
  const response = await fetchImpl(tokenUrl(options.loginUrl ?? DEFAULT_LOGIN_URL), {
91
131
  method: "POST",
132
+ redirect: "manual",
92
133
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
93
134
  body: new URLSearchParams({
94
135
  grant_type: "refresh_token",
@@ -106,20 +147,21 @@ export async function refreshSalesforceToken(options) {
106
147
  return {
107
148
  accessToken: data.access_token,
108
149
  refreshToken: data.refresh_token ?? options.refreshToken,
109
- instanceUrl: data.instance_url,
150
+ instanceUrl: validateSalesforceOrigin(String(data.instance_url), "Salesforce token instance_url"),
110
151
  expiresAt: Date.now() + SESSION_TTL_MS,
111
152
  };
112
153
  }
113
154
  export async function validateSalesforceToken(accessToken, instanceUrl, fetchImpl = fetch) {
155
+ const trustedInstanceUrl = validateSalesforceOrigin(instanceUrl, "Salesforce instance URL");
114
156
  let response;
115
157
  try {
116
- response = await fetchImpl(`${instanceUrl.replace(/\/$/, "")}/services/oauth2/userinfo`, { headers: { Authorization: `Bearer ${accessToken}` } });
158
+ response = await fetchImpl(`${trustedInstanceUrl}/services/oauth2/userinfo`, { redirect: "manual", headers: { Authorization: `Bearer ${accessToken}` } });
117
159
  }
118
160
  catch (error) {
119
161
  const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
120
162
  return {
121
163
  ok: false,
122
- 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.`,
164
+ 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.`,
123
165
  };
124
166
  }
125
167
  if (response.ok) {
@@ -1,22 +1,3 @@
1
- /**
2
- * TheirStack — technographic company discovery.
3
- *
4
- * Explorium can only size a firmographic universe (NAICS/size/geo) and can't even
5
- * return the list. For a CRM-hygiene/RevOps tool the real buying signal is
6
- * "company runs a CRM", and the deliverable is an actual list of those companies.
7
- * TheirStack does both: filter companies by the technology they use (Salesforce,
8
- * HubSpot, Pipedrive, …) plus firmographics, and return real records (name,
9
- * domain, employee_count, …). A cheap count sizes the market; a paged search
10
- * materializes the list. Both cost ~3 credits per company RETURNED — counting
11
- * still returns 1 row (the API rejects limit:0), so a count is ~3 credits, not
12
- * free; a list pull is ~3 × the rows.
13
- *
14
- * API: POST https://api.theirstack.com/v1/companies/search, Bearer auth.
15
- * Verified live (2026-06-29): filters company_technology_slug_or /
16
- * company_country_code_or / min_employee_count / max_employee_count; the total is
17
- * `metadata.total_results` with include_total_results:true; limit must be ≥ 1
18
- * (limit:0 → 422, the count gotcha — cf. Explorium's `size`-caps-the-total).
19
- */
20
1
  type FetchImpl = typeof fetch;
21
2
  /** TheirStack charges per company RETURNED — verified live (a list pull of N
22
3
  * companies spends 3N). The count (limit:1) returns 1 row, so it's ~3 credits. */
@@ -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.js";
20
21
  /** TheirStack charges per company RETURNED — verified live (a list pull of N
21
22
  * companies spends 3N). The count (limit:1) returns 1 row, so it's ~3 credits. */
22
23
  export const THEIRSTACK_CREDITS_PER_COMPANY = 3;
@@ -31,14 +32,6 @@ export function theirStackPullCost(companies, usdPerCredit) {
31
32
  ...(usdPerCredit && usdPerCredit > 0 ? { usd: Math.round(credits * usdPerCredit) } : {}),
32
33
  };
33
34
  }
34
- async function safeText(res) {
35
- try {
36
- return (await res.text()).slice(0, 300);
37
- }
38
- catch {
39
- return "";
40
- }
41
- }
42
35
  const BASE = "https://api.theirstack.com";
43
36
  const SEARCH_PATH = "/v1/companies/search";
44
37
  function buildBody(filters, limit, page, includeTotal) {
@@ -99,7 +92,7 @@ export async function theirStackCountCompanies(opts) {
99
92
  body: buildBody(opts.filters, 1, 0, true),
100
93
  });
101
94
  if (!res.ok) {
102
- throw new Error(`TheirStack count failed: HTTP ${res.status} ${await safeText(res)}`);
95
+ throw new ProviderHttpError("TheirStack", "count", res.status);
103
96
  }
104
97
  return readTheirStackTotal(await res.json());
105
98
  }
@@ -117,7 +110,7 @@ export async function theirStackSearchCompanies(opts) {
117
110
  body: buildBody(opts.filters, limit, opts.page ?? 0, true),
118
111
  });
119
112
  if (!res.ok) {
120
- throw new Error(`TheirStack search failed: HTTP ${res.status} ${await safeText(res)}`);
113
+ throw new ProviderHttpError("TheirStack", "search", res.status);
121
114
  }
122
115
  const body = (await res.json());
123
116
  const rows = body.data ?? body.results ?? [];