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
|
@@ -1,22 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TheirStack — technographic company discovery.
|
|
3
|
-
*
|
|
4
|
-
* Explorium can only size a firmographic universe (NAICS/size/geo) and can't even
|
|
5
|
-
* return the list. For a CRM-hygiene/RevOps tool the real buying signal is
|
|
6
|
-
* "company runs a CRM", and the deliverable is an actual list of those companies.
|
|
7
|
-
* TheirStack does both: filter companies by the technology they use (Salesforce,
|
|
8
|
-
* HubSpot, Pipedrive, …) plus firmographics, and return real records (name,
|
|
9
|
-
* domain, employee_count, …). A cheap count sizes the market; a paged search
|
|
10
|
-
* materializes the list. Both cost ~3 credits per company RETURNED — counting
|
|
11
|
-
* still returns 1 row (the API rejects limit:0), so a count is ~3 credits, not
|
|
12
|
-
* free; a list pull is ~3 × the rows.
|
|
13
|
-
*
|
|
14
|
-
* API: POST https://api.theirstack.com/v1/companies/search, Bearer auth.
|
|
15
|
-
* Verified live (2026-06-29): filters company_technology_slug_or /
|
|
16
|
-
* company_country_code_or / min_employee_count / max_employee_count; the total is
|
|
17
|
-
* `metadata.total_results` with include_total_results:true; limit must be ≥ 1
|
|
18
|
-
* (limit:0 → 422, the count gotcha — cf. Explorium's `size`-caps-the-total).
|
|
19
|
-
*/
|
|
20
1
|
type FetchImpl = typeof fetch;
|
|
21
2
|
/** TheirStack charges per company RETURNED — verified live (a list pull of N
|
|
22
3
|
* companies spends 3N). The count (limit:1) returns 1 row, so it's ~3 credits. */
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* `metadata.total_results` with include_total_results:true; limit must be ≥ 1
|
|
18
18
|
* (limit:0 → 422, the count gotcha — cf. Explorium's `size`-caps-the-total).
|
|
19
19
|
*/
|
|
20
|
+
import { ProviderHttpError } from "../providerError.js";
|
|
20
21
|
/** TheirStack charges per company RETURNED — verified live (a list pull of N
|
|
21
22
|
* companies spends 3N). The count (limit:1) returns 1 row, so it's ~3 credits. */
|
|
22
23
|
export const THEIRSTACK_CREDITS_PER_COMPANY = 3;
|
|
@@ -31,14 +32,6 @@ export function theirStackPullCost(companies, usdPerCredit) {
|
|
|
31
32
|
...(usdPerCredit && usdPerCredit > 0 ? { usd: Math.round(credits * usdPerCredit) } : {}),
|
|
32
33
|
};
|
|
33
34
|
}
|
|
34
|
-
async function safeText(res) {
|
|
35
|
-
try {
|
|
36
|
-
return (await res.text()).slice(0, 300);
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
return "";
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
35
|
const BASE = "https://api.theirstack.com";
|
|
43
36
|
const SEARCH_PATH = "/v1/companies/search";
|
|
44
37
|
function buildBody(filters, limit, page, includeTotal) {
|
|
@@ -99,7 +92,7 @@ export async function theirStackCountCompanies(opts) {
|
|
|
99
92
|
body: buildBody(opts.filters, 1, 0, true),
|
|
100
93
|
});
|
|
101
94
|
if (!res.ok) {
|
|
102
|
-
throw new
|
|
95
|
+
throw new ProviderHttpError("TheirStack", "count", res.status);
|
|
103
96
|
}
|
|
104
97
|
return readTheirStackTotal(await res.json());
|
|
105
98
|
}
|
|
@@ -117,7 +110,7 @@ export async function theirStackSearchCompanies(opts) {
|
|
|
117
110
|
body: buildBody(opts.filters, limit, opts.page ?? 0, true),
|
|
118
111
|
});
|
|
119
112
|
if (!res.ok) {
|
|
120
|
-
throw new
|
|
113
|
+
throw new ProviderHttpError("TheirStack", "search", res.status);
|
|
121
114
|
}
|
|
122
115
|
const body = (await res.json());
|
|
123
116
|
const rows = body.data ?? body.results ?? [];
|
package/dist/credentials.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readdirSync,
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, unlinkSync, } from "node:fs";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { refreshHubspotToken } from "./connectors/hubspotAuth.js";
|
|
6
|
-
import { refreshSalesforceToken } from "./connectors/salesforceAuth.js";
|
|
6
|
+
import { refreshSalesforceToken, validateSalesforceOrigin } from "./connectors/salesforceAuth.js";
|
|
7
7
|
import { detectKeychainBackend } from "./keychain.js";
|
|
8
|
+
import { assertNoSymlinkComponents, readSecureRegularFile, UnsafeManagedPathError, writeSecureFileAtomic, } from "./secureFile.js";
|
|
8
9
|
/**
|
|
9
10
|
* Local CLI credential store: ~/.fullstackgtm/credentials.json (0600), or
|
|
10
11
|
* $FSGTM_HOME/credentials.json when set. Environment tokens always win over
|
|
@@ -80,7 +81,9 @@ export function ensureSecureHomeDir() {
|
|
|
80
81
|
// umask) only to directories it creates, and never to pre-existing ones.
|
|
81
82
|
const levels = dir === baseHomeDir() ? [dir] : [baseHomeDir(), join(baseHomeDir(), "profiles"), dir];
|
|
82
83
|
for (const level of levels) {
|
|
84
|
+
assertNoSymlinkComponents(level);
|
|
83
85
|
mkdirSync(level, { recursive: true, mode: 0o700 });
|
|
86
|
+
assertNoSymlinkComponents(level);
|
|
84
87
|
try {
|
|
85
88
|
chmodSync(level, 0o700);
|
|
86
89
|
}
|
|
@@ -92,13 +95,7 @@ export function ensureSecureHomeDir() {
|
|
|
92
95
|
}
|
|
93
96
|
/** Write a 0600 file under the home, enforcing the mode even on rewrite. */
|
|
94
97
|
export function writeSecureFile(path, contents) {
|
|
95
|
-
|
|
96
|
-
try {
|
|
97
|
-
chmodSync(path, 0o600);
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
// Non-POSIX filesystems ignore chmod.
|
|
101
|
-
}
|
|
98
|
+
writeSecureFileAtomic(path, contents);
|
|
102
99
|
}
|
|
103
100
|
/**
|
|
104
101
|
* The 0600/0700 guarantee was write-only: a credentials.json inherited at
|
|
@@ -107,18 +104,12 @@ export function writeSecureFile(path, contents) {
|
|
|
107
104
|
* mode on read too — re-tighten to 0600 and warn once — so a world-readable
|
|
108
105
|
* credential store can't sit there silently leaking the token to other users.
|
|
109
106
|
*/
|
|
110
|
-
function
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
"(it was readable or writable by other users).");
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
catch {
|
|
120
|
-
// Missing file or non-POSIX filesystem: nothing to enforce.
|
|
121
|
-
}
|
|
107
|
+
function readCredentialFile(path) {
|
|
108
|
+
return readSecureRegularFile(path, {
|
|
109
|
+
tightenMode: 0o600,
|
|
110
|
+
onModeTightened: (mode) => console.error(`fullstackgtm: tightened ${path} from ${mode.toString(8).padStart(3, "0")} to 600 ` +
|
|
111
|
+
"(it was readable or writable by other users)."),
|
|
112
|
+
});
|
|
122
113
|
}
|
|
123
114
|
/**
|
|
124
115
|
* Persistence backend for the credential blob: the OS keychain when
|
|
@@ -144,7 +135,7 @@ function migratePlaintextToKeychain(keychain) {
|
|
|
144
135
|
if (!existsSync(credentialsPath()))
|
|
145
136
|
return;
|
|
146
137
|
try {
|
|
147
|
-
const fileParsed = JSON.parse(
|
|
138
|
+
const fileParsed = JSON.parse(readCredentialFile(credentialsPath()));
|
|
148
139
|
if (fileParsed && fileParsed.version === 1 && fileParsed.providers) {
|
|
149
140
|
const current = keychain.backend.get(keychain.account);
|
|
150
141
|
const existing = current ? (JSON.parse(current).providers ?? {}) : {};
|
|
@@ -164,7 +155,7 @@ function readFile() {
|
|
|
164
155
|
if (keychain)
|
|
165
156
|
migratePlaintextToKeychain(keychain);
|
|
166
157
|
try {
|
|
167
|
-
const raw = keychain ? keychain.backend.get(keychain.account) : (
|
|
158
|
+
const raw = keychain ? keychain.backend.get(keychain.account) : readCredentialFile(credentialsPath());
|
|
168
159
|
if (raw) {
|
|
169
160
|
const parsed = JSON.parse(raw);
|
|
170
161
|
if (parsed && typeof parsed === "object" && parsed.version === 1 && parsed.providers) {
|
|
@@ -172,7 +163,9 @@ function readFile() {
|
|
|
172
163
|
}
|
|
173
164
|
}
|
|
174
165
|
}
|
|
175
|
-
catch {
|
|
166
|
+
catch (error) {
|
|
167
|
+
if (error instanceof UnsafeManagedPathError)
|
|
168
|
+
throw error;
|
|
176
169
|
// Missing or unreadable store falls through to an empty one.
|
|
177
170
|
}
|
|
178
171
|
return { version: 1, providers: {} };
|
|
@@ -192,6 +185,18 @@ export function getCredential(provider) {
|
|
|
192
185
|
return readFile().providers[provider] ?? null;
|
|
193
186
|
}
|
|
194
187
|
export function storeCredential(provider, credential) {
|
|
188
|
+
if (provider === "salesforce") {
|
|
189
|
+
if (!credential.instanceUrl) {
|
|
190
|
+
throw new Error("Salesforce credentials require an instance URL.");
|
|
191
|
+
}
|
|
192
|
+
credential = {
|
|
193
|
+
...credential,
|
|
194
|
+
instanceUrl: validateSalesforceOrigin(credential.instanceUrl, "Salesforce credential instance URL"),
|
|
195
|
+
...(credential.loginUrl
|
|
196
|
+
? { loginUrl: validateSalesforceOrigin(credential.loginUrl, "Salesforce credential login URL") }
|
|
197
|
+
: {}),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
195
200
|
const file = readFile();
|
|
196
201
|
file.providers[provider] = credential;
|
|
197
202
|
persist(file);
|
|
@@ -248,11 +253,15 @@ export async function resolveSalesforceConnection(options = {}) {
|
|
|
248
253
|
if (!credential.instanceUrl) {
|
|
249
254
|
throw new Error("Stored Salesforce credential has no instance URL. Run `fullstackgtm login salesforce` again.");
|
|
250
255
|
}
|
|
256
|
+
const instanceUrl = validateSalesforceOrigin(credential.instanceUrl, "Stored Salesforce credential instance URL");
|
|
257
|
+
if (credential.loginUrl) {
|
|
258
|
+
validateSalesforceOrigin(credential.loginUrl, "Stored Salesforce credential login URL");
|
|
259
|
+
}
|
|
251
260
|
const needsRefresh = credential.kind === "oauth" &&
|
|
252
261
|
credential.expiresAt !== undefined &&
|
|
253
262
|
Date.now() > credential.expiresAt - REFRESH_SKEW_MS;
|
|
254
263
|
if (!needsRefresh) {
|
|
255
|
-
return { accessToken: credential.accessToken, instanceUrl
|
|
264
|
+
return { accessToken: credential.accessToken, instanceUrl };
|
|
256
265
|
}
|
|
257
266
|
if (!credential.refreshToken || !credential.clientId) {
|
|
258
267
|
throw new Error("Stored Salesforce token is expired and cannot be refreshed. Run `fullstackgtm login salesforce` again.");
|
|
@@ -279,9 +288,10 @@ export async function resolveSalesforceConnection(options = {}) {
|
|
|
279
288
|
if (!minted.instanceUrl) {
|
|
280
289
|
throw new Error("The hosted deployment returned no Salesforce instance URL.");
|
|
281
290
|
}
|
|
291
|
+
const instanceUrl = validateSalesforceOrigin(minted.instanceUrl, "Hosted Salesforce credential instance URL");
|
|
282
292
|
return {
|
|
283
293
|
accessToken: minted.accessToken,
|
|
284
|
-
instanceUrl
|
|
294
|
+
instanceUrl,
|
|
285
295
|
fieldMappings: minted.fieldMappings,
|
|
286
296
|
};
|
|
287
297
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,13 +6,13 @@ export { buildReassignPlans, type ReassignObjectType, type ReassignOptions } fro
|
|
|
6
6
|
export { buildLeadRoutePlan, type LeadRouteCounts, type LeadRouteOptions, type LeadRouteResult, } from "./route.ts";
|
|
7
7
|
export { accountHierarchyToMarkdown, buildAccountHierarchy, type AccountHierarchyConflict, type AccountHierarchyNode, type AccountHierarchyReport, } from "./hierarchy.ts";
|
|
8
8
|
export { buildRelationshipMap, relationshipMapToMarkdown, type RelationshipMap, type StakeholderNode, type StakeholderRole, type StakeholderSentiment, } from "./relationships.ts";
|
|
9
|
-
export { CONFIG_FILE_NAME, loadConfig, mergePolicy, resolveConfiguredRules, type FullstackgtmConfig, type LoadedConfig, } from "./config.ts";
|
|
9
|
+
export { CONFIG_FILE_NAME, loadConfig, mergePolicy, resolveConfiguredRules, rulePackageTrustFromCli, type FullstackgtmConfig, type LoadedConfig, type RulePackageTrust, } from "./config.ts";
|
|
10
10
|
export { applyPatchPlan, type ApplyPatchPlanOptions } from "./connector.ts";
|
|
11
11
|
export { APPLY_STAGES, BACKFILL_STRIPE_STAGES, composeListeners, createProgressEmitter, CRM_SYNC_STAGES, nullProgressEmitter, SNAPSHOT_PULL_STAGES, STRIPE_SNAPSHOT_STAGES, type ProgressEmitter, type ProgressEvent, type ProgressListener, type ProgressSnapshot, } from "./progress.ts";
|
|
12
12
|
export { createHubspotConnector, type HubspotConnectorOptions } from "./connectors/hubspot.ts";
|
|
13
13
|
export { DEFAULT_LOOPBACK_PORT, DEFAULT_OAUTH_SCOPES, exchangeHubspotCode, refreshHubspotToken, runHubspotLoopbackLogin, validateHubspotToken, type HubspotTokenSet, type LoopbackLoginOptions, } from "./connectors/hubspotAuth.ts";
|
|
14
14
|
export { createSalesforceConnector, type SalesforceConnection, type SalesforceConnectorOptions, } from "./connectors/salesforce.ts";
|
|
15
|
-
export { pollSalesforceDeviceLogin, refreshSalesforceToken, startSalesforceDeviceLogin, validateSalesforceToken, type SalesforceDeviceAuthorization, type SalesforceTokenSet, } from "./connectors/salesforceAuth.ts";
|
|
15
|
+
export { pollSalesforceDeviceLogin, refreshSalesforceToken, startSalesforceDeviceLogin, validateSalesforceOrigin, validateSalesforceToken, type SalesforceDeviceAuthorization, type SalesforceTokenSet, } from "./connectors/salesforceAuth.ts";
|
|
16
16
|
export { createStripeConnector, fetchStripePaidInvoices, type StripeConnectorOptions, type StripePaidInvoice, } from "./connectors/stripe.ts";
|
|
17
17
|
export { buildStripeBackfillPlan, DEFAULT_BACKFILL_MATCH_PROPERTY, type StripeBackfillCounts, type StripeBackfillOptions, type StripeBackfillResult, type StripeBackfillUnmatched, } from "./backfill.ts";
|
|
18
18
|
export { activeProfile, credentialsDir, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotAccessToken, resolveHubspotConnection, setActiveProfile, storeCredential, type HubspotConnection, type StoredCredential, } from "./credentials.ts";
|
|
@@ -53,3 +53,4 @@ export { scoreBand, judgeRunId, normalizeSpan, isGroundedSpan, scoreAccount, acc
|
|
|
53
53
|
export { draft, authorOpeners, draftOpenerLlm, deterministicOpener, creditedTriggerSignal, draftEvidence, sanitizeOpener, firstLine, firstLineGroundedInTrigger, hasBannedGreeting, isStaleTrigger, draftOperationId, draftEvidenceId, DRAFT_CHANNELS, DRAFT_SCHEMA, DEFAULT_DRAFT_PROMPT, DEFAULT_FRESHNESS_DAYS, type Draft, type RejectedDraft, type DraftResult, type DraftChannel, } from "./draft.ts";
|
|
54
54
|
export { DEFAULT_GOLDEN_SET, DEFAULT_MIN_ACCURACY, HOT_SCORE, defaultJudgeFn, gradeAgainstOutcomes, gradeJudge, parseGoldenSet, type Confusion, type EvalJudgeFn, type GoldenHistory, type GoldenRow, type GradeResult, type OutcomeCalibration, } from "./judgeEval.ts";
|
|
55
55
|
export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanAssumption, PatchPlanAssumptionConfidence, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
|
|
56
|
+
export { ProviderHttpError } from "./providerError.ts";
|
package/dist/index.js
CHANGED
|
@@ -6,13 +6,13 @@ export { buildReassignPlans } from "./reassign.js";
|
|
|
6
6
|
export { buildLeadRoutePlan, } from "./route.js";
|
|
7
7
|
export { accountHierarchyToMarkdown, buildAccountHierarchy, } from "./hierarchy.js";
|
|
8
8
|
export { buildRelationshipMap, relationshipMapToMarkdown, } from "./relationships.js";
|
|
9
|
-
export { CONFIG_FILE_NAME, loadConfig, mergePolicy, resolveConfiguredRules, } from "./config.js";
|
|
9
|
+
export { CONFIG_FILE_NAME, loadConfig, mergePolicy, resolveConfiguredRules, rulePackageTrustFromCli, } from "./config.js";
|
|
10
10
|
export { applyPatchPlan } from "./connector.js";
|
|
11
11
|
export { APPLY_STAGES, BACKFILL_STRIPE_STAGES, composeListeners, createProgressEmitter, CRM_SYNC_STAGES, nullProgressEmitter, SNAPSHOT_PULL_STAGES, STRIPE_SNAPSHOT_STAGES, } from "./progress.js";
|
|
12
12
|
export { createHubspotConnector } from "./connectors/hubspot.js";
|
|
13
13
|
export { DEFAULT_LOOPBACK_PORT, DEFAULT_OAUTH_SCOPES, exchangeHubspotCode, refreshHubspotToken, runHubspotLoopbackLogin, validateHubspotToken, } from "./connectors/hubspotAuth.js";
|
|
14
14
|
export { createSalesforceConnector, } from "./connectors/salesforce.js";
|
|
15
|
-
export { pollSalesforceDeviceLogin, refreshSalesforceToken, startSalesforceDeviceLogin, validateSalesforceToken, } from "./connectors/salesforceAuth.js";
|
|
15
|
+
export { pollSalesforceDeviceLogin, refreshSalesforceToken, startSalesforceDeviceLogin, validateSalesforceOrigin, validateSalesforceToken, } from "./connectors/salesforceAuth.js";
|
|
16
16
|
export { createStripeConnector, fetchStripePaidInvoices, } from "./connectors/stripe.js";
|
|
17
17
|
export { buildStripeBackfillPlan, DEFAULT_BACKFILL_MATCH_PROPERTY, } from "./backfill.js";
|
|
18
18
|
export { activeProfile, credentialsDir, credentialsPath, DEFAULT_PROFILE, deleteCredential, getCredential, listProfiles, resolveHubspotAccessToken, resolveHubspotConnection, setActiveProfile, storeCredential, } from "./credentials.js";
|
|
@@ -52,3 +52,4 @@ export { fetchAtsJobs, snippetFor, } from "./connectors/atsBoards.js";
|
|
|
52
52
|
export { scoreBand, judgeRunId, normalizeSpan, isGroundedSpan, scoreAccount, accountRecentlyTouched, bestContactForAccount, deterministicDecision, deterministicWhyNow, JUDGE_SCHEMA, DEFAULT_JUDGE_PROMPT, judgeDecisionLlm, judgeSignals, judgeDir, createFileJudgeStore, } from "./judge.js";
|
|
53
53
|
export { draft, authorOpeners, draftOpenerLlm, deterministicOpener, creditedTriggerSignal, draftEvidence, sanitizeOpener, firstLine, firstLineGroundedInTrigger, hasBannedGreeting, isStaleTrigger, draftOperationId, draftEvidenceId, DRAFT_CHANNELS, DRAFT_SCHEMA, DEFAULT_DRAFT_PROMPT, DEFAULT_FRESHNESS_DAYS, } from "./draft.js";
|
|
54
54
|
export { DEFAULT_GOLDEN_SET, DEFAULT_MIN_ACCURACY, HOT_SCORE, defaultJudgeFn, gradeAgainstOutcomes, gradeJudge, parseGoldenSet, } from "./judgeEval.js";
|
|
55
|
+
export { ProviderHttpError } from "./providerError.js";
|
package/dist/market.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ProgressEmitter } from "./progress.ts";
|
|
2
2
|
import type { GtmEvidence } from "./types.ts";
|
|
3
|
+
export { assertPublicUrl } from "./publicHttp.ts";
|
|
3
4
|
/**
|
|
4
5
|
* The Market Map: a live model of the competitive category a company sells
|
|
5
6
|
* into. Vendors publish claims constantly (pricing pages, feature pages,
|
|
@@ -162,7 +163,6 @@ export type FetchPage = (url: string) => Promise<{
|
|
|
162
163
|
status: number;
|
|
163
164
|
body: string;
|
|
164
165
|
}>;
|
|
165
|
-
export declare function assertPublicUrl(rawUrl: string): Promise<URL>;
|
|
166
166
|
export type CaptureOptions = {
|
|
167
167
|
/** Directory for captures; defaults to <marketHome>/captures. Ignored when `store` is given. */
|
|
168
168
|
dir?: string;
|
package/dist/market.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
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.js";
|
|
7
5
|
import { MARKET_CAPTURE_STAGES, nullProgressEmitter } from "./progress.js";
|
|
6
|
+
import { publicHttpGet } from "./publicHttp.js";
|
|
7
|
+
export { assertPublicUrl } from "./publicHttp.js";
|
|
8
8
|
const INTENSITY_RANK = {
|
|
9
9
|
loud: 3,
|
|
10
10
|
quiet: 2,
|
|
@@ -152,136 +152,16 @@ export function extractReadableText(html) {
|
|
|
152
152
|
* any host that is or resolves to a private/loopback/link-local/metadata
|
|
153
153
|
* address, and (3) follow redirects manually, re-validating each hop.
|
|
154
154
|
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* competitor pages; a hardened deployment should fetch through an egress proxy.
|
|
155
|
+
* The default transport resolves once, rejects mixed public/private answers,
|
|
156
|
+
* and pins a validated address into socket creation to prevent DNS rebinding.
|
|
158
157
|
*/
|
|
159
158
|
const MAX_REDIRECTS = 5;
|
|
160
159
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
161
160
|
const MAX_BODY_BYTES = 5_000_000;
|
|
162
|
-
function ipv4IsPrivate(ip) {
|
|
163
|
-
const parts = ip.split(".").map((n) => Number(n));
|
|
164
|
-
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
|
|
165
|
-
return true;
|
|
166
|
-
const [a, b] = parts;
|
|
167
|
-
if (a === 0 || a === 127)
|
|
168
|
-
return true; // this-host, loopback
|
|
169
|
-
if (a === 10)
|
|
170
|
-
return true; // private
|
|
171
|
-
if (a === 172 && b >= 16 && b <= 31)
|
|
172
|
-
return true; // private
|
|
173
|
-
if (a === 192 && b === 168)
|
|
174
|
-
return true; // private
|
|
175
|
-
if (a === 169 && b === 254)
|
|
176
|
-
return true; // link-local incl. 169.254.169.254 metadata
|
|
177
|
-
if (a === 100 && b >= 64 && b <= 127)
|
|
178
|
-
return true; // CGNAT
|
|
179
|
-
if (a >= 224)
|
|
180
|
-
return true; // multicast / reserved
|
|
181
|
-
return false;
|
|
182
|
-
}
|
|
183
|
-
function ipIsPrivate(ip) {
|
|
184
|
-
const family = isIP(ip);
|
|
185
|
-
if (family === 4)
|
|
186
|
-
return ipv4IsPrivate(ip);
|
|
187
|
-
if (family === 6) {
|
|
188
|
-
const lower = ip.toLowerCase();
|
|
189
|
-
if (lower === "::1" || lower === "::")
|
|
190
|
-
return true; // loopback / unspecified
|
|
191
|
-
// IPv4-mapped (::ffff:…) — Node normalizes ::ffff:127.0.0.1 to ::ffff:7f00:1,
|
|
192
|
-
// so accept both the dotted and the hex-pair forms, unwrap, check the v4.
|
|
193
|
-
const mapped = lower.match(/^::ffff:(.+)$/);
|
|
194
|
-
if (mapped) {
|
|
195
|
-
const rest = mapped[1];
|
|
196
|
-
if (rest.includes("."))
|
|
197
|
-
return ipv4IsPrivate(rest);
|
|
198
|
-
const groups = rest.split(":");
|
|
199
|
-
if (groups.length === 2) {
|
|
200
|
-
const hi = parseInt(groups[0], 16);
|
|
201
|
-
const lo = parseInt(groups[1], 16);
|
|
202
|
-
if (Number.isNaN(hi) || Number.isNaN(lo))
|
|
203
|
-
return true;
|
|
204
|
-
return ipv4IsPrivate(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
|
|
205
|
-
}
|
|
206
|
-
return true; // unrecognized mapped form → refuse
|
|
207
|
-
}
|
|
208
|
-
if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb"))
|
|
209
|
-
return true; // link-local fe80::/10
|
|
210
|
-
if (lower.startsWith("fc") || lower.startsWith("fd"))
|
|
211
|
-
return true; // unique-local fc00::/7
|
|
212
|
-
return false;
|
|
213
|
-
}
|
|
214
|
-
return true; // not a recognizable IP literal → refuse
|
|
215
|
-
}
|
|
216
|
-
export async function assertPublicUrl(rawUrl) {
|
|
217
|
-
let url;
|
|
218
|
-
try {
|
|
219
|
-
url = new URL(rawUrl);
|
|
220
|
-
}
|
|
221
|
-
catch {
|
|
222
|
-
throw new Error(`market capture: "${rawUrl}" is not a valid URL.`);
|
|
223
|
-
}
|
|
224
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
225
|
-
throw new Error(`market capture refuses ${url.protocol} URLs (only http/https): ${rawUrl}`);
|
|
226
|
-
}
|
|
227
|
-
const host = url.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
|
|
228
|
-
if (isIP(host)) {
|
|
229
|
-
if (ipIsPrivate(host))
|
|
230
|
-
throw new Error(`market capture refuses private/loopback address ${host} (SSRF guard).`);
|
|
231
|
-
return url;
|
|
232
|
-
}
|
|
233
|
-
// Hostname: resolve and refuse if ANY address is private.
|
|
234
|
-
const addrs = await lookup(host, { all: true });
|
|
235
|
-
for (const { address } of addrs) {
|
|
236
|
-
if (ipIsPrivate(address)) {
|
|
237
|
-
throw new Error(`market capture refuses ${host} — it resolves to private/internal address ${address} (SSRF guard).`);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
return url;
|
|
241
|
-
}
|
|
242
161
|
const defaultFetchPage = async (url) => {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const controller = new AbortController();
|
|
247
|
-
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
248
|
-
let response;
|
|
249
|
-
try {
|
|
250
|
-
response = await fetch(current, {
|
|
251
|
-
headers: {
|
|
252
|
-
"User-Agent": "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)",
|
|
253
|
-
"Accept-Language": "en-US",
|
|
254
|
-
},
|
|
255
|
-
redirect: "manual",
|
|
256
|
-
signal: controller.signal,
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
finally {
|
|
260
|
-
clearTimeout(timer);
|
|
261
|
-
}
|
|
262
|
-
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
|
|
263
|
-
current = new URL(response.headers.get("location"), current).toString();
|
|
264
|
-
continue; // re-validate the redirect target on the next iteration
|
|
265
|
-
}
|
|
266
|
-
const reader = response.body?.getReader();
|
|
267
|
-
if (!reader)
|
|
268
|
-
return { status: response.status, body: await response.text() };
|
|
269
|
-
const chunks = [];
|
|
270
|
-
let total = 0;
|
|
271
|
-
for (;;) {
|
|
272
|
-
const { done, value } = await reader.read();
|
|
273
|
-
if (done)
|
|
274
|
-
break;
|
|
275
|
-
total += value.length;
|
|
276
|
-
if (total > MAX_BODY_BYTES) {
|
|
277
|
-
await reader.cancel();
|
|
278
|
-
break;
|
|
279
|
-
}
|
|
280
|
-
chunks.push(value);
|
|
281
|
-
}
|
|
282
|
-
return { status: response.status, body: Buffer.concat(chunks).toString("utf8") };
|
|
283
|
-
}
|
|
284
|
-
throw new Error(`market capture: too many redirects (>${MAX_REDIRECTS}) for ${url}`);
|
|
162
|
+
const response = await publicHttpGet(url, { maxBytes: MAX_BODY_BYTES, maxRedirects: MAX_REDIRECTS,
|
|
163
|
+
timeoutMs: FETCH_TIMEOUT_MS, headers: { "User-Agent": "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)", "Accept-Language": "en-US" } });
|
|
164
|
+
return { status: response.status, body: Buffer.from(response.body).toString("utf8") };
|
|
285
165
|
};
|
|
286
166
|
export async function captureMarket(config, options = {}) {
|
|
287
167
|
const store = options.store ?? createFileMarketStore(config.category, { capturesDir: options.dir });
|
package/dist/marketClassify.d.ts
CHANGED
|
@@ -51,6 +51,16 @@ export type MarketWorksheet = {
|
|
|
51
51
|
captureHash: string;
|
|
52
52
|
text: string;
|
|
53
53
|
}>;
|
|
54
|
+
/** Deterministic retrieval hints: page lines that contain claim terms. These are not labels. */
|
|
55
|
+
claimHints: Array<{
|
|
56
|
+
claimId: string;
|
|
57
|
+
matches: Array<{
|
|
58
|
+
url: string;
|
|
59
|
+
captureHash: string;
|
|
60
|
+
term: string;
|
|
61
|
+
quote: string;
|
|
62
|
+
}>;
|
|
63
|
+
}>;
|
|
54
64
|
instructions: string;
|
|
55
65
|
};
|
|
56
66
|
export declare function buildWorksheet(config: MarketConfig, vendorId: string, options?: {
|
package/dist/marketClassify.js
CHANGED
|
@@ -188,6 +188,56 @@ export async function classifyMarket(config, options) {
|
|
|
188
188
|
retriedVendorIds,
|
|
189
189
|
};
|
|
190
190
|
}
|
|
191
|
+
const HINT_STOPWORDS = new Set([
|
|
192
|
+
"the", "and", "for", "with", "from", "that", "this", "into", "your", "you", "are", "but", "not", "when", "how", "use", "uses", "using",
|
|
193
|
+
"claim", "claims", "vendor", "vendors", "page", "pages", "loud", "quiet", "absent", "capability", "specific", "differentiating",
|
|
194
|
+
"unknown", "buyer", "buyers", "pricing", "structure", "category", "support", "supported", "primary", "secondary", "mentioned", "mentions",
|
|
195
|
+
]);
|
|
196
|
+
function normalizeHintText(value) {
|
|
197
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().replace(/\s+/g, " ");
|
|
198
|
+
}
|
|
199
|
+
function claimHintTerms(claim) {
|
|
200
|
+
const exact = [claim.id.replace(/[-_]+/g, " "), claim.capability, ...(claim.terms ?? [])]
|
|
201
|
+
.map(normalizeHintText)
|
|
202
|
+
.filter((term) => term.length >= 3);
|
|
203
|
+
const tokenSource = normalizeHintText(`${claim.capability} ${claim.definition} ${(claim.terms ?? []).join(" ")}`)
|
|
204
|
+
.split(" ")
|
|
205
|
+
.filter((term) => term.length >= 4 && !HINT_STOPWORDS.has(term));
|
|
206
|
+
return Array.from(new Set([...exact, ...tokenSource])).slice(0, 24);
|
|
207
|
+
}
|
|
208
|
+
function lineSnippet(line, term) {
|
|
209
|
+
const clean = line.trim().replace(/\s+/g, " ");
|
|
210
|
+
if (clean.length <= 300)
|
|
211
|
+
return clean;
|
|
212
|
+
const lower = clean.toLowerCase();
|
|
213
|
+
const idx = lower.indexOf(term.toLowerCase().split(" ")[0] ?? "");
|
|
214
|
+
const start = Math.max(0, idx < 0 ? 0 : idx - 120);
|
|
215
|
+
return clean.slice(start, start + 300).trim();
|
|
216
|
+
}
|
|
217
|
+
function buildClaimHints(claims, pages) {
|
|
218
|
+
return claims.map((claim) => {
|
|
219
|
+
const terms = claimHintTerms(claim);
|
|
220
|
+
const matches = [];
|
|
221
|
+
for (const page of pages) {
|
|
222
|
+
const lines = page.text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
223
|
+
for (const term of terms) {
|
|
224
|
+
const normalizedTerm = normalizeHintText(term);
|
|
225
|
+
const line = lines.find((candidate) => normalizeHintText(candidate).includes(normalizedTerm));
|
|
226
|
+
if (!line)
|
|
227
|
+
continue;
|
|
228
|
+
const quote = lineSnippet(line, term);
|
|
229
|
+
if (quote && !matches.some((m) => m.quote === quote && m.url === page.url)) {
|
|
230
|
+
matches.push({ url: page.url, captureHash: page.captureHash, term, quote });
|
|
231
|
+
}
|
|
232
|
+
if (matches.length >= 4)
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
if (matches.length >= 4)
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
return { claimId: claim.id, matches };
|
|
239
|
+
});
|
|
240
|
+
}
|
|
191
241
|
export function buildWorksheet(config, vendorId, options = {}) {
|
|
192
242
|
const vendor = config.vendors.find((candidate) => candidate.id === vendorId);
|
|
193
243
|
if (!vendor)
|
|
@@ -211,6 +261,7 @@ export function buildWorksheet(config, vendorId, options = {}) {
|
|
|
211
261
|
vendor: { id: vendor.id, name: vendor.name },
|
|
212
262
|
claims: config.claims,
|
|
213
263
|
pages,
|
|
214
|
-
|
|
264
|
+
claimHints: buildClaimHints(config.claims, pages),
|
|
265
|
+
instructions: "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.",
|
|
215
266
|
};
|
|
216
267
|
}
|
package/dist/marketSourcing.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* Sitemaps / redirects / logo bytes are plain resources — they never need a
|
|
17
17
|
* headless browser, so the default fetch is sufficient even in the hosted layer.
|
|
18
18
|
*/
|
|
19
|
-
import {
|
|
19
|
+
import { publicHttpGet } from "./publicHttp.js";
|
|
20
20
|
import { DEFAULT_MODELS, forcedToolCall } from "./llm.js";
|
|
21
21
|
const USER_AGENT = "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)";
|
|
22
22
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
@@ -156,28 +156,9 @@ export function extractLogoUrl(html, baseUrl) {
|
|
|
156
156
|
// ── Default SSRF-guarded fetchers ────────────────────────────────────────────
|
|
157
157
|
/** Manual-redirect fetch that re-validates every hop against the SSRF guard. */
|
|
158
158
|
async function guardedFetch(url) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const res = await fetch(current, {
|
|
163
|
-
redirect: "manual",
|
|
164
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
165
|
-
headers: { "User-Agent": USER_AGENT },
|
|
166
|
-
});
|
|
167
|
-
const location = res.headers.get("location");
|
|
168
|
-
if (res.status >= 300 && res.status < 400 && location) {
|
|
169
|
-
try {
|
|
170
|
-
await res.body?.cancel();
|
|
171
|
-
}
|
|
172
|
-
catch {
|
|
173
|
-
/* ignore */
|
|
174
|
-
}
|
|
175
|
-
current = new URL(location, current).toString();
|
|
176
|
-
continue;
|
|
177
|
-
}
|
|
178
|
-
return res;
|
|
179
|
-
}
|
|
180
|
-
return null;
|
|
159
|
+
const res = await publicHttpGet(url, { maxRedirects: MAX_REDIRECTS, timeoutMs: FETCH_TIMEOUT_MS,
|
|
160
|
+
headers: { "User-Agent": USER_AGENT } });
|
|
161
|
+
return new Response(res.body.length ? Buffer.from(res.body) : null, { status: res.status, headers: res.headers });
|
|
181
162
|
}
|
|
182
163
|
const defaultFetchText = async (url) => {
|
|
183
164
|
try {
|
|
@@ -206,28 +187,9 @@ const defaultFetchBytes = async (url) => {
|
|
|
206
187
|
* + status WITHOUT downloading the body. Used to detect identity drift.
|
|
207
188
|
*/
|
|
208
189
|
export const resolveFinalUrl = async (rawUrl) => {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const res = await fetch(current, {
|
|
213
|
-
redirect: "manual",
|
|
214
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
215
|
-
headers: { "User-Agent": USER_AGENT },
|
|
216
|
-
});
|
|
217
|
-
try {
|
|
218
|
-
await res.body?.cancel();
|
|
219
|
-
}
|
|
220
|
-
catch {
|
|
221
|
-
/* ignore */
|
|
222
|
-
}
|
|
223
|
-
const location = res.headers.get("location");
|
|
224
|
-
if (res.status >= 300 && res.status < 400 && location) {
|
|
225
|
-
current = new URL(location, current).toString();
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
return { finalUrl: current, status: res.status };
|
|
229
|
-
}
|
|
230
|
-
return { finalUrl: current, status: 0 };
|
|
190
|
+
const res = await publicHttpGet(rawUrl, { maxRedirects: MAX_REDIRECTS, timeoutMs: FETCH_TIMEOUT_MS,
|
|
191
|
+
maxBytes: 0, headers: { "User-Agent": USER_AGENT } });
|
|
192
|
+
return { finalUrl: res.finalUrl, status: res.status };
|
|
231
193
|
};
|
|
232
194
|
// ── Fetching helpers ─────────────────────────────────────────────────────────
|
|
233
195
|
/**
|