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.
- package/CHANGELOG.md +76 -6
- package/CONTRIBUTING.md +23 -4
- package/DATA-FLOWS.md +7 -2
- package/INSTALL_FOR_AGENTS.md +15 -6
- package/README.md +28 -9
- package/SECURITY.md +27 -3
- package/dist/audit.js +7 -0
- package/dist/backfill.js +2 -16
- package/dist/cli/audit.js +10 -7
- package/dist/cli/auth.js +8 -4
- package/dist/cli/fix.d.ts +3 -0
- package/dist/cli/fix.js +90 -8
- package/dist/cli/help.d.ts +6 -0
- package/dist/cli/help.js +81 -3
- package/dist/cli/market.js +180 -2
- package/dist/cli/plans.js +56 -5
- package/dist/cli/suggest.d.ts +2 -1
- package/dist/cli/suggest.js +49 -3
- package/dist/cli/ui.js +2 -0
- package/dist/cli.js +41 -16
- package/dist/config.d.ts +13 -1
- package/dist/config.js +23 -2
- package/dist/connectors/hubspot.d.ts +2 -1
- package/dist/connectors/prospectSources.js +4 -11
- package/dist/connectors/salesforce.d.ts +2 -1
- package/dist/connectors/salesforce.js +12 -5
- package/dist/connectors/salesforceAuth.d.ts +9 -0
- package/dist/connectors/salesforceAuth.js +47 -5
- package/dist/connectors/signalSources.js +2 -11
- package/dist/connectors/theirstack.d.ts +0 -19
- package/dist/connectors/theirstack.js +3 -10
- package/dist/credentials.js +36 -26
- package/dist/format.js +31 -5
- package/dist/freeEmailDomains.d.ts +1 -0
- package/dist/freeEmailDomains.js +14 -0
- package/dist/hierarchy.d.ts +29 -0
- package/dist/hierarchy.js +164 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.js +8 -3
- package/dist/market.d.ts +1 -1
- package/dist/market.js +7 -127
- package/dist/marketClassify.d.ts +10 -0
- package/dist/marketClassify.js +52 -1
- package/dist/marketSourcing.js +7 -45
- package/dist/mcp.js +86 -130
- package/dist/planStore.d.ts +28 -1
- package/dist/planStore.js +195 -49
- package/dist/providerError.d.ts +21 -0
- package/dist/providerError.js +30 -0
- package/dist/publicHttp.d.ts +28 -0
- package/dist/publicHttp.js +143 -0
- package/dist/relationships.d.ts +39 -0
- package/dist/relationships.js +101 -0
- package/dist/route.d.ts +46 -0
- package/dist/route.js +228 -0
- package/dist/rules.d.ts +1 -0
- package/dist/rules.js +172 -12
- package/dist/secureFile.d.ts +15 -0
- package/dist/secureFile.js +164 -0
- package/dist/types.d.ts +16 -1
- package/docs/api.md +49 -5
- package/docs/architecture.md +18 -4
- package/llms.txt +7 -0
- package/package.json +5 -3
- package/skills/fullstackgtm/SKILL.md +9 -3
- package/src/audit.ts +7 -0
- package/src/backfill.ts +2 -17
- package/src/cli/audit.ts +10 -7
- package/src/cli/auth.ts +8 -4
- package/src/cli/fix.ts +96 -8
- package/src/cli/help.ts +88 -3
- package/src/cli/market.ts +181 -2
- package/src/cli/plans.ts +66 -5
- package/src/cli/suggest.ts +58 -4
- package/src/cli/ui.ts +1 -0
- package/src/cli.ts +39 -14
- package/src/config.ts +39 -1
- package/src/connectors/hubspot.ts +3 -1
- package/src/connectors/prospectSources.ts +4 -11
- package/src/connectors/salesforce.ts +15 -6
- package/src/connectors/salesforceAuth.ts +46 -6
- package/src/connectors/signalSources.ts +2 -12
- package/src/connectors/theirstack.ts +3 -10
- package/src/credentials.ts +47 -28
- package/src/format.ts +33 -5
- package/src/freeEmailDomains.ts +14 -0
- package/src/hierarchy.ts +185 -0
- package/src/index.ts +29 -0
- package/src/market.ts +7 -111
- package/src/marketClassify.ts +61 -1
- package/src/marketSourcing.ts +7 -43
- package/src/mcp.ts +115 -152
- package/src/planStore.ts +235 -57
- package/src/providerError.ts +37 -0
- package/src/publicHttp.ts +147 -0
- package/src/relationships.ts +115 -0
- package/src/route.ts +278 -0
- package/src/rules.ts +192 -12
- package/src/secureFile.ts +169 -0
- package/src/types.ts +18 -0
package/dist/cli/suggest.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// (exit 1) that prints the exact corrected command for the agent to run
|
|
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
|
-
import { HELP, usage } from "./help.js";
|
|
14
|
+
import { COMMAND_FLAGS, FLAGS_WITH_VALUES, GLOBAL_FLAGS, GLOBAL_SHORT_FLAGS, HELP, usage, } from "./help.js";
|
|
15
15
|
export function levenshtein(a, b) {
|
|
16
16
|
const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
|
|
17
17
|
for (let i = 0; i <= a.length; i += 1)
|
|
@@ -96,6 +96,52 @@ export function detectFlagTypo(args) {
|
|
|
96
96
|
}
|
|
97
97
|
return null;
|
|
98
98
|
}
|
|
99
|
+
function isFlagShaped(token) {
|
|
100
|
+
return token !== "-" && token.startsWith("-");
|
|
101
|
+
}
|
|
102
|
+
function suggestCommandFlag(unknown, allowed) {
|
|
103
|
+
const candidates = [...allowed].filter((flag) => flag.startsWith("--"));
|
|
104
|
+
const prefix = candidates
|
|
105
|
+
.filter((flag) => unknown.startsWith(`${flag}-`) || flag.startsWith(unknown))
|
|
106
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
107
|
+
if (prefix)
|
|
108
|
+
return prefix;
|
|
109
|
+
return nearest(unknown.toLowerCase().replace(/_/g, "-"), candidates, 3);
|
|
110
|
+
}
|
|
111
|
+
export function detectUnknownFlag(command, args) {
|
|
112
|
+
const commandFlags = COMMAND_FLAGS[command];
|
|
113
|
+
if (!commandFlags)
|
|
114
|
+
return null;
|
|
115
|
+
const allowed = new Set([...GLOBAL_FLAGS, ...commandFlags]);
|
|
116
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
117
|
+
const token = args[index];
|
|
118
|
+
if (token === "--")
|
|
119
|
+
break;
|
|
120
|
+
if (GLOBAL_SHORT_FLAGS.includes(token))
|
|
121
|
+
continue;
|
|
122
|
+
if (!token.startsWith("-"))
|
|
123
|
+
continue;
|
|
124
|
+
if (!token.startsWith("--")) {
|
|
125
|
+
return { given: token, suggestion: null, replacement: [] };
|
|
126
|
+
}
|
|
127
|
+
const equalsIndex = token.indexOf("=");
|
|
128
|
+
const flag = equalsIndex === -1 ? token : token.slice(0, equalsIndex);
|
|
129
|
+
if (allowed.has(flag)) {
|
|
130
|
+
if (equalsIndex !== -1) {
|
|
131
|
+
const value = token.slice(equalsIndex + 1);
|
|
132
|
+
const replacement = value === "" ? [flag] : [flag, value];
|
|
133
|
+
return { given: token, suggestion: replacement.join(" "), replacement };
|
|
134
|
+
}
|
|
135
|
+
if (FLAGS_WITH_VALUES.has(flag) && args[index + 1] !== undefined && !isFlagShaped(args[index + 1])) {
|
|
136
|
+
index += 1;
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const suggestion = suggestCommandFlag(flag, allowed);
|
|
141
|
+
return { given: flag, suggestion, replacement: suggestion ? [suggestion] : [] };
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
99
145
|
/** Nearest known command, derived from the same HELP table. */
|
|
100
146
|
export function suggestCommand(command) {
|
|
101
147
|
if (!command)
|
|
@@ -127,8 +173,8 @@ export function unknownFlagEnvelope(command, args, typo) {
|
|
|
127
173
|
code: "UNKNOWN_FLAG",
|
|
128
174
|
message: `Unknown flag: ${typo.given}`,
|
|
129
175
|
hints: [
|
|
130
|
-
`Did you mean: ${typo.suggestion}
|
|
131
|
-
`Try: ${correctedCommand(command, args, typo)}
|
|
176
|
+
...(typo.suggestion ? [`Did you mean: ${typo.suggestion}`] : []),
|
|
177
|
+
...(typo.replacement.length > 0 ? [`Try: ${correctedCommand(command, args, typo)}`] : []),
|
|
132
178
|
],
|
|
133
179
|
},
|
|
134
180
|
};
|
package/dist/cli/ui.js
CHANGED
|
@@ -374,6 +374,8 @@ export function planStatusWord(status, p) {
|
|
|
374
374
|
return p.yellow(status);
|
|
375
375
|
if (status === "approved" || status === "applied")
|
|
376
376
|
return p.green(status);
|
|
377
|
+
if (status === "applying")
|
|
378
|
+
return p.yellow(status);
|
|
377
379
|
if (status === "rejected")
|
|
378
380
|
return p.red(status);
|
|
379
381
|
return status;
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@ import { activeProfile, listProfiles, setActiveProfile } from "./credentials.js"
|
|
|
2
2
|
import { capabilitiesCommand, printCommandHelpJson, robotDocsCommand, unknownCommandEnvelope } from "./cli/capabilities.js";
|
|
3
3
|
import { BESPOKE_HELP, commandHelp, HELP, shortUsage, stylizeShortUsage, usage } from "./cli/help.js";
|
|
4
4
|
import { readPackageInfo } from "./cli/shared.js";
|
|
5
|
-
import { correctedCommand, detectFlagTypo, suggestCommand, unknownFlagEnvelope } from "./cli/suggest.js";
|
|
5
|
+
import { correctedCommand, detectFlagTypo, detectUnknownFlag, suggestCommand, unknownFlagEnvelope } from "./cli/suggest.js";
|
|
6
6
|
import { colorEnabled, paint } from "./cli/ui.js";
|
|
7
7
|
// Verb modules load lazily inside their dispatch branch below. The dispatcher
|
|
8
8
|
// used to import all of them eagerly, so `--version` compiled the full
|
|
@@ -56,16 +56,22 @@ export async function runCli(argv) {
|
|
|
56
56
|
}
|
|
57
57
|
if (command === "help") {
|
|
58
58
|
const [topic] = args;
|
|
59
|
-
if (topic && topic
|
|
59
|
+
if (topic && !topic.startsWith("-") && args.includes("--json")) {
|
|
60
60
|
// `help <cmd> --json` → machine-readable help derived from the same
|
|
61
|
-
// HELP entry the plain text renders from.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
// HELP entry the plain text renders from. JSON takes precedence over
|
|
62
|
+
// --full so automated callers get a stable shape.
|
|
63
|
+
printCommandHelpJson(topic);
|
|
64
|
+
}
|
|
65
|
+
else if (args.includes("--full")) {
|
|
66
|
+
// --full is the global escape hatch to the complete reference, even
|
|
67
|
+
// when a topic is given (help <cmd> --full), unless --json is requested.
|
|
68
|
+
console.log(usage());
|
|
69
|
+
}
|
|
70
|
+
else if (topic && !topic.startsWith("-")) {
|
|
71
|
+
console.log(commandHelp(topic));
|
|
66
72
|
}
|
|
67
73
|
else {
|
|
68
|
-
console.log(
|
|
74
|
+
console.log(styledShortUsage());
|
|
69
75
|
}
|
|
70
76
|
return;
|
|
71
77
|
}
|
|
@@ -82,7 +88,7 @@ export async function runCli(argv) {
|
|
|
82
88
|
}
|
|
83
89
|
// Commands without bespoke help get focused per-command help on --help
|
|
84
90
|
// instead of executing (audit used to silently run the sample audit) or
|
|
85
|
-
// dumping the whole surface. call/market/enrich/
|
|
91
|
+
// dumping the whole surface. call/market/enrich/schedule print
|
|
86
92
|
// their own richer help. `--full` always escapes to the complete reference.
|
|
87
93
|
if (!BESPOKE_HELP.includes(command) && (args.includes("--help") || args.includes("-h"))) {
|
|
88
94
|
console.log(args.includes("--full") ? usage() : commandHelp(command));
|
|
@@ -90,20 +96,27 @@ export async function runCli(argv) {
|
|
|
90
96
|
}
|
|
91
97
|
// Flag typos used to be silently dropped by the per-verb parsers —
|
|
92
98
|
// `audit --demo --jsn` exited 0 and printed markdown where the agent asked
|
|
93
|
-
// for JSON.
|
|
94
|
-
// help.ts
|
|
95
|
-
// auto-execute the correction, so a typo can never
|
|
96
|
-
// write-shaped invocation stages.
|
|
99
|
+
// for JSON. Every flag-shaped token is now checked against the per-command
|
|
100
|
+
// registry in help.ts, so unknown flags fail closed instead of being ignored.
|
|
101
|
+
// Suggest-only: never auto-execute the correction, so a typo can never
|
|
102
|
+
// silently change what a write-shaped invocation stages.
|
|
97
103
|
if (command in HELP) {
|
|
98
|
-
const typo =
|
|
104
|
+
const typo = detectUnknownFlag(command, args);
|
|
99
105
|
if (typo) {
|
|
100
106
|
if (args.includes("--json")) {
|
|
101
107
|
console.log(JSON.stringify(unknownFlagEnvelope(command, args, typo), null, 2));
|
|
102
108
|
}
|
|
103
109
|
else {
|
|
110
|
+
console.error(`Unknown flag for ${command}: ${typo.given}`);
|
|
111
|
+
// Keep the old near-miss shape too; existing agents key off it.
|
|
104
112
|
console.error(`Unknown flag: ${typo.given}`);
|
|
105
|
-
|
|
106
|
-
|
|
113
|
+
if (typo.suggestion) {
|
|
114
|
+
console.error(`Did you mean ${typo.suggestion}?`);
|
|
115
|
+
console.error(`Did you mean: ${typo.suggestion}`);
|
|
116
|
+
if (typo.replacement.length > 0) {
|
|
117
|
+
console.error(`Try: ${correctedCommand(command, args, typo)}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
107
120
|
}
|
|
108
121
|
process.exitCode = 1;
|
|
109
122
|
return;
|
|
@@ -161,6 +174,18 @@ export async function runCli(argv) {
|
|
|
161
174
|
await (await import("./cli/fix.js")).resolveCommand(args);
|
|
162
175
|
return;
|
|
163
176
|
}
|
|
177
|
+
if (command === "route") {
|
|
178
|
+
await (await import("./cli/fix.js")).routeCommand(args);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (command === "hierarchy") {
|
|
182
|
+
await (await import("./cli/fix.js")).hierarchyCommand(args);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (command === "relationships") {
|
|
186
|
+
await (await import("./cli/fix.js")).relationshipsCommand(args);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
164
189
|
if (command === "bulk-update") {
|
|
165
190
|
await (await import("./cli/fix.js")).bulkUpdateCommand(args);
|
|
166
191
|
return;
|
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
|
-
|
|
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;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type FieldMappings } from "../mappings.ts";
|
|
2
2
|
import type { GtmConnector, SnapshotProgress } from "../types.ts";
|
|
3
3
|
import { type ProgressEmitter } from "../progress.ts";
|
|
4
|
+
export type HubspotWritableConnector = GtmConnector & Required<Pick<GtmConnector, "applyOperation" | "readField">>;
|
|
4
5
|
export type HubspotConnectorOptions = {
|
|
5
6
|
/** Returns a HubSpot access token (private app token or OAuth access token). */
|
|
6
7
|
getAccessToken: () => string | Promise<string>;
|
|
@@ -26,4 +27,4 @@ export type HubspotConnectorOptions = {
|
|
|
26
27
|
* amountless deals — so audit rules can surface the gaps instead of hiding
|
|
27
28
|
* them.
|
|
28
29
|
*/
|
|
29
|
-
export declare function createHubspotConnector(options: HubspotConnectorOptions):
|
|
30
|
+
export declare function createHubspotConnector(options: HubspotConnectorOptions): HubspotWritableConnector;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ProviderHttpError } from "../providerError.js";
|
|
1
2
|
function splitName(full) {
|
|
2
3
|
if (!full)
|
|
3
4
|
return {};
|
|
@@ -26,7 +27,7 @@ export async function fetchExploriumProspects(opts) {
|
|
|
26
27
|
body: JSON.stringify({ mode: "full", size, page_size: size, page: 1, filters: opts.filters }),
|
|
27
28
|
});
|
|
28
29
|
if (!response.ok) {
|
|
29
|
-
throw new
|
|
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) => ({
|
|
@@ -58,7 +59,7 @@ export async function fetchPipe0CrustdataProspects(opts) {
|
|
|
58
59
|
}),
|
|
59
60
|
});
|
|
60
61
|
if (!response.ok) {
|
|
61
|
-
throw new
|
|
62
|
+
throw new ProviderHttpError("pipe0", "prospect search", response.status);
|
|
62
63
|
}
|
|
63
64
|
const body = (await response.json());
|
|
64
65
|
// Surface upstream provider errors (e.g. CreditBalanceInsufficient) instead of
|
|
@@ -119,7 +120,7 @@ export async function probeExploriumBusinessCount(opts) {
|
|
|
119
120
|
body: JSON.stringify({ mode: "full", page_size: 1, page: 1, filters: opts.filters }),
|
|
120
121
|
});
|
|
121
122
|
if (!response.ok) {
|
|
122
|
-
throw new
|
|
123
|
+
throw new ProviderHttpError("Explorium", "business count", response.status);
|
|
123
124
|
}
|
|
124
125
|
const body = (await response.json());
|
|
125
126
|
const total = body.total_results;
|
|
@@ -293,14 +294,6 @@ function fieldValue(field) {
|
|
|
293
294
|
const v = field?.value;
|
|
294
295
|
return typeof v === "string" && v.trim() ? v : undefined;
|
|
295
296
|
}
|
|
296
|
-
async function safeText(response) {
|
|
297
|
-
try {
|
|
298
|
-
return (await response.text()).slice(0, 300);
|
|
299
|
-
}
|
|
300
|
-
catch {
|
|
301
|
-
return "";
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
297
|
// ---------------------------------------------------------------------------
|
|
305
298
|
// Pre-email dedup: identity keys shared between prospects and CRM contacts, so
|
|
306
299
|
// we can drop already-in-CRM / already-seen people BEFORE paying for emails.
|
|
@@ -6,6 +6,7 @@ export type SalesforceConnection = {
|
|
|
6
6
|
/** e.g. https://yourorg.my.salesforce.com */
|
|
7
7
|
instanceUrl: string;
|
|
8
8
|
};
|
|
9
|
+
export type SalesforceWritableConnector = GtmConnector & Required<Pick<GtmConnector, "applyOperation" | "readField">>;
|
|
9
10
|
export type SalesforceConnectorOptions = {
|
|
10
11
|
/** Returns an access token plus the instance URL it belongs to. */
|
|
11
12
|
getConnection: () => SalesforceConnection | Promise<SalesforceConnection>;
|
|
@@ -32,4 +33,4 @@ export type SalesforceConnectorOptions = {
|
|
|
32
33
|
* surface the gaps). Probabilities are normalized to 0..1 to match the
|
|
33
34
|
* canonical model.
|
|
34
35
|
*/
|
|
35
|
-
export declare function createSalesforceConnector(options: SalesforceConnectorOptions):
|
|
36
|
+
export declare function createSalesforceConnector(options: SalesforceConnectorOptions): SalesforceWritableConnector;
|
|
@@ -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
|
|
101
|
-
|
|
102
|
-
|
|
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 ${
|
|
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 = `${
|
|
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
|
|
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
|
|
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(`${
|
|
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 ${
|
|
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) {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { readFileSync, statSync } from "node:fs";
|
|
22
22
|
import { resolve } from "node:path";
|
|
23
|
+
import { FREE_EMAIL_DOMAINS } from "../freeEmailDomains.js";
|
|
23
24
|
import { SIGNAL_BUCKETS } from "../signals.js";
|
|
24
25
|
import { parseSpoolText, spoolFilesIn } from "../spoolFiles.js";
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
@@ -243,22 +244,12 @@ function fieldValue(values, name) {
|
|
|
243
244
|
}
|
|
244
245
|
return "";
|
|
245
246
|
}
|
|
246
|
-
const FREE_MAIL = new Set([
|
|
247
|
-
"gmail.com",
|
|
248
|
-
"yahoo.com",
|
|
249
|
-
"hotmail.com",
|
|
250
|
-
"outlook.com",
|
|
251
|
-
"icloud.com",
|
|
252
|
-
"aol.com",
|
|
253
|
-
"proton.me",
|
|
254
|
-
"protonmail.com",
|
|
255
|
-
]);
|
|
256
247
|
/** Company domain from an email, or "" for free-mail / no email. */
|
|
257
248
|
function corporateDomain(email) {
|
|
258
249
|
if (!email.includes("@"))
|
|
259
250
|
return "";
|
|
260
251
|
const domain = email.split("@").at(-1).trim().toLowerCase();
|
|
261
|
-
if (!domain ||
|
|
252
|
+
if (!domain || FREE_EMAIL_DOMAINS.has(domain))
|
|
262
253
|
return "";
|
|
263
254
|
return domain;
|
|
264
255
|
}
|
|
@@ -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
|
|
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
|
|
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 ?? [];
|