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/src/cli/suggest.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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.
|
|
68
|
-
|
|
69
|
-
|
|
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(
|
|
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/
|
|
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.
|
|
98
|
-
// help.ts
|
|
99
|
-
// auto-execute the correction, so a typo can never
|
|
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 =
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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):
|
|
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
|
|
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
|
|
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
|
|
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
|
-
):
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
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 ${
|
|
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 = `${
|
|
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
|
|
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
|
|
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
|
-
`${
|
|
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 ${
|
|
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 ||
|
|
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
|
|
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
|
|
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 ?? [];
|