fullstackgtm 0.48.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 +38 -0
- package/CONTRIBUTING.md +23 -4
- package/DATA-FLOWS.md +7 -2
- package/INSTALL_FOR_AGENTS.md +13 -4
- package/README.md +12 -1
- package/SECURITY.md +27 -3
- package/dist/cli/audit.js +10 -7
- package/dist/cli/auth.js +8 -4
- package/dist/cli/fix.js +20 -7
- package/dist/cli/help.js +10 -6
- package/dist/cli/market.js +180 -2
- package/dist/cli/plans.js +56 -5
- package/dist/cli/ui.js +2 -0
- package/dist/cli.js +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +23 -2
- package/dist/connectors/prospectSources.js +4 -11
- 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/theirstack.d.ts +0 -19
- package/dist/connectors/theirstack.js +3 -10
- package/dist/credentials.js +36 -26
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- 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 +67 -115
- 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/secureFile.d.ts +15 -0
- package/dist/secureFile.js +164 -0
- package/dist/types.d.ts +1 -1
- package/docs/api.md +29 -2
- package/docs/architecture.md +13 -2
- package/llms.txt +7 -0
- package/package.json +5 -3
- package/skills/fullstackgtm/SKILL.md +7 -1
- package/src/cli/audit.ts +10 -7
- package/src/cli/auth.ts +8 -4
- package/src/cli/fix.ts +28 -7
- package/src/cli/help.ts +10 -6
- package/src/cli/market.ts +181 -2
- package/src/cli/plans.ts +66 -5
- package/src/cli/ui.ts +1 -0
- package/src/cli.ts +1 -1
- package/src/config.ts +39 -1
- package/src/connectors/prospectSources.ts +4 -11
- package/src/connectors/salesforce.ts +12 -5
- package/src/connectors/salesforceAuth.ts +46 -6
- package/src/connectors/theirstack.ts +3 -10
- package/src/credentials.ts +47 -28
- package/src/index.ts +4 -0
- package/src/market.ts +7 -111
- package/src/marketClassify.ts +61 -1
- package/src/marketSourcing.ts +7 -43
- package/src/mcp.ts +93 -136
- package/src/planStore.ts +235 -57
- package/src/providerError.ts +37 -0
- package/src/publicHttp.ts +147 -0
- package/src/secureFile.ts +169 -0
- package/src/types.ts +1 -0
|
@@ -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";
|
|
@@ -152,13 +153,17 @@ export function createSalesforceConnector(
|
|
|
152
153
|
|
|
153
154
|
async function request(path: string, init: RequestInit = {}): Promise<any> {
|
|
154
155
|
const connection = await options.getConnection();
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
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;
|
|
158
162
|
let response: Response;
|
|
159
163
|
try {
|
|
160
164
|
response = await fetchImpl(url, {
|
|
161
165
|
...init,
|
|
166
|
+
redirect: "manual",
|
|
162
167
|
headers: {
|
|
163
168
|
Authorization: `Bearer ${connection.accessToken}`,
|
|
164
169
|
"Content-Type": "application/json",
|
|
@@ -168,7 +173,7 @@ export function createSalesforceConnector(
|
|
|
168
173
|
} catch (error) {
|
|
169
174
|
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
170
175
|
throw new Error(
|
|
171
|
-
`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.`,
|
|
172
177
|
);
|
|
173
178
|
}
|
|
174
179
|
if (!response.ok) {
|
|
@@ -644,8 +649,9 @@ export function createSalesforceConnector(
|
|
|
644
649
|
loserIds: string[],
|
|
645
650
|
): Promise<{ ok: boolean; detail?: string }> {
|
|
646
651
|
const connection = await options.getConnection();
|
|
652
|
+
const instanceUrl = validateSalesforceOrigin(connection.instanceUrl, "Salesforce connection instance URL");
|
|
647
653
|
const version = apiVersion.replace(/^v/, ""); // SOAP path uses "59.0", not "v59.0"
|
|
648
|
-
const url = `${
|
|
654
|
+
const url = `${instanceUrl}/services/Soap/u/${version}`;
|
|
649
655
|
const toMerge = loserIds.map((id) => `<urn:recordToMergeIds>${escapeXml(id)}</urn:recordToMergeIds>`).join("");
|
|
650
656
|
const envelope =
|
|
651
657
|
`<?xml version="1.0" encoding="UTF-8"?>` +
|
|
@@ -659,6 +665,7 @@ export function createSalesforceConnector(
|
|
|
659
665
|
try {
|
|
660
666
|
response = await fetchImpl(url, {
|
|
661
667
|
method: "POST",
|
|
668
|
+
redirect: "manual",
|
|
662
669
|
headers: { "Content-Type": "text/xml; charset=UTF-8", SOAPAction: '""' },
|
|
663
670
|
body: envelope,
|
|
664
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) {
|
|
@@ -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 ?? [];
|
package/src/credentials.ts
CHANGED
|
@@ -3,17 +3,20 @@ import {
|
|
|
3
3
|
existsSync,
|
|
4
4
|
mkdirSync,
|
|
5
5
|
readdirSync,
|
|
6
|
-
readFileSync,
|
|
7
|
-
statSync,
|
|
8
6
|
unlinkSync,
|
|
9
|
-
writeFileSync,
|
|
10
7
|
} from "node:fs";
|
|
11
8
|
import { createHash } from "node:crypto";
|
|
12
9
|
import { homedir } from "node:os";
|
|
13
10
|
import { join } from "node:path";
|
|
14
11
|
import { refreshHubspotToken } from "./connectors/hubspotAuth.ts";
|
|
15
|
-
import { refreshSalesforceToken } from "./connectors/salesforceAuth.ts";
|
|
12
|
+
import { refreshSalesforceToken, validateSalesforceOrigin } from "./connectors/salesforceAuth.ts";
|
|
16
13
|
import { detectKeychainBackend } from "./keychain.ts";
|
|
14
|
+
import {
|
|
15
|
+
assertNoSymlinkComponents,
|
|
16
|
+
readSecureRegularFile,
|
|
17
|
+
UnsafeManagedPathError,
|
|
18
|
+
writeSecureFileAtomic,
|
|
19
|
+
} from "./secureFile.ts";
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
22
|
* Local CLI credential store: ~/.fullstackgtm/credentials.json (0600), or
|
|
@@ -126,7 +129,9 @@ export function ensureSecureHomeDir(): string {
|
|
|
126
129
|
const levels =
|
|
127
130
|
dir === baseHomeDir() ? [dir] : [baseHomeDir(), join(baseHomeDir(), "profiles"), dir];
|
|
128
131
|
for (const level of levels) {
|
|
132
|
+
assertNoSymlinkComponents(level);
|
|
129
133
|
mkdirSync(level, { recursive: true, mode: 0o700 });
|
|
134
|
+
assertNoSymlinkComponents(level);
|
|
130
135
|
try {
|
|
131
136
|
chmodSync(level, 0o700);
|
|
132
137
|
} catch {
|
|
@@ -138,12 +143,7 @@ export function ensureSecureHomeDir(): string {
|
|
|
138
143
|
|
|
139
144
|
/** Write a 0600 file under the home, enforcing the mode even on rewrite. */
|
|
140
145
|
export function writeSecureFile(path: string, contents: string) {
|
|
141
|
-
|
|
142
|
-
try {
|
|
143
|
-
chmodSync(path, 0o600);
|
|
144
|
-
} catch {
|
|
145
|
-
// Non-POSIX filesystems ignore chmod.
|
|
146
|
-
}
|
|
146
|
+
writeSecureFileAtomic(path, contents);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
/**
|
|
@@ -153,19 +153,14 @@ export function writeSecureFile(path: string, contents: string) {
|
|
|
153
153
|
* mode on read too — re-tighten to 0600 and warn once — so a world-readable
|
|
154
154
|
* credential store can't sit there silently leaking the token to other users.
|
|
155
155
|
*/
|
|
156
|
-
function
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
} catch {
|
|
167
|
-
// Missing file or non-POSIX filesystem: nothing to enforce.
|
|
168
|
-
}
|
|
156
|
+
function readCredentialFile(path: string): string {
|
|
157
|
+
return readSecureRegularFile(path, {
|
|
158
|
+
tightenMode: 0o600,
|
|
159
|
+
onModeTightened: (mode) => console.error(
|
|
160
|
+
`fullstackgtm: tightened ${path} from ${mode.toString(8).padStart(3, "0")} to 600 ` +
|
|
161
|
+
"(it was readable or writable by other users).",
|
|
162
|
+
),
|
|
163
|
+
});
|
|
169
164
|
}
|
|
170
165
|
|
|
171
166
|
/**
|
|
@@ -190,7 +185,7 @@ function activeKeychain(): { account: string; backend: NonNullable<ReturnType<ty
|
|
|
190
185
|
function migratePlaintextToKeychain(keychain: NonNullable<ReturnType<typeof activeKeychain>>): void {
|
|
191
186
|
if (!existsSync(credentialsPath())) return;
|
|
192
187
|
try {
|
|
193
|
-
const fileParsed = JSON.parse(
|
|
188
|
+
const fileParsed = JSON.parse(readCredentialFile(credentialsPath()));
|
|
194
189
|
if (fileParsed && fileParsed.version === 1 && fileParsed.providers) {
|
|
195
190
|
const current = keychain.backend.get(keychain.account);
|
|
196
191
|
const existing = current ? (JSON.parse(current).providers ?? {}) : {};
|
|
@@ -209,14 +204,15 @@ function readFile(): CredentialsFile {
|
|
|
209
204
|
const keychain = activeKeychain();
|
|
210
205
|
if (keychain) migratePlaintextToKeychain(keychain);
|
|
211
206
|
try {
|
|
212
|
-
const raw = keychain ? keychain.backend.get(keychain.account) : (
|
|
207
|
+
const raw = keychain ? keychain.backend.get(keychain.account) : readCredentialFile(credentialsPath());
|
|
213
208
|
if (raw) {
|
|
214
209
|
const parsed = JSON.parse(raw);
|
|
215
210
|
if (parsed && typeof parsed === "object" && parsed.version === 1 && parsed.providers) {
|
|
216
211
|
return parsed as CredentialsFile;
|
|
217
212
|
}
|
|
218
213
|
}
|
|
219
|
-
} catch {
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (error instanceof UnsafeManagedPathError) throw error;
|
|
220
216
|
// Missing or unreadable store falls through to an empty one.
|
|
221
217
|
}
|
|
222
218
|
return { version: 1, providers: {} };
|
|
@@ -238,6 +234,18 @@ export function getCredential(provider: string): StoredCredential | null {
|
|
|
238
234
|
}
|
|
239
235
|
|
|
240
236
|
export function storeCredential(provider: string, credential: StoredCredential) {
|
|
237
|
+
if (provider === "salesforce") {
|
|
238
|
+
if (!credential.instanceUrl) {
|
|
239
|
+
throw new Error("Salesforce credentials require an instance URL.");
|
|
240
|
+
}
|
|
241
|
+
credential = {
|
|
242
|
+
...credential,
|
|
243
|
+
instanceUrl: validateSalesforceOrigin(credential.instanceUrl, "Salesforce credential instance URL"),
|
|
244
|
+
...(credential.loginUrl
|
|
245
|
+
? { loginUrl: validateSalesforceOrigin(credential.loginUrl, "Salesforce credential login URL") }
|
|
246
|
+
: {}),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
241
249
|
const file = readFile();
|
|
242
250
|
file.providers[provider] = credential;
|
|
243
251
|
persist(file);
|
|
@@ -315,12 +323,19 @@ export async function resolveSalesforceConnection(
|
|
|
315
323
|
"Stored Salesforce credential has no instance URL. Run `fullstackgtm login salesforce` again.",
|
|
316
324
|
);
|
|
317
325
|
}
|
|
326
|
+
const instanceUrl = validateSalesforceOrigin(
|
|
327
|
+
credential.instanceUrl,
|
|
328
|
+
"Stored Salesforce credential instance URL",
|
|
329
|
+
);
|
|
330
|
+
if (credential.loginUrl) {
|
|
331
|
+
validateSalesforceOrigin(credential.loginUrl, "Stored Salesforce credential login URL");
|
|
332
|
+
}
|
|
318
333
|
const needsRefresh =
|
|
319
334
|
credential.kind === "oauth" &&
|
|
320
335
|
credential.expiresAt !== undefined &&
|
|
321
336
|
Date.now() > credential.expiresAt - REFRESH_SKEW_MS;
|
|
322
337
|
if (!needsRefresh) {
|
|
323
|
-
return { accessToken: credential.accessToken, instanceUrl
|
|
338
|
+
return { accessToken: credential.accessToken, instanceUrl };
|
|
324
339
|
}
|
|
325
340
|
if (!credential.refreshToken || !credential.clientId) {
|
|
326
341
|
throw new Error(
|
|
@@ -349,9 +364,13 @@ export async function resolveSalesforceConnection(
|
|
|
349
364
|
if (!minted.instanceUrl) {
|
|
350
365
|
throw new Error("The hosted deployment returned no Salesforce instance URL.");
|
|
351
366
|
}
|
|
367
|
+
const instanceUrl = validateSalesforceOrigin(
|
|
368
|
+
minted.instanceUrl,
|
|
369
|
+
"Hosted Salesforce credential instance URL",
|
|
370
|
+
);
|
|
352
371
|
return {
|
|
353
372
|
accessToken: minted.accessToken,
|
|
354
|
-
instanceUrl
|
|
373
|
+
instanceUrl,
|
|
355
374
|
fieldMappings: minted.fieldMappings,
|
|
356
375
|
};
|
|
357
376
|
}
|
package/src/index.ts
CHANGED
|
@@ -38,8 +38,10 @@ export {
|
|
|
38
38
|
loadConfig,
|
|
39
39
|
mergePolicy,
|
|
40
40
|
resolveConfiguredRules,
|
|
41
|
+
rulePackageTrustFromCli,
|
|
41
42
|
type FullstackgtmConfig,
|
|
42
43
|
type LoadedConfig,
|
|
44
|
+
type RulePackageTrust,
|
|
43
45
|
} from "./config.ts";
|
|
44
46
|
export { applyPatchPlan, type ApplyPatchPlanOptions } from "./connector.ts";
|
|
45
47
|
export {
|
|
@@ -76,6 +78,7 @@ export {
|
|
|
76
78
|
pollSalesforceDeviceLogin,
|
|
77
79
|
refreshSalesforceToken,
|
|
78
80
|
startSalesforceDeviceLogin,
|
|
81
|
+
validateSalesforceOrigin,
|
|
79
82
|
validateSalesforceToken,
|
|
80
83
|
type SalesforceDeviceAuthorization,
|
|
81
84
|
type SalesforceTokenSet,
|
|
@@ -575,3 +578,4 @@ export type {
|
|
|
575
578
|
RiskLevel,
|
|
576
579
|
SourceFreshness,
|
|
577
580
|
} from "./types.ts";
|
|
581
|
+
export { ProviderHttpError } from "./providerError.ts";
|
package/src/market.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { lookup } from "node:dns/promises";
|
|
3
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { isIP } from "node:net";
|
|
5
3
|
import { join } from "node:path";
|
|
6
4
|
import { credentialsDir } from "./credentials.ts";
|
|
7
5
|
import { MARKET_CAPTURE_STAGES, nullProgressEmitter, type ProgressEmitter } from "./progress.ts";
|
|
8
6
|
import type { GtmEvidence } from "./types.ts";
|
|
7
|
+
import { assertPublicUrl, publicHttpGet } from "./publicHttp.ts";
|
|
8
|
+
export { assertPublicUrl } from "./publicHttp.ts";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* The Market Map: a live model of the competitive category a company sells
|
|
@@ -328,121 +328,17 @@ export type FetchPage = (url: string) => Promise<{ status: number; body: string
|
|
|
328
328
|
* any host that is or resolves to a private/loopback/link-local/metadata
|
|
329
329
|
* address, and (3) follow redirects manually, re-validating each hop.
|
|
330
330
|
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
* competitor pages; a hardened deployment should fetch through an egress proxy.
|
|
331
|
+
* The default transport resolves once, rejects mixed public/private answers,
|
|
332
|
+
* and pins a validated address into socket creation to prevent DNS rebinding.
|
|
334
333
|
*/
|
|
335
334
|
const MAX_REDIRECTS = 5;
|
|
336
335
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
337
336
|
const MAX_BODY_BYTES = 5_000_000;
|
|
338
337
|
|
|
339
|
-
function ipv4IsPrivate(ip: string): boolean {
|
|
340
|
-
const parts = ip.split(".").map((n) => Number(n));
|
|
341
|
-
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
|
|
342
|
-
const [a, b] = parts;
|
|
343
|
-
if (a === 0 || a === 127) return true; // this-host, loopback
|
|
344
|
-
if (a === 10) return true; // private
|
|
345
|
-
if (a === 172 && b >= 16 && b <= 31) return true; // private
|
|
346
|
-
if (a === 192 && b === 168) return true; // private
|
|
347
|
-
if (a === 169 && b === 254) return true; // link-local incl. 169.254.169.254 metadata
|
|
348
|
-
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
|
349
|
-
if (a >= 224) return true; // multicast / reserved
|
|
350
|
-
return false;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function ipIsPrivate(ip: string): boolean {
|
|
354
|
-
const family = isIP(ip);
|
|
355
|
-
if (family === 4) return ipv4IsPrivate(ip);
|
|
356
|
-
if (family === 6) {
|
|
357
|
-
const lower = ip.toLowerCase();
|
|
358
|
-
if (lower === "::1" || lower === "::") return true; // loopback / unspecified
|
|
359
|
-
// IPv4-mapped (::ffff:…) — Node normalizes ::ffff:127.0.0.1 to ::ffff:7f00:1,
|
|
360
|
-
// so accept both the dotted and the hex-pair forms, unwrap, check the v4.
|
|
361
|
-
const mapped = lower.match(/^::ffff:(.+)$/);
|
|
362
|
-
if (mapped) {
|
|
363
|
-
const rest = mapped[1];
|
|
364
|
-
if (rest.includes(".")) return ipv4IsPrivate(rest);
|
|
365
|
-
const groups = rest.split(":");
|
|
366
|
-
if (groups.length === 2) {
|
|
367
|
-
const hi = parseInt(groups[0], 16);
|
|
368
|
-
const lo = parseInt(groups[1], 16);
|
|
369
|
-
if (Number.isNaN(hi) || Number.isNaN(lo)) return true;
|
|
370
|
-
return ipv4IsPrivate(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
|
|
371
|
-
}
|
|
372
|
-
return true; // unrecognized mapped form → refuse
|
|
373
|
-
}
|
|
374
|
-
if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true; // link-local fe80::/10
|
|
375
|
-
if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // unique-local fc00::/7
|
|
376
|
-
return false;
|
|
377
|
-
}
|
|
378
|
-
return true; // not a recognizable IP literal → refuse
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
export async function assertPublicUrl(rawUrl: string): Promise<URL> {
|
|
382
|
-
let url: URL;
|
|
383
|
-
try {
|
|
384
|
-
url = new URL(rawUrl);
|
|
385
|
-
} catch {
|
|
386
|
-
throw new Error(`market capture: "${rawUrl}" is not a valid URL.`);
|
|
387
|
-
}
|
|
388
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
389
|
-
throw new Error(`market capture refuses ${url.protocol} URLs (only http/https): ${rawUrl}`);
|
|
390
|
-
}
|
|
391
|
-
const host = url.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
|
|
392
|
-
if (isIP(host)) {
|
|
393
|
-
if (ipIsPrivate(host)) throw new Error(`market capture refuses private/loopback address ${host} (SSRF guard).`);
|
|
394
|
-
return url;
|
|
395
|
-
}
|
|
396
|
-
// Hostname: resolve and refuse if ANY address is private.
|
|
397
|
-
const addrs = await lookup(host, { all: true });
|
|
398
|
-
for (const { address } of addrs) {
|
|
399
|
-
if (ipIsPrivate(address)) {
|
|
400
|
-
throw new Error(`market capture refuses ${host} — it resolves to private/internal address ${address} (SSRF guard).`);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
return url;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
338
|
const defaultFetchPage: FetchPage = async (url) => {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const controller = new AbortController();
|
|
411
|
-
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
412
|
-
let response: Response;
|
|
413
|
-
try {
|
|
414
|
-
response = await fetch(current, {
|
|
415
|
-
headers: {
|
|
416
|
-
"User-Agent": "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)",
|
|
417
|
-
"Accept-Language": "en-US",
|
|
418
|
-
},
|
|
419
|
-
redirect: "manual",
|
|
420
|
-
signal: controller.signal,
|
|
421
|
-
});
|
|
422
|
-
} finally {
|
|
423
|
-
clearTimeout(timer);
|
|
424
|
-
}
|
|
425
|
-
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
|
|
426
|
-
current = new URL(response.headers.get("location") as string, current).toString();
|
|
427
|
-
continue; // re-validate the redirect target on the next iteration
|
|
428
|
-
}
|
|
429
|
-
const reader = response.body?.getReader();
|
|
430
|
-
if (!reader) return { status: response.status, body: await response.text() };
|
|
431
|
-
const chunks: Uint8Array[] = [];
|
|
432
|
-
let total = 0;
|
|
433
|
-
for (;;) {
|
|
434
|
-
const { done, value } = await reader.read();
|
|
435
|
-
if (done) break;
|
|
436
|
-
total += value.length;
|
|
437
|
-
if (total > MAX_BODY_BYTES) {
|
|
438
|
-
await reader.cancel();
|
|
439
|
-
break;
|
|
440
|
-
}
|
|
441
|
-
chunks.push(value);
|
|
442
|
-
}
|
|
443
|
-
return { status: response.status, body: Buffer.concat(chunks).toString("utf8") };
|
|
444
|
-
}
|
|
445
|
-
throw new Error(`market capture: too many redirects (>${MAX_REDIRECTS}) for ${url}`);
|
|
339
|
+
const response = await publicHttpGet(url, { maxBytes: MAX_BODY_BYTES, maxRedirects: MAX_REDIRECTS,
|
|
340
|
+
timeoutMs: FETCH_TIMEOUT_MS, headers: { "User-Agent": "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)", "Accept-Language": "en-US" } });
|
|
341
|
+
return { status: response.status, body: Buffer.from(response.body).toString("utf8") };
|
|
446
342
|
};
|
|
447
343
|
|
|
448
344
|
export type CaptureOptions = {
|
package/src/marketClassify.ts
CHANGED
|
@@ -276,9 +276,68 @@ export type MarketWorksheet = {
|
|
|
276
276
|
vendor: { id: string; name: string };
|
|
277
277
|
claims: MarketClaim[];
|
|
278
278
|
pages: Array<{ kind: CaptureEntry["kind"]; url: string; captureHash: string; text: string }>;
|
|
279
|
+
/** Deterministic retrieval hints: page lines that contain claim terms. These are not labels. */
|
|
280
|
+
claimHints: Array<{
|
|
281
|
+
claimId: string;
|
|
282
|
+
matches: Array<{ url: string; captureHash: string; term: string; quote: string }>;
|
|
283
|
+
}>;
|
|
279
284
|
instructions: string;
|
|
280
285
|
};
|
|
281
286
|
|
|
287
|
+
const HINT_STOPWORDS = new Set([
|
|
288
|
+
"the", "and", "for", "with", "from", "that", "this", "into", "your", "you", "are", "but", "not", "when", "how", "use", "uses", "using",
|
|
289
|
+
"claim", "claims", "vendor", "vendors", "page", "pages", "loud", "quiet", "absent", "capability", "specific", "differentiating",
|
|
290
|
+
"unknown", "buyer", "buyers", "pricing", "structure", "category", "support", "supported", "primary", "secondary", "mentioned", "mentions",
|
|
291
|
+
]);
|
|
292
|
+
|
|
293
|
+
function normalizeHintText(value: string): string {
|
|
294
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().replace(/\s+/g, " ");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function claimHintTerms(claim: MarketClaim): string[] {
|
|
298
|
+
const exact = [claim.id.replace(/[-_]+/g, " "), claim.capability, ...(claim.terms ?? [])]
|
|
299
|
+
.map(normalizeHintText)
|
|
300
|
+
.filter((term) => term.length >= 3);
|
|
301
|
+
const tokenSource = normalizeHintText(`${claim.capability} ${claim.definition} ${(claim.terms ?? []).join(" ")}`)
|
|
302
|
+
.split(" ")
|
|
303
|
+
.filter((term) => term.length >= 4 && !HINT_STOPWORDS.has(term));
|
|
304
|
+
return Array.from(new Set([...exact, ...tokenSource])).slice(0, 24);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function lineSnippet(line: string, term: string): string {
|
|
308
|
+
const clean = line.trim().replace(/\s+/g, " ");
|
|
309
|
+
if (clean.length <= 300) return clean;
|
|
310
|
+
const lower = clean.toLowerCase();
|
|
311
|
+
const idx = lower.indexOf(term.toLowerCase().split(" ")[0] ?? "");
|
|
312
|
+
const start = Math.max(0, idx < 0 ? 0 : idx - 120);
|
|
313
|
+
return clean.slice(start, start + 300).trim();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function buildClaimHints(
|
|
317
|
+
claims: MarketClaim[],
|
|
318
|
+
pages: Array<{ kind: CaptureEntry["kind"]; url: string; captureHash: string; text: string }>,
|
|
319
|
+
): MarketWorksheet["claimHints"] {
|
|
320
|
+
return claims.map((claim) => {
|
|
321
|
+
const terms = claimHintTerms(claim);
|
|
322
|
+
const matches: Array<{ url: string; captureHash: string; term: string; quote: string }> = [];
|
|
323
|
+
for (const page of pages) {
|
|
324
|
+
const lines = page.text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
325
|
+
for (const term of terms) {
|
|
326
|
+
const normalizedTerm = normalizeHintText(term);
|
|
327
|
+
const line = lines.find((candidate) => normalizeHintText(candidate).includes(normalizedTerm));
|
|
328
|
+
if (!line) continue;
|
|
329
|
+
const quote = lineSnippet(line, term);
|
|
330
|
+
if (quote && !matches.some((m) => m.quote === quote && m.url === page.url)) {
|
|
331
|
+
matches.push({ url: page.url, captureHash: page.captureHash, term, quote });
|
|
332
|
+
}
|
|
333
|
+
if (matches.length >= 4) break;
|
|
334
|
+
}
|
|
335
|
+
if (matches.length >= 4) break;
|
|
336
|
+
}
|
|
337
|
+
return { claimId: claim.id, matches };
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
282
341
|
export function buildWorksheet(
|
|
283
342
|
config: MarketConfig,
|
|
284
343
|
vendorId: string,
|
|
@@ -304,7 +363,8 @@ export function buildWorksheet(
|
|
|
304
363
|
vendor: { id: vendor.id, name: vendor.name },
|
|
305
364
|
claims: config.claims,
|
|
306
365
|
pages,
|
|
366
|
+
claimHints: buildClaimHints(config.claims, pages),
|
|
307
367
|
instructions:
|
|
308
|
-
"Produce one observation per claim
|
|
368
|
+
"Produce one observation per claim from these pages only. Intensity rubric: loud = hero copy, primary positioning, or a named/dedicated product area; quiet = supported by a verbatim page quote but secondary, qualified, or merely available; absent = the pages do not support the claim; unobservable = no usable captured page exists for this vendor, not a synonym for absent. claimHints are deterministic retrieval hints only: use them to find candidate support, but do not mark a claim loud/quiet unless the quote truly supports the claim. Every loud/quiet reading must quote a verbatim span (≤300 chars) from a page's text, with that page's url and captureHash in evidence metadata. Submit as an ObservationSet via `market observe --from <file>` — quotes are mechanically verified against the captures.",
|
|
309
369
|
};
|
|
310
370
|
}
|