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.
Files changed (69) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/CONTRIBUTING.md +23 -4
  3. package/DATA-FLOWS.md +7 -2
  4. package/INSTALL_FOR_AGENTS.md +13 -4
  5. package/README.md +12 -1
  6. package/SECURITY.md +27 -3
  7. package/dist/cli/audit.js +10 -7
  8. package/dist/cli/auth.js +8 -4
  9. package/dist/cli/fix.js +20 -7
  10. package/dist/cli/help.js +10 -6
  11. package/dist/cli/market.js +180 -2
  12. package/dist/cli/plans.js +56 -5
  13. package/dist/cli/ui.js +2 -0
  14. package/dist/cli.js +1 -1
  15. package/dist/config.d.ts +13 -1
  16. package/dist/config.js +23 -2
  17. package/dist/connectors/prospectSources.js +4 -11
  18. package/dist/connectors/salesforce.js +12 -5
  19. package/dist/connectors/salesforceAuth.d.ts +9 -0
  20. package/dist/connectors/salesforceAuth.js +47 -5
  21. package/dist/connectors/theirstack.d.ts +0 -19
  22. package/dist/connectors/theirstack.js +3 -10
  23. package/dist/credentials.js +36 -26
  24. package/dist/index.d.ts +3 -2
  25. package/dist/index.js +3 -2
  26. package/dist/market.d.ts +1 -1
  27. package/dist/market.js +7 -127
  28. package/dist/marketClassify.d.ts +10 -0
  29. package/dist/marketClassify.js +52 -1
  30. package/dist/marketSourcing.js +7 -45
  31. package/dist/mcp.js +67 -115
  32. package/dist/planStore.d.ts +28 -1
  33. package/dist/planStore.js +195 -49
  34. package/dist/providerError.d.ts +21 -0
  35. package/dist/providerError.js +30 -0
  36. package/dist/publicHttp.d.ts +28 -0
  37. package/dist/publicHttp.js +143 -0
  38. package/dist/secureFile.d.ts +15 -0
  39. package/dist/secureFile.js +164 -0
  40. package/dist/types.d.ts +1 -1
  41. package/docs/api.md +29 -2
  42. package/docs/architecture.md +13 -2
  43. package/llms.txt +7 -0
  44. package/package.json +5 -3
  45. package/skills/fullstackgtm/SKILL.md +7 -1
  46. package/src/cli/audit.ts +10 -7
  47. package/src/cli/auth.ts +8 -4
  48. package/src/cli/fix.ts +28 -7
  49. package/src/cli/help.ts +10 -6
  50. package/src/cli/market.ts +181 -2
  51. package/src/cli/plans.ts +66 -5
  52. package/src/cli/ui.ts +1 -0
  53. package/src/cli.ts +1 -1
  54. package/src/config.ts +39 -1
  55. package/src/connectors/prospectSources.ts +4 -11
  56. package/src/connectors/salesforce.ts +12 -5
  57. package/src/connectors/salesforceAuth.ts +46 -6
  58. package/src/connectors/theirstack.ts +3 -10
  59. package/src/credentials.ts +47 -28
  60. package/src/index.ts +4 -0
  61. package/src/market.ts +7 -111
  62. package/src/marketClassify.ts +61 -1
  63. package/src/marketSourcing.ts +7 -43
  64. package/src/mcp.ts +93 -136
  65. package/src/planStore.ts +235 -57
  66. package/src/providerError.ts +37 -0
  67. package/src/publicHttp.ts +147 -0
  68. package/src/secureFile.ts +169 -0
  69. 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 url = path.startsWith("http")
156
- ? path
157
- : `${connection.instanceUrl.replace(/\/$/, "")}${path}`;
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 ${connection.instanceUrl}${cause}. Check SALESFORCE_INSTANCE_URL (your My Domain URL, e.g. https://yourco.my.salesforce.com) and network access.`,
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 = `${connection.instanceUrl.replace(/\/$/, "")}/services/Soap/u/${version}`;
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.replace(/\/$/, "")}/services/oauth2/token`;
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: data.instance_url,
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
- `${instanceUrl.replace(/\/$/, "")}/services/oauth2/userinfo`,
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 ${instanceUrl}${cause}. Check the --instance-url (your My Domain URL, e.g. https://yourco.my.salesforce.com) and network access.`,
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 Error(`TheirStack count failed: HTTP ${res.status} ${await safeText(res)}`);
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 Error(`TheirStack search failed: HTTP ${res.status} ${await safeText(res)}`);
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 ?? [];
@@ -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
- writeFileSync(path, contents, { mode: 0o600 });
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 enforceCredentialFileMode(path: string): void {
157
- try {
158
- const mode = statSync(path).mode & 0o777;
159
- if ((mode & 0o077) !== 0) {
160
- chmodSync(path, 0o600);
161
- console.error(
162
- `fullstackgtm: tightened ${path} from ${mode.toString(8).padStart(3, "0")} to 600 ` +
163
- "(it was readable or writable by other users).",
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(readFileSync(credentialsPath(), "utf8"));
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) : (enforceCredentialFileMode(credentialsPath()), readFileSync(credentialsPath(), "utf8"));
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: credential.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: minted.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
- * Residual gap (documented, not defended here): TOCTOU DNS rebinding between
332
- * our lookup and fetch's own resolution. Out of scope for fetching public
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
- let current = url;
408
- for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
409
- await assertPublicUrl(current);
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 = {
@@ -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 (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.",
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
  }