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/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/format.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { PatchPlan, PatchPlanRun } from "./types.ts";
|
|
2
2
|
|
|
3
|
-
// `summary: true` renders
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// where you approve specific operations — keep every operation's detail.
|
|
3
|
+
// `summary: true` renders the compact audit view: header, findings summary,
|
|
4
|
+
// assumptions/open questions, and an operation count. Defaults to the full view
|
|
5
|
+
// so write-preview callers (bulk-update, fix, dedupe, plans show, MCP) — where
|
|
6
|
+
// you approve specific operations — keep every operation's detail.
|
|
8
7
|
export function patchPlanToMarkdown(plan: PatchPlan, opts: { summary?: boolean } = {}) {
|
|
9
8
|
const lines = [
|
|
10
9
|
`# ${plan.title}`,
|
|
@@ -29,6 +28,8 @@ export function patchPlanToMarkdown(plan: PatchPlan, opts: { summary?: boolean }
|
|
|
29
28
|
lines.push("");
|
|
30
29
|
}
|
|
31
30
|
|
|
31
|
+
appendAssumptionsAndDecisionPoints(lines, plan);
|
|
32
|
+
|
|
32
33
|
if (opts.summary) {
|
|
33
34
|
const ops = plan.operations.length;
|
|
34
35
|
lines.push(
|
|
@@ -155,6 +156,33 @@ export function formatPatchPlanRun(run: PatchPlanRun) {
|
|
|
155
156
|
return `${lines.join("\n")}\n`;
|
|
156
157
|
}
|
|
157
158
|
|
|
159
|
+
|
|
160
|
+
function appendAssumptionsAndDecisionPoints(lines: string[], plan: PatchPlan) {
|
|
161
|
+
const assumptions = [...(plan.assumptions ?? [])].sort((left, right) => {
|
|
162
|
+
if (left.confidence === "guess" && right.confidence !== "guess") return -1;
|
|
163
|
+
if (left.confidence !== "guess" && right.confidence === "guess") return 1;
|
|
164
|
+
return 0;
|
|
165
|
+
});
|
|
166
|
+
const openQuestions = plan.openQuestions ?? [];
|
|
167
|
+
if (assumptions.length === 0 && openQuestions.length === 0) return;
|
|
168
|
+
|
|
169
|
+
lines.push("## Assumptions & decision points", "");
|
|
170
|
+
for (const assumption of assumptions) {
|
|
171
|
+
const warning = assumption.confidence === "guess" ? "⚠️ " : "";
|
|
172
|
+
lines.push(`- ${warning}${assumption.text}${assumption.source ? ` _(source: ${assumption.source})_` : ""}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (openQuestions.length > 0) {
|
|
176
|
+
if (assumptions.length > 0) lines.push("");
|
|
177
|
+
lines.push("### Open questions", "");
|
|
178
|
+
for (const question of openQuestions) {
|
|
179
|
+
lines.push(`- ${question}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
lines.push("");
|
|
184
|
+
}
|
|
185
|
+
|
|
158
186
|
function formatValue(value: unknown) {
|
|
159
187
|
if (value === undefined || value === null || value === "") return "unset";
|
|
160
188
|
if (typeof value === "string") return value;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Shared free-mail domains that should never be treated as company domains.
|
|
2
|
+
export const FREE_EMAIL_DOMAINS = new Set([
|
|
3
|
+
"gmail.com",
|
|
4
|
+
"googlemail.com",
|
|
5
|
+
"yahoo.com",
|
|
6
|
+
"outlook.com",
|
|
7
|
+
"hotmail.com",
|
|
8
|
+
"live.com",
|
|
9
|
+
"icloud.com",
|
|
10
|
+
"me.com",
|
|
11
|
+
"aol.com",
|
|
12
|
+
"proton.me",
|
|
13
|
+
"protonmail.com",
|
|
14
|
+
]);
|
package/src/hierarchy.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { normalizeDomain } from "./merge.ts";
|
|
2
|
+
import type { CanonicalAccount, CanonicalGtmSnapshot } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export type AccountHierarchyNode = {
|
|
5
|
+
accountId: string;
|
|
6
|
+
name: string;
|
|
7
|
+
domain?: string;
|
|
8
|
+
parentAccountId?: string;
|
|
9
|
+
parentReason?: string;
|
|
10
|
+
children: AccountHierarchyNode[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type AccountHierarchyConflict = {
|
|
14
|
+
type: "duplicate_domain" | "ambiguous_parent" | "cycle";
|
|
15
|
+
accountIds: string[];
|
|
16
|
+
key: string;
|
|
17
|
+
detail: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type AccountHierarchyReport = {
|
|
21
|
+
generatedAt: string;
|
|
22
|
+
roots: AccountHierarchyNode[];
|
|
23
|
+
orphanAccounts: string[];
|
|
24
|
+
conflicts: AccountHierarchyConflict[];
|
|
25
|
+
counts: { accounts: number; roots: number; linkedChildren: number; conflicts: number };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function rawString(account: CanonicalAccount, keys: string[]): string | undefined {
|
|
29
|
+
if (!account.raw || typeof account.raw !== "object") return undefined;
|
|
30
|
+
const raw = account.raw as Record<string, unknown>;
|
|
31
|
+
for (const key of keys) {
|
|
32
|
+
const value = raw[key];
|
|
33
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
34
|
+
if (value && typeof value === "object") {
|
|
35
|
+
const nested = value as Record<string, unknown>;
|
|
36
|
+
for (const nestedKey of ["id", "value", "name"]) {
|
|
37
|
+
if (typeof nested[nestedKey] === "string" && String(nested[nestedKey]).trim()) return String(nested[nestedKey]).trim();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parentHint(account: CanonicalAccount): string | undefined {
|
|
45
|
+
return rawString(account, ["parentAccountId", "parent_account_id", "ParentId", "parentCompanyId", "hs_parent_company_id"]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function cloneNode(account: CanonicalAccount): AccountHierarchyNode {
|
|
49
|
+
return { accountId: String(account.id), name: account.name, ...(account.domain ? { domain: account.domain } : {}), children: [] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function looksLikePublicSuffix(domain: string): boolean {
|
|
53
|
+
const parts = domain.split(".");
|
|
54
|
+
if (parts.length !== 2) return false;
|
|
55
|
+
const [label, tld] = parts;
|
|
56
|
+
return tld.length === 2 && ["ac", "co", "com", "edu", "gov", "net", "org"].includes(label);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function domainParent(childDomain: string, domains: Map<string, CanonicalAccount[]>): CanonicalAccount | "ambiguous" | null {
|
|
60
|
+
const parts = childDomain.split(".");
|
|
61
|
+
for (let i = 1; i < parts.length - 1; i++) {
|
|
62
|
+
const candidate = parts.slice(i).join(".");
|
|
63
|
+
if (candidate.split(".").length < 2 || looksLikePublicSuffix(candidate)) continue;
|
|
64
|
+
const unique = new Map((domains.get(candidate) ?? []).map((account) => [String(account.id), account]));
|
|
65
|
+
if (unique.size > 1) return "ambiguous";
|
|
66
|
+
if (unique.size === 1) return [...unique.values()][0];
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function detectCycles(parentByChild: Map<string, { parentId: string; reason: string }>): { accountIds: Set<string>; cycles: string[][] } {
|
|
72
|
+
const cyclic = new Set<string>();
|
|
73
|
+
const cycles: string[][] = [];
|
|
74
|
+
const emitted = new Set<string>();
|
|
75
|
+
|
|
76
|
+
for (const start of parentByChild.keys()) {
|
|
77
|
+
const path: string[] = [];
|
|
78
|
+
const indexById = new Map<string, number>();
|
|
79
|
+
let cursor: string | undefined = start;
|
|
80
|
+
while (cursor && parentByChild.has(cursor)) {
|
|
81
|
+
const seenAt = indexById.get(cursor);
|
|
82
|
+
if (seenAt !== undefined) {
|
|
83
|
+
const cycle = path.slice(seenAt);
|
|
84
|
+
for (const id of cycle) cyclic.add(id);
|
|
85
|
+
const key = [...cycle].sort().join("|");
|
|
86
|
+
if (!emitted.has(key)) {
|
|
87
|
+
emitted.add(key);
|
|
88
|
+
cycles.push(cycle);
|
|
89
|
+
}
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
indexById.set(cursor, path.length);
|
|
93
|
+
path.push(cursor);
|
|
94
|
+
cursor = parentByChild.get(cursor)?.parentId;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { accountIds: cyclic, cycles };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function buildAccountHierarchy(snapshot: CanonicalGtmSnapshot): AccountHierarchyReport {
|
|
102
|
+
const byId = new Map(snapshot.accounts.map((account) => [String(account.id), account]));
|
|
103
|
+
const accounts = [...byId.values()];
|
|
104
|
+
const domains = new Map<string, CanonicalAccount[]>();
|
|
105
|
+
for (const account of accounts) {
|
|
106
|
+
const domain = normalizeDomain(account.domain);
|
|
107
|
+
if (domain) domains.set(domain, [...(domains.get(domain) ?? []), account]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const conflicts: AccountHierarchyConflict[] = [];
|
|
111
|
+
for (const [domain, accounts] of domains) {
|
|
112
|
+
const ids = [...new Set(accounts.map((account) => String(account.id)))];
|
|
113
|
+
if (ids.length > 1) conflicts.push({ type: "duplicate_domain", accountIds: ids.sort(), key: domain, detail: `${ids.length} accounts share domain ${domain}; hierarchy inference will not choose between them.` });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const nodes = new Map(accounts.map((account) => [String(account.id), cloneNode(account)]));
|
|
117
|
+
const parentByChild = new Map<string, { parentId: string; reason: string }>();
|
|
118
|
+
for (const account of accounts) {
|
|
119
|
+
const id = String(account.id);
|
|
120
|
+
const hint = parentHint(account);
|
|
121
|
+
if (hint && byId.has(hint) && hint !== id) {
|
|
122
|
+
parentByChild.set(id, { parentId: hint, reason: "provider-parent" });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const domain = normalizeDomain(account.domain);
|
|
126
|
+
if (!domain) continue;
|
|
127
|
+
const inferred = domainParent(domain, domains);
|
|
128
|
+
if (inferred === "ambiguous") conflicts.push({ type: "ambiguous_parent", accountIds: [id], key: domain, detail: `Multiple possible parent accounts found for ${domain}; leaving account as a root.` });
|
|
129
|
+
else if (inferred && String(inferred.id) !== id) parentByChild.set(id, { parentId: String(inferred.id), reason: "domain-subdomain" });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const cyclicAccounts = detectCycles(parentByChild);
|
|
133
|
+
for (const cycle of cyclicAccounts.cycles) {
|
|
134
|
+
const ordered = [...cycle].sort();
|
|
135
|
+
const key = ordered.join(" -> ");
|
|
136
|
+
conflicts.push({
|
|
137
|
+
type: "cycle",
|
|
138
|
+
accountIds: ordered,
|
|
139
|
+
key,
|
|
140
|
+
detail: `Cyclic parent hints detected among ${ordered.join(", ")}; leaving those accounts as roots instead of hiding them in a loop.`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
for (const accountId of cyclicAccounts.accountIds) parentByChild.delete(accountId);
|
|
144
|
+
|
|
145
|
+
const roots: AccountHierarchyNode[] = [];
|
|
146
|
+
const orphanAccounts: string[] = [];
|
|
147
|
+
const linkedChildren = new Set<string>();
|
|
148
|
+
for (const account of accounts) {
|
|
149
|
+
const id = String(account.id);
|
|
150
|
+
const node = nodes.get(id)!;
|
|
151
|
+
const parent = parentByChild.get(id);
|
|
152
|
+
if (parent && nodes.has(parent.parentId)) {
|
|
153
|
+
node.parentAccountId = parent.parentId;
|
|
154
|
+
node.parentReason = parent.reason;
|
|
155
|
+
nodes.get(parent.parentId)!.children.push(node);
|
|
156
|
+
linkedChildren.add(id);
|
|
157
|
+
} else {
|
|
158
|
+
roots.push(node);
|
|
159
|
+
if (!account.domain && !parentHint(account)) orphanAccounts.push(id);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const sortTree = (list: AccountHierarchyNode[]) => {
|
|
163
|
+
list.sort((a, b) => a.name.localeCompare(b.name) || a.accountId.localeCompare(b.accountId));
|
|
164
|
+
for (const node of list) sortTree(node.children);
|
|
165
|
+
};
|
|
166
|
+
sortTree(roots);
|
|
167
|
+
return { generatedAt: snapshot.generatedAt, roots, orphanAccounts: orphanAccounts.sort(), conflicts, counts: { accounts: accounts.length, roots: roots.length, linkedChildren: linkedChildren.size, conflicts: conflicts.length } };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function accountHierarchyToMarkdown(report: AccountHierarchyReport): string {
|
|
171
|
+
const lines = ["# Account hierarchy", "", `${report.counts.accounts} account(s), ${report.counts.roots} root(s), ${report.counts.linkedChildren} inferred child link(s), ${report.counts.conflicts} conflict(s).`, ""];
|
|
172
|
+
const walk = (nodes: AccountHierarchyNode[], depth: number) => {
|
|
173
|
+
for (const node of nodes) {
|
|
174
|
+
const suffix = [node.domain, node.parentReason ? `via ${node.parentReason}` : undefined].filter(Boolean).join("; ");
|
|
175
|
+
lines.push(`${" ".repeat(depth)}- ${node.name} (${node.accountId}${suffix ? ` — ${suffix}` : ""})`);
|
|
176
|
+
walk(node.children, depth + 1);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
walk(report.roots, 0);
|
|
180
|
+
if (report.conflicts.length) {
|
|
181
|
+
lines.push("", "## Conflicts");
|
|
182
|
+
for (const conflict of report.conflicts) lines.push(`- ${conflict.type} ${conflict.key}: ${conflict.detail}`);
|
|
183
|
+
}
|
|
184
|
+
return lines.join("\n");
|
|
185
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,13 +12,36 @@ export {
|
|
|
12
12
|
export { buildBulkUpdatePlan, isFilterableField, parseWhere, type BulkUpdateOptions } from "./bulkUpdate.ts";
|
|
13
13
|
export { buildDedupePlan, dedupeKey, type DedupeOptions } from "./dedupe.ts";
|
|
14
14
|
export { buildReassignPlans, type ReassignObjectType, type ReassignOptions } from "./reassign.ts";
|
|
15
|
+
export {
|
|
16
|
+
buildLeadRoutePlan,
|
|
17
|
+
type LeadRouteCounts,
|
|
18
|
+
type LeadRouteOptions,
|
|
19
|
+
type LeadRouteResult,
|
|
20
|
+
} from "./route.ts";
|
|
21
|
+
export {
|
|
22
|
+
accountHierarchyToMarkdown,
|
|
23
|
+
buildAccountHierarchy,
|
|
24
|
+
type AccountHierarchyConflict,
|
|
25
|
+
type AccountHierarchyNode,
|
|
26
|
+
type AccountHierarchyReport,
|
|
27
|
+
} from "./hierarchy.ts";
|
|
28
|
+
export {
|
|
29
|
+
buildRelationshipMap,
|
|
30
|
+
relationshipMapToMarkdown,
|
|
31
|
+
type RelationshipMap,
|
|
32
|
+
type StakeholderNode,
|
|
33
|
+
type StakeholderRole,
|
|
34
|
+
type StakeholderSentiment,
|
|
35
|
+
} from "./relationships.ts";
|
|
15
36
|
export {
|
|
16
37
|
CONFIG_FILE_NAME,
|
|
17
38
|
loadConfig,
|
|
18
39
|
mergePolicy,
|
|
19
40
|
resolveConfiguredRules,
|
|
41
|
+
rulePackageTrustFromCli,
|
|
20
42
|
type FullstackgtmConfig,
|
|
21
43
|
type LoadedConfig,
|
|
44
|
+
type RulePackageTrust,
|
|
22
45
|
} from "./config.ts";
|
|
23
46
|
export { applyPatchPlan, type ApplyPatchPlanOptions } from "./connector.ts";
|
|
24
47
|
export {
|
|
@@ -55,6 +78,7 @@ export {
|
|
|
55
78
|
pollSalesforceDeviceLogin,
|
|
56
79
|
refreshSalesforceToken,
|
|
57
80
|
startSalesforceDeviceLogin,
|
|
81
|
+
validateSalesforceOrigin,
|
|
58
82
|
validateSalesforceToken,
|
|
59
83
|
type SalesforceDeviceAuthorization,
|
|
60
84
|
type SalesforceTokenSet,
|
|
@@ -209,6 +233,7 @@ export {
|
|
|
209
233
|
type AuditLogExport,
|
|
210
234
|
type AuditLogVerification,
|
|
211
235
|
} from "./auditLog.ts";
|
|
236
|
+
export { FREE_EMAIL_DOMAINS } from "./freeEmailDomains.ts";
|
|
212
237
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
213
238
|
export {
|
|
214
239
|
computeHealth,
|
|
@@ -236,6 +261,7 @@ export {
|
|
|
236
261
|
buildSnapshotIndex,
|
|
237
262
|
builtinAuditRules,
|
|
238
263
|
closingSoonInactiveRule,
|
|
264
|
+
isFindingsOnlyRule,
|
|
239
265
|
duplicateAccountDomainRule,
|
|
240
266
|
duplicateContactEmailRule,
|
|
241
267
|
duplicateOpenDealRule,
|
|
@@ -540,6 +566,8 @@ export type {
|
|
|
540
566
|
PatchOperationResult,
|
|
541
567
|
PatchOperationType,
|
|
542
568
|
PatchPlan,
|
|
569
|
+
PatchPlanAssumption,
|
|
570
|
+
PatchPlanAssumptionConfidence,
|
|
543
571
|
PatchPlanRun,
|
|
544
572
|
PatchPlanRunStatus,
|
|
545
573
|
PatchVerification,
|
|
@@ -550,3 +578,4 @@ export type {
|
|
|
550
578
|
RiskLevel,
|
|
551
579
|
SourceFreshness,
|
|
552
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 = {
|