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/planStore.js CHANGED
@@ -1,7 +1,9 @@
1
- import { chmodSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
1
+ import { chmodSync, closeSync, mkdirSync, openSync, readdirSync, unlinkSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { join } from "node:path";
3
4
  import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.js";
4
5
  import { computeApprovalDigests, loadOrCreateSigningKey } from "./integrity.js";
6
+ import { assertNoSymlinkComponents, readSecureRegularFile, UnsafeManagedPathError } from "./secureFile.js";
5
7
  /**
6
8
  * Plans as JSON files in a directory (default `$FSGTM_HOME/plans`), one file
7
9
  * per plan id. Filesystem-shaped on purpose: greppable, diffable, and any
@@ -9,6 +11,11 @@ import { computeApprovalDigests, loadOrCreateSigningKey } from "./integrity.js";
9
11
  */
10
12
  export function createFilePlanStore(directory) {
11
13
  const dir = directory ?? join(credentialsDir(), "plans");
14
+ function ensurePlanDirectory() {
15
+ assertNoSymlinkComponents(dir);
16
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
17
+ assertNoSymlinkComponents(dir);
18
+ }
12
19
  function pathFor(planId) {
13
20
  if (!/^[\w.-]+$/.test(planId))
14
21
  throw new Error(`Invalid plan id: ${planId}`);
@@ -16,9 +23,12 @@ export function createFilePlanStore(directory) {
16
23
  }
17
24
  function read(planId) {
18
25
  try {
19
- return JSON.parse(readFileSync(pathFor(planId), "utf8"));
26
+ assertNoSymlinkComponents(dir);
27
+ return JSON.parse(readSecureRegularFile(pathFor(planId)));
20
28
  }
21
- catch {
29
+ catch (error) {
30
+ if (error instanceof UnsafeManagedPathError)
31
+ throw error;
22
32
  return null;
23
33
  }
24
34
  }
@@ -29,14 +39,18 @@ export function createFilePlanStore(directory) {
29
39
  // directory world-readable.
30
40
  if (!directory)
31
41
  ensureSecureHomeDir();
32
- mkdirSync(dir, { recursive: true, mode: 0o700 });
42
+ ensurePlanDirectory();
33
43
  try {
34
44
  chmodSync(dir, 0o700);
35
45
  }
36
46
  catch {
37
47
  // Non-POSIX filesystems ignore chmod.
38
48
  }
39
- const next = { ...stored, updatedAt: new Date().toISOString() };
49
+ const next = {
50
+ ...stored,
51
+ revision: (stored.revision ?? 0) + 1,
52
+ updatedAt: new Date().toISOString(),
53
+ };
40
54
  writeSecureFile(pathFor(stored.plan.id), `${JSON.stringify(next, null, 2)}\n`);
41
55
  return next;
42
56
  }
@@ -46,23 +60,56 @@ export function createFilePlanStore(directory) {
46
60
  throw new Error(`No stored plan with id ${planId}.`);
47
61
  return stored;
48
62
  }
63
+ function withPlanLock(planId, action) {
64
+ ensurePlanDirectory();
65
+ const lockPath = `${pathFor(planId)}.lock`;
66
+ let fd;
67
+ try {
68
+ fd = openSync(lockPath, "wx", 0o600);
69
+ }
70
+ catch (error) {
71
+ const code = error instanceof Error && "code" in error ? String(error.code) : "unknown";
72
+ if (code === "EEXIST") {
73
+ throw new Error(`Plan ${planId} is being updated by another process; retry after it finishes.`);
74
+ }
75
+ throw error;
76
+ }
77
+ try {
78
+ return action();
79
+ }
80
+ finally {
81
+ closeSync(fd);
82
+ try {
83
+ unlinkSync(lockPath);
84
+ }
85
+ catch { /* a missing lock is already released */ }
86
+ }
87
+ }
49
88
  return {
50
89
  async save(plan) {
51
- const now = new Date().toISOString();
52
- return write({
53
- plan,
54
- status: plan.status,
55
- approvedOperationIds: [],
56
- valueOverrides: {},
57
- runs: [],
58
- createdAt: now,
59
- updatedAt: now,
90
+ return withPlanLock(plan.id, () => {
91
+ const existing = read(plan.id);
92
+ if (existing && ["approved", "applying", "applied"].includes(existing.status)) {
93
+ throw new Error(`Plan ${plan.id} is already ${existing.status}; refusing to replace its approval or apply history.`);
94
+ }
95
+ const now = new Date().toISOString();
96
+ return write({
97
+ plan,
98
+ status: plan.status,
99
+ approvedOperationIds: [],
100
+ valueOverrides: {},
101
+ revision: 0,
102
+ runs: [],
103
+ createdAt: now,
104
+ updatedAt: now,
105
+ });
60
106
  });
61
107
  },
62
108
  async get(planId) {
63
109
  return read(planId);
64
110
  },
65
111
  async list(status) {
112
+ assertNoSymlinkComponents(dir);
66
113
  let entries = [];
67
114
  try {
68
115
  entries = readdirSync(dir);
@@ -78,46 +125,145 @@ export function createFilePlanStore(directory) {
78
125
  return status ? plans.filter((stored) => stored.status === status) : plans;
79
126
  },
80
127
  async approveOperations(planId, operationIds, valueOverrides = {}) {
81
- const stored = mustRead(planId);
82
- if (stored.status === "applied") {
83
- throw new Error(`Plan ${planId} has already been applied.`);
84
- }
85
- if (stored.status === "rejected") {
86
- throw new Error(`Plan ${planId} was rejected; re-run the audit for a fresh plan.`);
87
- }
88
- const known = new Set(stored.plan.operations.map((operation) => operation.id));
89
- for (const operationId of operationIds) {
90
- if (!known.has(operationId)) {
91
- throw new Error(`Plan ${planId} has no operation ${operationId}.`);
128
+ return withPlanLock(planId, () => {
129
+ const stored = mustRead(planId);
130
+ if (stored.status === "applied") {
131
+ throw new Error(`Plan ${planId} has already been applied.`);
92
132
  }
93
- }
94
- const approvedOperationIds = Array.from(new Set([...stored.approvedOperationIds, ...operationIds]));
95
- const mergedOverrides = { ...stored.valueOverrides, ...valueOverrides };
96
- // Bind the approval to the operation content so apply can detect a
97
- // post-approval edit. Recompute over ALL approved ops (a later approve
98
- // call may add overrides that change an earlier op's resolved value).
99
- const approvalDigests = computeApprovalDigests(stored.plan.operations, approvedOperationIds, mergedOverrides, loadOrCreateSigningKey());
100
- return write({
101
- ...stored,
102
- status: "approved",
103
- approvedOperationIds,
104
- valueOverrides: mergedOverrides,
105
- approvalDigests,
133
+ if (stored.status === "rejected") {
134
+ throw new Error(`Plan ${planId} was rejected; re-run the audit for a fresh plan.`);
135
+ }
136
+ if (stored.status === "applying" || stored.applyClaim) {
137
+ throw new Error(`Plan ${planId} is applying; approvals are frozen until the attempt is resolved.`);
138
+ }
139
+ const known = new Set(stored.plan.operations.map((operation) => operation.id));
140
+ for (const operationId of operationIds) {
141
+ if (!known.has(operationId)) {
142
+ throw new Error(`Plan ${planId} has no operation ${operationId}.`);
143
+ }
144
+ }
145
+ const approvedOperationIds = Array.from(new Set([...stored.approvedOperationIds, ...operationIds]));
146
+ const mergedOverrides = { ...stored.valueOverrides, ...valueOverrides };
147
+ // Bind the approval to the operation content so apply can detect a
148
+ // post-approval edit. Recompute over ALL approved ops (a later approve
149
+ // call may add overrides that change an earlier op's resolved value).
150
+ const approvalDigests = computeApprovalDigests(stored.plan.operations, approvedOperationIds, mergedOverrides, loadOrCreateSigningKey());
151
+ return write({
152
+ ...stored,
153
+ status: "approved",
154
+ approvedOperationIds,
155
+ valueOverrides: mergedOverrides,
156
+ approvalDigests,
157
+ });
106
158
  });
107
159
  },
108
160
  async reject(planId) {
109
- const stored = mustRead(planId);
110
- if (stored.status === "applied") {
111
- throw new Error(`Plan ${planId} has already been applied.`);
112
- }
113
- return write({ ...stored, status: "rejected", approvedOperationIds: [] });
161
+ return withPlanLock(planId, () => {
162
+ const stored = mustRead(planId);
163
+ if (stored.status === "applied") {
164
+ throw new Error(`Plan ${planId} has already been applied.`);
165
+ }
166
+ if (stored.status === "applying" || stored.applyClaim) {
167
+ throw new Error(`Plan ${planId} is applying and cannot be rejected until the attempt is resolved.`);
168
+ }
169
+ return write({ ...stored, status: "rejected", approvedOperationIds: [], applyClaim: undefined });
170
+ });
171
+ },
172
+ async claimApply(planId, metadata) {
173
+ return withPlanLock(planId, () => {
174
+ const stored = mustRead(planId);
175
+ if (stored.status === "applying" || stored.applyClaim) {
176
+ throw new Error(`Plan ${planId} is already applying; inspect its recorded attempts before any retry.`);
177
+ }
178
+ if (stored.status !== "approved") {
179
+ throw new Error(`Plan ${planId} is ${stored.status}; only an approved plan can be claimed for apply.`);
180
+ }
181
+ const claimId = randomUUID();
182
+ const claimedAt = new Date().toISOString();
183
+ const claimed = write({
184
+ ...stored,
185
+ status: "applying",
186
+ applyClaim: {
187
+ id: claimId,
188
+ claimedAt,
189
+ revision: stored.revision ?? 0,
190
+ },
191
+ applyAttempts: [...(stored.applyAttempts ?? []), {
192
+ id: claimId,
193
+ claimedAt,
194
+ provider: metadata?.provider ?? "unknown",
195
+ source: metadata?.source ?? "cli",
196
+ status: "in_progress",
197
+ }],
198
+ });
199
+ return { stored: claimed, claimId };
200
+ });
114
201
  },
115
- async recordRun(planId, run) {
116
- const stored = mustRead(planId);
117
- return write({
118
- ...stored,
119
- status: run.status === "applied" || run.status === "partial" ? "applied" : stored.status,
120
- runs: [...stored.runs, run],
202
+ async recordRun(planId, run, claimId) {
203
+ return withPlanLock(planId, () => {
204
+ const stored = mustRead(planId);
205
+ if (stored.status !== "applying" || stored.applyClaim?.id !== claimId) {
206
+ throw new Error(`Refusing to record run for plan ${planId}: apply claim is missing or does not match.`);
207
+ }
208
+ return write({
209
+ ...stored,
210
+ status: run.status === "applied" || run.status === "partial" ? "applied" : "approved",
211
+ applyClaim: undefined,
212
+ applyAttempts: (stored.applyAttempts ?? []).map((attempt) => attempt.id === claimId
213
+ ? { ...attempt, status: "completed", resolvedAt: new Date().toISOString() }
214
+ : attempt),
215
+ runs: [...stored.runs, run],
216
+ });
217
+ });
218
+ },
219
+ async abortApplyPreflight(planId, claimId, note) {
220
+ return withPlanLock(planId, () => {
221
+ const stored = mustRead(planId);
222
+ if (stored.status !== "applying" || stored.applyClaim?.id !== claimId) {
223
+ throw new Error(`Refusing to abort preflight for plan ${planId}: apply claim is missing or does not match.`);
224
+ }
225
+ return write({
226
+ ...stored,
227
+ status: "approved",
228
+ applyClaim: undefined,
229
+ applyAttempts: (stored.applyAttempts ?? []).map((attempt) => attempt.id === claimId
230
+ ? { ...attempt, status: "preflight_aborted", resolvedAt: new Date().toISOString(), note }
231
+ : attempt),
232
+ });
233
+ });
234
+ },
235
+ async markApplyUncertain(planId, claimId) {
236
+ return withPlanLock(planId, () => {
237
+ const stored = mustRead(planId);
238
+ if (stored.status !== "applying" || stored.applyClaim?.id !== claimId)
239
+ return stored;
240
+ return write({
241
+ ...stored,
242
+ applyAttempts: (stored.applyAttempts ?? []).map((attempt) => attempt.id === claimId
243
+ ? { ...attempt, status: "uncertain", note: "Apply exited without a completed run; some provider writes may have landed." }
244
+ : attempt),
245
+ });
246
+ });
247
+ },
248
+ async recoverApply(planId) {
249
+ return withPlanLock(planId, () => {
250
+ const stored = mustRead(planId);
251
+ if (stored.status !== "applying" || !stored.applyClaim) {
252
+ throw new Error(`Plan ${planId} has no unresolved apply attempt.`);
253
+ }
254
+ // Recovery is deliberately privilege-decaying: discard the old approval.
255
+ // An operator must inspect the provider, then explicitly approve a fresh attempt.
256
+ return write({
257
+ ...stored,
258
+ status: "needs_approval",
259
+ applyClaim: undefined,
260
+ approvedOperationIds: [],
261
+ valueOverrides: {},
262
+ approvalDigests: undefined,
263
+ applyAttempts: (stored.applyAttempts ?? []).map((attempt) => attempt.id === stored.applyClaim.id
264
+ ? { ...attempt, status: "uncertain", resolvedAt: new Date().toISOString(), note: "Operator acknowledged uncertain provider state; approval cleared before any retry." }
265
+ : attempt),
266
+ });
121
267
  });
122
268
  },
123
269
  };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A persistence-safe provider failure. Third-party response bodies are
3
+ * deliberately excluded: they can reflect credentials, filters, or PII and
4
+ * errors are rendered in terminals, MCP responses, and scheduled-run files.
5
+ */
6
+ export declare class ProviderHttpError extends Error {
7
+ readonly code = "PROVIDER_HTTP_ERROR";
8
+ readonly provider: string;
9
+ readonly operation: string;
10
+ readonly status: number;
11
+ readonly retryable: boolean;
12
+ constructor(provider: string, operation: string, status: number, retryable?: boolean);
13
+ toJSON(): {
14
+ code: string;
15
+ provider: string;
16
+ operation: string;
17
+ status: number;
18
+ retryable: boolean;
19
+ message: string;
20
+ };
21
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * A persistence-safe provider failure. Third-party response bodies are
3
+ * deliberately excluded: they can reflect credentials, filters, or PII and
4
+ * errors are rendered in terminals, MCP responses, and scheduled-run files.
5
+ */
6
+ export class ProviderHttpError extends Error {
7
+ code = "PROVIDER_HTTP_ERROR";
8
+ provider;
9
+ operation;
10
+ status;
11
+ retryable;
12
+ constructor(provider, operation, status, retryable = status === 429 || status >= 500) {
13
+ super(`${provider} ${operation} failed: HTTP ${status}.`);
14
+ this.name = "ProviderHttpError";
15
+ this.provider = provider;
16
+ this.operation = operation;
17
+ this.status = status;
18
+ this.retryable = retryable;
19
+ }
20
+ toJSON() {
21
+ return {
22
+ code: this.code,
23
+ provider: this.provider,
24
+ operation: this.operation,
25
+ status: this.status,
26
+ retryable: this.retryable,
27
+ message: this.message,
28
+ };
29
+ }
30
+ }
@@ -0,0 +1,28 @@
1
+ export declare const DEFAULT_PUBLIC_HTTP_MAX_BYTES = 5000000;
2
+ export type PublicAddress = {
3
+ address: string;
4
+ family: number;
5
+ };
6
+ export type PublicLookup = (hostname: string) => Promise<PublicAddress[]>;
7
+ export declare function ipIsPrivate(ip: string): boolean;
8
+ /** Resolve once and reject the entire hostname if any answer is non-public. */
9
+ export declare function resolvePublicAddresses(hostname: string, lookup?: PublicLookup): Promise<PublicAddress[]>;
10
+ export declare function assertPublicUrl(rawUrl: string, lookup?: PublicLookup): Promise<URL>;
11
+ export type PublicHttpResult = {
12
+ status: number;
13
+ headers: Headers;
14
+ body: Uint8Array;
15
+ finalUrl: string;
16
+ };
17
+ type HopResult = Omit<PublicHttpResult, "finalUrl">;
18
+ export type PublicRequestHop = (url: URL, addresses: PublicAddress[], headers: Record<string, string>, timeoutMs: number, maxBytes: number) => Promise<HopResult>;
19
+ /** Public-only GET with DNS pinning, bounded bodies, and per-hop redirect validation. */
20
+ export declare function publicHttpGet(rawUrl: string, options?: {
21
+ headers?: Record<string, string>;
22
+ timeoutMs?: number;
23
+ maxBytes?: number;
24
+ maxRedirects?: number;
25
+ lookup?: PublicLookup;
26
+ requestHop?: PublicRequestHop;
27
+ }): Promise<PublicHttpResult>;
28
+ export {};
@@ -0,0 +1,143 @@
1
+ import { lookup as dnsLookup } from "node:dns/promises";
2
+ import { request as httpRequest } from "node:http";
3
+ import { request as httpsRequest } from "node:https";
4
+ import { isIP } from "node:net";
5
+ export const DEFAULT_PUBLIC_HTTP_MAX_BYTES = 5_000_000;
6
+ function ipv4IsPrivate(ip) {
7
+ const parts = ip.split(".").map(Number);
8
+ if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
9
+ return true;
10
+ const [a, b, c] = parts;
11
+ return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) ||
12
+ (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) ||
13
+ (a === 192 && b === 0 && (c === 0 || c === 2)) || (a === 192 && b === 168) ||
14
+ (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
15
+ (a === 203 && b === 0 && c === 113) || a >= 224;
16
+ }
17
+ function embeddedIpv4IsPrivate(high, low) {
18
+ const hi = Number.parseInt(high, 16);
19
+ const lo = Number.parseInt(low, 16);
20
+ if (!Number.isInteger(hi) || !Number.isInteger(lo) || hi > 0xffff || lo > 0xffff)
21
+ return true;
22
+ return ipv4IsPrivate(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
23
+ }
24
+ export function ipIsPrivate(ip) {
25
+ const family = isIP(ip);
26
+ if (family === 4)
27
+ return ipv4IsPrivate(ip);
28
+ if (family !== 6)
29
+ return true;
30
+ const lower = ip.toLowerCase();
31
+ if (lower === "::" || lower === "::1")
32
+ return true;
33
+ const embedded = lower.match(/^::(?:ffff:)?([0-9a-f]+):([0-9a-f]+)$/);
34
+ if (embedded)
35
+ return embeddedIpv4IsPrivate(embedded[1], embedded[2]);
36
+ const embeddedDotted = lower.match(/^::(?:ffff:)?(.+\..+)$/);
37
+ if (embeddedDotted)
38
+ return ipv4IsPrivate(embeddedDotted[1]);
39
+ const nat64 = lower.match(/^64:ff9b::([0-9a-f]+):([0-9a-f]+)$/);
40
+ if (nat64)
41
+ return embeddedIpv4IsPrivate(nat64[1], nat64[2]);
42
+ const sixToFour = lower.match(/^2002:([0-9a-f]+):([0-9a-f]+):/);
43
+ if (sixToFour)
44
+ return embeddedIpv4IsPrivate(sixToFour[1], sixToFour[2]);
45
+ return /^(?:fc|fd)/.test(lower) || /^(?:fe8|fe9|fea|feb)/.test(lower) ||
46
+ lower.startsWith("ff") || lower.startsWith("100:") ||
47
+ lower.startsWith("2001:db8:") || lower.startsWith("2001:0:") ||
48
+ lower.startsWith("2001:10:") || lower.startsWith("3fff:") ||
49
+ lower.startsWith("64:ff9b:1:");
50
+ }
51
+ const systemLookup = async (hostname) => (await dnsLookup(hostname, { all: true, verbatim: true })).map(({ address, family }) => ({ address, family }));
52
+ /** Resolve once and reject the entire hostname if any answer is non-public. */
53
+ export async function resolvePublicAddresses(hostname, lookup = systemLookup) {
54
+ const literal = hostname.replace(/^\[|\]$/g, "");
55
+ const family = isIP(literal);
56
+ const addresses = family ? [{ address: literal, family }] : await lookup(literal);
57
+ if (addresses.length === 0)
58
+ throw new Error(`public HTTP: ${literal} did not resolve to an address.`);
59
+ for (const { address } of addresses) {
60
+ if (ipIsPrivate(address))
61
+ throw new Error(`public HTTP refuses ${literal} — it resolves to private/internal address ${address} (SSRF guard).`);
62
+ }
63
+ return addresses;
64
+ }
65
+ export async function assertPublicUrl(rawUrl, lookup = systemLookup) {
66
+ let url;
67
+ try {
68
+ url = new URL(rawUrl);
69
+ }
70
+ catch {
71
+ throw new Error(`market capture: "${rawUrl}" is not a valid URL.`);
72
+ }
73
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
74
+ throw new Error(`market capture refuses ${url.protocol} URLs (only http/https): ${rawUrl}`);
75
+ }
76
+ const host = url.hostname.replace(/^\[|\]$/g, "");
77
+ if (isIP(host) && ipIsPrivate(host)) {
78
+ throw new Error(`market capture refuses private/loopback address ${host} (SSRF guard).`);
79
+ }
80
+ const addresses = await resolvePublicAddresses(host, lookup);
81
+ // Keep the resolved set available to the request path without changing the public API.
82
+ Object.defineProperty(url, "__publicAddresses", { value: addresses });
83
+ return url;
84
+ }
85
+ const requestHop = (url, addresses, headers, timeoutMs, maxBytes) => new Promise((resolve, reject) => {
86
+ let cursor = 0;
87
+ const options = {
88
+ protocol: url.protocol, hostname: url.hostname, port: url.port || undefined,
89
+ path: `${url.pathname}${url.search}`, method: "GET", headers,
90
+ lookup: (_hostname, options, callback) => {
91
+ const requestedFamily = typeof options === "number" ? options : options?.family;
92
+ const eligible = requestedFamily ? addresses.filter((a) => a.family === requestedFamily) : addresses;
93
+ const selected = eligible[cursor++ % eligible.length];
94
+ if (!selected)
95
+ return callback(new Error("public HTTP: no validated address matches the requested family"), "", 0);
96
+ callback(null, selected.address, selected.family);
97
+ },
98
+ };
99
+ const req = (url.protocol === "https:" ? httpsRequest : httpRequest)(options, (res) => {
100
+ if (maxBytes === 0) {
101
+ resolve({ status: res.statusCode ?? 0, headers: new Headers(res.headers), body: new Uint8Array() });
102
+ res.destroy();
103
+ return;
104
+ }
105
+ const chunks = [];
106
+ let total = 0;
107
+ res.on("data", (chunk) => {
108
+ total += chunk.length;
109
+ if (total > maxBytes) {
110
+ req.destroy(new Error(`public HTTP response exceeds ${maxBytes} bytes`));
111
+ return;
112
+ }
113
+ chunks.push(chunk);
114
+ });
115
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, headers: new Headers(res.headers), body: Buffer.concat(chunks) }));
116
+ });
117
+ req.setTimeout(timeoutMs, () => req.destroy(new Error(`public HTTP request timed out after ${timeoutMs}ms`)));
118
+ req.on("error", reject);
119
+ req.end();
120
+ });
121
+ const SENSITIVE_HEADERS = new Set(["authorization", "cookie", "proxy-authorization"]);
122
+ /** Public-only GET with DNS pinning, bounded bodies, and per-hop redirect validation. */
123
+ export async function publicHttpGet(rawUrl, options = {}) {
124
+ let current = rawUrl;
125
+ let headers = { ...(options.headers ?? {}) };
126
+ const maxRedirects = options.maxRedirects ?? 5;
127
+ for (let hop = 0; hop <= maxRedirects; hop++) {
128
+ const url = await assertPublicUrl(current, options.lookup);
129
+ const addresses = url.__publicAddresses;
130
+ const result = await (options.requestHop ?? requestHop)(url, addresses, headers, options.timeoutMs ?? 15_000, options.maxBytes ?? DEFAULT_PUBLIC_HTTP_MAX_BYTES);
131
+ const location = result.headers.get("location");
132
+ if (result.status >= 300 && result.status < 400 && location) {
133
+ const next = new URL(location, url);
134
+ if (next.origin !== url.origin) {
135
+ headers = Object.fromEntries(Object.entries(headers).filter(([key]) => !SENSITIVE_HEADERS.has(key.toLowerCase())));
136
+ }
137
+ current = next.toString();
138
+ continue;
139
+ }
140
+ return { ...result, finalUrl: current };
141
+ }
142
+ throw new Error(`public HTTP: too many redirects (>${maxRedirects}) for ${rawUrl}`);
143
+ }
@@ -0,0 +1,39 @@
1
+ import type { CanonicalGtmSnapshot } from "./types.ts";
2
+ export type StakeholderRole = "economic_buyer" | "decision_maker" | "technical_buyer" | "champion" | "influencer" | "unknown";
3
+ export type StakeholderSentiment = "positive" | "neutral" | "negative" | "unknown";
4
+ export type StakeholderNode = {
5
+ contactId: string;
6
+ name: string;
7
+ title?: string;
8
+ email?: string;
9
+ role: StakeholderRole;
10
+ sentiment: StakeholderSentiment;
11
+ lastActivityAt?: string;
12
+ evidence: string[];
13
+ };
14
+ export type RelationshipMap = {
15
+ account: {
16
+ accountId: string;
17
+ name: string;
18
+ domain?: string;
19
+ };
20
+ stakeholders: StakeholderNode[];
21
+ openDeals: Array<{
22
+ dealId: string;
23
+ name: string;
24
+ stage?: string;
25
+ amount?: number;
26
+ ownerId?: string;
27
+ }>;
28
+ gaps: string[];
29
+ counts: {
30
+ stakeholders: number;
31
+ openDeals: number;
32
+ activities: number;
33
+ };
34
+ };
35
+ export declare function buildRelationshipMap(snapshot: CanonicalGtmSnapshot, selector: {
36
+ accountId?: string;
37
+ domain?: string;
38
+ }): RelationshipMap;
39
+ export declare function relationshipMapToMarkdown(map: RelationshipMap): string;