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.
Files changed (100) hide show
  1. package/CHANGELOG.md +76 -6
  2. package/CONTRIBUTING.md +23 -4
  3. package/DATA-FLOWS.md +7 -2
  4. package/INSTALL_FOR_AGENTS.md +15 -6
  5. package/README.md +28 -9
  6. package/SECURITY.md +27 -3
  7. package/dist/audit.js +7 -0
  8. package/dist/backfill.js +2 -16
  9. package/dist/cli/audit.js +10 -7
  10. package/dist/cli/auth.js +8 -4
  11. package/dist/cli/fix.d.ts +3 -0
  12. package/dist/cli/fix.js +90 -8
  13. package/dist/cli/help.d.ts +6 -0
  14. package/dist/cli/help.js +81 -3
  15. package/dist/cli/market.js +180 -2
  16. package/dist/cli/plans.js +56 -5
  17. package/dist/cli/suggest.d.ts +2 -1
  18. package/dist/cli/suggest.js +49 -3
  19. package/dist/cli/ui.js +2 -0
  20. package/dist/cli.js +41 -16
  21. package/dist/config.d.ts +13 -1
  22. package/dist/config.js +23 -2
  23. package/dist/connectors/hubspot.d.ts +2 -1
  24. package/dist/connectors/prospectSources.js +4 -11
  25. package/dist/connectors/salesforce.d.ts +2 -1
  26. package/dist/connectors/salesforce.js +12 -5
  27. package/dist/connectors/salesforceAuth.d.ts +9 -0
  28. package/dist/connectors/salesforceAuth.js +47 -5
  29. package/dist/connectors/signalSources.js +2 -11
  30. package/dist/connectors/theirstack.d.ts +0 -19
  31. package/dist/connectors/theirstack.js +3 -10
  32. package/dist/credentials.js +36 -26
  33. package/dist/format.js +31 -5
  34. package/dist/freeEmailDomains.d.ts +1 -0
  35. package/dist/freeEmailDomains.js +14 -0
  36. package/dist/hierarchy.d.ts +29 -0
  37. package/dist/hierarchy.js +164 -0
  38. package/dist/index.d.ts +9 -4
  39. package/dist/index.js +8 -3
  40. package/dist/market.d.ts +1 -1
  41. package/dist/market.js +7 -127
  42. package/dist/marketClassify.d.ts +10 -0
  43. package/dist/marketClassify.js +52 -1
  44. package/dist/marketSourcing.js +7 -45
  45. package/dist/mcp.js +86 -130
  46. package/dist/planStore.d.ts +28 -1
  47. package/dist/planStore.js +195 -49
  48. package/dist/providerError.d.ts +21 -0
  49. package/dist/providerError.js +30 -0
  50. package/dist/publicHttp.d.ts +28 -0
  51. package/dist/publicHttp.js +143 -0
  52. package/dist/relationships.d.ts +39 -0
  53. package/dist/relationships.js +101 -0
  54. package/dist/route.d.ts +46 -0
  55. package/dist/route.js +228 -0
  56. package/dist/rules.d.ts +1 -0
  57. package/dist/rules.js +172 -12
  58. package/dist/secureFile.d.ts +15 -0
  59. package/dist/secureFile.js +164 -0
  60. package/dist/types.d.ts +16 -1
  61. package/docs/api.md +49 -5
  62. package/docs/architecture.md +18 -4
  63. package/llms.txt +7 -0
  64. package/package.json +5 -3
  65. package/skills/fullstackgtm/SKILL.md +9 -3
  66. package/src/audit.ts +7 -0
  67. package/src/backfill.ts +2 -17
  68. package/src/cli/audit.ts +10 -7
  69. package/src/cli/auth.ts +8 -4
  70. package/src/cli/fix.ts +96 -8
  71. package/src/cli/help.ts +88 -3
  72. package/src/cli/market.ts +181 -2
  73. package/src/cli/plans.ts +66 -5
  74. package/src/cli/suggest.ts +58 -4
  75. package/src/cli/ui.ts +1 -0
  76. package/src/cli.ts +39 -14
  77. package/src/config.ts +39 -1
  78. package/src/connectors/hubspot.ts +3 -1
  79. package/src/connectors/prospectSources.ts +4 -11
  80. package/src/connectors/salesforce.ts +15 -6
  81. package/src/connectors/salesforceAuth.ts +46 -6
  82. package/src/connectors/signalSources.ts +2 -12
  83. package/src/connectors/theirstack.ts +3 -10
  84. package/src/credentials.ts +47 -28
  85. package/src/format.ts +33 -5
  86. package/src/freeEmailDomains.ts +14 -0
  87. package/src/hierarchy.ts +185 -0
  88. package/src/index.ts +29 -0
  89. package/src/market.ts +7 -111
  90. package/src/marketClassify.ts +61 -1
  91. package/src/marketSourcing.ts +7 -43
  92. package/src/mcp.ts +115 -152
  93. package/src/planStore.ts +235 -57
  94. package/src/providerError.ts +37 -0
  95. package/src/publicHttp.ts +147 -0
  96. package/src/relationships.ts +115 -0
  97. package/src/route.ts +278 -0
  98. package/src/rules.ts +192 -12
  99. package/src/secureFile.ts +169 -0
  100. package/src/types.ts +18 -0
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
- * Residual gap (documented, not defended here): TOCTOU DNS rebinding between
156
- * our lookup and fetch's own resolution. Out of scope for fetching public
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
- let current = url;
244
- for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
245
- await assertPublicUrl(current);
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 });
@@ -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?: {
@@ -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
- instructions: "Produce one observation per claim (intensity loud|quiet|absent from these pages only; unobservable only if a page you need failed to capture). 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.",
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
  }
@@ -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 { assertPublicUrl } from "./market.js";
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
- let current = url;
160
- for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
161
- await assertPublicUrl(current);
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
- let current = rawUrl;
210
- for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
211
- await assertPublicUrl(current);
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
  /**