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
package/src/planStore.ts 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.ts";
4
5
  import { computeApprovalDigests, loadOrCreateSigningKey } from "./integrity.ts";
6
+ import { assertNoSymlinkComponents, readSecureRegularFile, UnsafeManagedPathError } from "./secureFile.ts";
5
7
  import type { ApprovalStatus, PatchPlan, PatchPlanRun } from "./types.ts";
6
8
 
7
9
  /**
@@ -23,11 +25,32 @@ export type StoredPlan = {
23
25
  * file is caught instead of written. Absent on plans approved before 0.26.0.
24
26
  */
25
27
  approvalDigests?: Record<string, string>;
28
+ /** Monotonic store revision used to make lifecycle changes explicit. */
29
+ revision: number;
30
+ /** Present only while one process owns the right to apply this plan. */
31
+ applyClaim?: {
32
+ id: string;
33
+ claimedAt: string;
34
+ revision: number;
35
+ };
36
+ /** Durable journal of apply ownership. An unresolved entry must never be replayed automatically. */
37
+ applyAttempts?: ApplyAttempt[];
26
38
  runs: PatchPlanRun[];
27
39
  createdAt: string;
28
40
  updatedAt: string;
29
41
  };
30
42
 
43
+ export type ApplyAttempt = {
44
+ id: string;
45
+ claimedAt: string;
46
+ provider: string;
47
+ source: "cli" | "fix" | "mcp";
48
+ status: "in_progress" | "preflight_aborted" | "completed" | "uncertain";
49
+ resolvedAt?: string;
50
+ /** Deliberately generic: provider errors can contain CRM data or credentials. */
51
+ note?: string;
52
+ };
53
+
31
54
  export interface PlanStore {
32
55
  save(plan: PatchPlan): Promise<StoredPlan>;
33
56
  get(planId: string): Promise<StoredPlan | null>;
@@ -38,7 +61,11 @@ export interface PlanStore {
38
61
  valueOverrides?: Record<string, unknown>,
39
62
  ): Promise<StoredPlan>;
40
63
  reject(planId: string): Promise<StoredPlan>;
41
- recordRun(planId: string, run: PatchPlanRun): Promise<StoredPlan>;
64
+ claimApply(planId: string, metadata?: Pick<ApplyAttempt, "provider" | "source">): Promise<{ stored: StoredPlan; claimId: string }>;
65
+ recordRun(planId: string, run: PatchPlanRun, claimId: string): Promise<StoredPlan>;
66
+ abortApplyPreflight(planId: string, claimId: string, note: string): Promise<StoredPlan>;
67
+ markApplyUncertain(planId: string, claimId: string): Promise<StoredPlan>;
68
+ recoverApply(planId: string): Promise<StoredPlan>;
42
69
  }
43
70
 
44
71
  /**
@@ -49,6 +76,12 @@ export interface PlanStore {
49
76
  export function createFilePlanStore(directory?: string): PlanStore {
50
77
  const dir = directory ?? join(credentialsDir(), "plans");
51
78
 
79
+ function ensurePlanDirectory(): void {
80
+ assertNoSymlinkComponents(dir);
81
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
82
+ assertNoSymlinkComponents(dir);
83
+ }
84
+
52
85
  function pathFor(planId: string) {
53
86
  if (!/^[\w.-]+$/.test(planId)) throw new Error(`Invalid plan id: ${planId}`);
54
87
  return join(dir, `${planId}.json`);
@@ -56,8 +89,10 @@ export function createFilePlanStore(directory?: string): PlanStore {
56
89
 
57
90
  function read(planId: string): StoredPlan | null {
58
91
  try {
59
- return JSON.parse(readFileSync(pathFor(planId), "utf8")) as StoredPlan;
60
- } catch {
92
+ assertNoSymlinkComponents(dir);
93
+ return JSON.parse(readSecureRegularFile(pathFor(planId))) as StoredPlan;
94
+ } catch (error) {
95
+ if (error instanceof UnsafeManagedPathError) throw error;
61
96
  return null;
62
97
  }
63
98
  }
@@ -68,13 +103,17 @@ export function createFilePlanStore(directory?: string): PlanStore {
68
103
  // otherwise an `audit --save` before any `login` would create the home
69
104
  // directory world-readable.
70
105
  if (!directory) ensureSecureHomeDir();
71
- mkdirSync(dir, { recursive: true, mode: 0o700 });
106
+ ensurePlanDirectory();
72
107
  try {
73
108
  chmodSync(dir, 0o700);
74
109
  } catch {
75
110
  // Non-POSIX filesystems ignore chmod.
76
111
  }
77
- const next = { ...stored, updatedAt: new Date().toISOString() };
112
+ const next = {
113
+ ...stored,
114
+ revision: (stored.revision ?? 0) + 1,
115
+ updatedAt: new Date().toISOString(),
116
+ };
78
117
  writeSecureFile(pathFor(stored.plan.id), `${JSON.stringify(next, null, 2)}\n`);
79
118
  return next;
80
119
  }
@@ -85,17 +124,47 @@ export function createFilePlanStore(directory?: string): PlanStore {
85
124
  return stored;
86
125
  }
87
126
 
127
+ function withPlanLock<T>(planId: string, action: () => T): T {
128
+ ensurePlanDirectory();
129
+ const lockPath = `${pathFor(planId)}.lock`;
130
+ let fd: number;
131
+ try {
132
+ fd = openSync(lockPath, "wx", 0o600);
133
+ } catch (error) {
134
+ const code = error instanceof Error && "code" in error ? String(error.code) : "unknown";
135
+ if (code === "EEXIST") {
136
+ throw new Error(`Plan ${planId} is being updated by another process; retry after it finishes.`);
137
+ }
138
+ throw error;
139
+ }
140
+ try {
141
+ return action();
142
+ } finally {
143
+ closeSync(fd);
144
+ try { unlinkSync(lockPath); } catch { /* a missing lock is already released */ }
145
+ }
146
+ }
147
+
88
148
  return {
89
149
  async save(plan) {
90
- const now = new Date().toISOString();
91
- return write({
92
- plan,
93
- status: plan.status,
94
- approvedOperationIds: [],
95
- valueOverrides: {},
96
- runs: [],
97
- createdAt: now,
98
- updatedAt: now,
150
+ return withPlanLock(plan.id, () => {
151
+ const existing = read(plan.id);
152
+ if (existing && ["approved", "applying", "applied"].includes(existing.status)) {
153
+ throw new Error(
154
+ `Plan ${plan.id} is already ${existing.status}; refusing to replace its approval or apply history.`,
155
+ );
156
+ }
157
+ const now = new Date().toISOString();
158
+ return write({
159
+ plan,
160
+ status: plan.status,
161
+ approvedOperationIds: [],
162
+ valueOverrides: {},
163
+ revision: 0,
164
+ runs: [],
165
+ createdAt: now,
166
+ updatedAt: now,
167
+ });
99
168
  });
100
169
  },
101
170
 
@@ -104,6 +173,7 @@ export function createFilePlanStore(directory?: string): PlanStore {
104
173
  },
105
174
 
106
175
  async list(status) {
176
+ assertNoSymlinkComponents(dir);
107
177
  let entries: string[] = [];
108
178
  try {
109
179
  entries = readdirSync(dir);
@@ -119,55 +189,163 @@ export function createFilePlanStore(directory?: string): PlanStore {
119
189
  },
120
190
 
121
191
  async approveOperations(planId, operationIds, valueOverrides = {}) {
122
- const stored = mustRead(planId);
123
- if (stored.status === "applied") {
124
- throw new Error(`Plan ${planId} has already been applied.`);
125
- }
126
- if (stored.status === "rejected") {
127
- throw new Error(`Plan ${planId} was rejected; re-run the audit for a fresh plan.`);
128
- }
129
- const known = new Set(stored.plan.operations.map((operation) => operation.id));
130
- for (const operationId of operationIds) {
131
- if (!known.has(operationId)) {
132
- throw new Error(`Plan ${planId} has no operation ${operationId}.`);
192
+ return withPlanLock(planId, () => {
193
+ const stored = mustRead(planId);
194
+ if (stored.status === "applied") {
195
+ throw new Error(`Plan ${planId} has already been applied.`);
133
196
  }
134
- }
135
- const approvedOperationIds = Array.from(
136
- new Set([...stored.approvedOperationIds, ...operationIds]),
137
- );
138
- const mergedOverrides = { ...stored.valueOverrides, ...valueOverrides };
139
- // Bind the approval to the operation content so apply can detect a
140
- // post-approval edit. Recompute over ALL approved ops (a later approve
141
- // call may add overrides that change an earlier op's resolved value).
142
- const approvalDigests = computeApprovalDigests(
143
- stored.plan.operations,
144
- approvedOperationIds,
145
- mergedOverrides,
146
- loadOrCreateSigningKey(),
147
- );
148
- return write({
149
- ...stored,
150
- status: "approved",
151
- approvedOperationIds,
152
- valueOverrides: mergedOverrides,
153
- approvalDigests,
197
+ if (stored.status === "rejected") {
198
+ throw new Error(`Plan ${planId} was rejected; re-run the audit for a fresh plan.`);
199
+ }
200
+ if (stored.status === "applying" || stored.applyClaim) {
201
+ throw new Error(`Plan ${planId} is applying; approvals are frozen until the attempt is resolved.`);
202
+ }
203
+ const known = new Set(stored.plan.operations.map((operation) => operation.id));
204
+ for (const operationId of operationIds) {
205
+ if (!known.has(operationId)) {
206
+ throw new Error(`Plan ${planId} has no operation ${operationId}.`);
207
+ }
208
+ }
209
+ const approvedOperationIds = Array.from(
210
+ new Set([...stored.approvedOperationIds, ...operationIds]),
211
+ );
212
+ const mergedOverrides = { ...stored.valueOverrides, ...valueOverrides };
213
+ // Bind the approval to the operation content so apply can detect a
214
+ // post-approval edit. Recompute over ALL approved ops (a later approve
215
+ // call may add overrides that change an earlier op's resolved value).
216
+ const approvalDigests = computeApprovalDigests(
217
+ stored.plan.operations,
218
+ approvedOperationIds,
219
+ mergedOverrides,
220
+ loadOrCreateSigningKey(),
221
+ );
222
+ return write({
223
+ ...stored,
224
+ status: "approved",
225
+ approvedOperationIds,
226
+ valueOverrides: mergedOverrides,
227
+ approvalDigests,
228
+ });
154
229
  });
155
230
  },
156
231
 
157
232
  async reject(planId) {
158
- const stored = mustRead(planId);
159
- if (stored.status === "applied") {
160
- throw new Error(`Plan ${planId} has already been applied.`);
161
- }
162
- return write({ ...stored, status: "rejected", approvedOperationIds: [] });
233
+ return withPlanLock(planId, () => {
234
+ const stored = mustRead(planId);
235
+ if (stored.status === "applied") {
236
+ throw new Error(`Plan ${planId} has already been applied.`);
237
+ }
238
+ if (stored.status === "applying" || stored.applyClaim) {
239
+ throw new Error(`Plan ${planId} is applying and cannot be rejected until the attempt is resolved.`);
240
+ }
241
+ return write({ ...stored, status: "rejected", approvedOperationIds: [], applyClaim: undefined });
242
+ });
163
243
  },
164
244
 
165
- async recordRun(planId, run) {
166
- const stored = mustRead(planId);
167
- return write({
168
- ...stored,
169
- status: run.status === "applied" || run.status === "partial" ? "applied" : stored.status,
170
- runs: [...stored.runs, run],
245
+ async claimApply(planId, metadata) {
246
+ return withPlanLock(planId, () => {
247
+ const stored = mustRead(planId);
248
+ if (stored.status === "applying" || stored.applyClaim) {
249
+ throw new Error(
250
+ `Plan ${planId} is already applying; inspect its recorded attempts before any retry.`,
251
+ );
252
+ }
253
+ if (stored.status !== "approved") {
254
+ throw new Error(`Plan ${planId} is ${stored.status}; only an approved plan can be claimed for apply.`);
255
+ }
256
+ const claimId = randomUUID();
257
+ const claimedAt = new Date().toISOString();
258
+ const claimed = write({
259
+ ...stored,
260
+ status: "applying",
261
+ applyClaim: {
262
+ id: claimId,
263
+ claimedAt,
264
+ revision: stored.revision ?? 0,
265
+ },
266
+ applyAttempts: [...(stored.applyAttempts ?? []), {
267
+ id: claimId,
268
+ claimedAt,
269
+ provider: metadata?.provider ?? "unknown",
270
+ source: metadata?.source ?? "cli",
271
+ status: "in_progress",
272
+ }],
273
+ });
274
+ return { stored: claimed, claimId };
275
+ });
276
+ },
277
+
278
+ async recordRun(planId, run, claimId) {
279
+ return withPlanLock(planId, () => {
280
+ const stored = mustRead(planId);
281
+ if (stored.status !== "applying" || stored.applyClaim?.id !== claimId) {
282
+ throw new Error(`Refusing to record run for plan ${planId}: apply claim is missing or does not match.`);
283
+ }
284
+ return write({
285
+ ...stored,
286
+ status: run.status === "applied" || run.status === "partial" ? "applied" : "approved",
287
+ applyClaim: undefined,
288
+ applyAttempts: (stored.applyAttempts ?? []).map((attempt) =>
289
+ attempt.id === claimId
290
+ ? { ...attempt, status: "completed" as const, resolvedAt: new Date().toISOString() }
291
+ : attempt),
292
+ runs: [...stored.runs, run],
293
+ });
294
+ });
295
+ },
296
+
297
+ async abortApplyPreflight(planId, claimId, note) {
298
+ return withPlanLock(planId, () => {
299
+ const stored = mustRead(planId);
300
+ if (stored.status !== "applying" || stored.applyClaim?.id !== claimId) {
301
+ throw new Error(`Refusing to abort preflight for plan ${planId}: apply claim is missing or does not match.`);
302
+ }
303
+ return write({
304
+ ...stored,
305
+ status: "approved",
306
+ applyClaim: undefined,
307
+ applyAttempts: (stored.applyAttempts ?? []).map((attempt) =>
308
+ attempt.id === claimId
309
+ ? { ...attempt, status: "preflight_aborted" as const, resolvedAt: new Date().toISOString(), note }
310
+ : attempt),
311
+ });
312
+ });
313
+ },
314
+
315
+ async markApplyUncertain(planId, claimId) {
316
+ return withPlanLock(planId, () => {
317
+ const stored = mustRead(planId);
318
+ if (stored.status !== "applying" || stored.applyClaim?.id !== claimId) return stored;
319
+ return write({
320
+ ...stored,
321
+ applyAttempts: (stored.applyAttempts ?? []).map((attempt) =>
322
+ attempt.id === claimId
323
+ ? { ...attempt, status: "uncertain" as const, note: "Apply exited without a completed run; some provider writes may have landed." }
324
+ : attempt),
325
+ });
326
+ });
327
+ },
328
+
329
+ async recoverApply(planId) {
330
+ return withPlanLock(planId, () => {
331
+ const stored = mustRead(planId);
332
+ if (stored.status !== "applying" || !stored.applyClaim) {
333
+ throw new Error(`Plan ${planId} has no unresolved apply attempt.`);
334
+ }
335
+ // Recovery is deliberately privilege-decaying: discard the old approval.
336
+ // An operator must inspect the provider, then explicitly approve a fresh attempt.
337
+ return write({
338
+ ...stored,
339
+ status: "needs_approval",
340
+ applyClaim: undefined,
341
+ approvedOperationIds: [],
342
+ valueOverrides: {},
343
+ approvalDigests: undefined,
344
+ applyAttempts: (stored.applyAttempts ?? []).map((attempt) =>
345
+ attempt.id === stored.applyClaim!.id
346
+ ? { ...attempt, status: "uncertain" as const, resolvedAt: new Date().toISOString(), note: "Operator acknowledged uncertain provider state; approval cleared before any retry." }
347
+ : attempt),
348
+ });
171
349
  });
172
350
  },
173
351
  };
@@ -0,0 +1,37 @@
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
+ readonly code = "PROVIDER_HTTP_ERROR";
8
+ readonly provider: string;
9
+ readonly operation: string;
10
+ readonly status: number;
11
+ readonly retryable: boolean;
12
+
13
+ constructor(
14
+ provider: string,
15
+ operation: string,
16
+ status: number,
17
+ retryable = status === 429 || status >= 500,
18
+ ) {
19
+ super(`${provider} ${operation} failed: HTTP ${status}.`);
20
+ this.name = "ProviderHttpError";
21
+ this.provider = provider;
22
+ this.operation = operation;
23
+ this.status = status;
24
+ this.retryable = retryable;
25
+ }
26
+
27
+ toJSON() {
28
+ return {
29
+ code: this.code,
30
+ provider: this.provider,
31
+ operation: this.operation,
32
+ status: this.status,
33
+ retryable: this.retryable,
34
+ message: this.message,
35
+ };
36
+ }
37
+ }
@@ -0,0 +1,147 @@
1
+ import { lookup as dnsLookup } from "node:dns/promises";
2
+ import { request as httpRequest, type RequestOptions } from "node:http";
3
+ import { request as httpsRequest } from "node:https";
4
+ import { isIP } from "node:net";
5
+
6
+ export const DEFAULT_PUBLIC_HTTP_MAX_BYTES = 5_000_000;
7
+
8
+ export type PublicAddress = { address: string; family: number };
9
+ export type PublicLookup = (hostname: string) => Promise<PublicAddress[]>;
10
+
11
+ function ipv4IsPrivate(ip: string): boolean {
12
+ const parts = ip.split(".").map(Number);
13
+ if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
14
+ const [a, b, c] = parts;
15
+ return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) ||
16
+ (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) ||
17
+ (a === 192 && b === 0 && (c === 0 || c === 2)) || (a === 192 && b === 168) ||
18
+ (a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
19
+ (a === 203 && b === 0 && c === 113) || a >= 224;
20
+ }
21
+
22
+ function embeddedIpv4IsPrivate(high: string, low: string): boolean {
23
+ const hi = Number.parseInt(high, 16);
24
+ const lo = Number.parseInt(low, 16);
25
+ if (!Number.isInteger(hi) || !Number.isInteger(lo) || hi > 0xffff || lo > 0xffff) return true;
26
+ return ipv4IsPrivate(`${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`);
27
+ }
28
+
29
+ export function ipIsPrivate(ip: string): boolean {
30
+ const family = isIP(ip);
31
+ if (family === 4) return ipv4IsPrivate(ip);
32
+ if (family !== 6) return true;
33
+ const lower = ip.toLowerCase();
34
+ if (lower === "::" || lower === "::1") return true;
35
+ const embedded = lower.match(/^::(?:ffff:)?([0-9a-f]+):([0-9a-f]+)$/);
36
+ if (embedded) return embeddedIpv4IsPrivate(embedded[1], embedded[2]);
37
+ const embeddedDotted = lower.match(/^::(?:ffff:)?(.+\..+)$/);
38
+ if (embeddedDotted) return ipv4IsPrivate(embeddedDotted[1]);
39
+ const nat64 = lower.match(/^64:ff9b::([0-9a-f]+):([0-9a-f]+)$/);
40
+ if (nat64) return embeddedIpv4IsPrivate(nat64[1], nat64[2]);
41
+ const sixToFour = lower.match(/^2002:([0-9a-f]+):([0-9a-f]+):/);
42
+ if (sixToFour) return embeddedIpv4IsPrivate(sixToFour[1], sixToFour[2]);
43
+ return /^(?:fc|fd)/.test(lower) || /^(?:fe8|fe9|fea|feb)/.test(lower) ||
44
+ lower.startsWith("ff") || lower.startsWith("100:") ||
45
+ lower.startsWith("2001:db8:") || lower.startsWith("2001:0:") ||
46
+ lower.startsWith("2001:10:") || lower.startsWith("3fff:") ||
47
+ lower.startsWith("64:ff9b:1:");
48
+ }
49
+
50
+ const systemLookup: PublicLookup = async (hostname) =>
51
+ (await dnsLookup(hostname, { all: true, verbatim: true })).map(({ address, family }) => ({ address, family }));
52
+
53
+ /** Resolve once and reject the entire hostname if any answer is non-public. */
54
+ export async function resolvePublicAddresses(hostname: string, lookup: PublicLookup = systemLookup): Promise<PublicAddress[]> {
55
+ const literal = hostname.replace(/^\[|\]$/g, "");
56
+ const family = isIP(literal);
57
+ const addresses = family ? [{ address: literal, family }] : await lookup(literal);
58
+ if (addresses.length === 0) throw new Error(`public HTTP: ${literal} did not resolve to an address.`);
59
+ for (const { address } of addresses) {
60
+ if (ipIsPrivate(address)) throw new Error(`public HTTP refuses ${literal} — it resolves to private/internal address ${address} (SSRF guard).`);
61
+ }
62
+ return addresses;
63
+ }
64
+
65
+ export async function assertPublicUrl(rawUrl: string, lookup: PublicLookup = systemLookup): Promise<URL> {
66
+ let url: URL;
67
+ try { url = new URL(rawUrl); } catch { throw new Error(`market capture: "${rawUrl}" is not a valid URL.`); }
68
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
69
+ throw new Error(`market capture refuses ${url.protocol} URLs (only http/https): ${rawUrl}`);
70
+ }
71
+ const host = url.hostname.replace(/^\[|\]$/g, "");
72
+ if (isIP(host) && ipIsPrivate(host)) {
73
+ throw new Error(`market capture refuses private/loopback address ${host} (SSRF guard).`);
74
+ }
75
+ const addresses = await resolvePublicAddresses(host, lookup);
76
+ // Keep the resolved set available to the request path without changing the public API.
77
+ Object.defineProperty(url, "__publicAddresses", { value: addresses });
78
+ return url;
79
+ }
80
+
81
+ export type PublicHttpResult = { status: number; headers: Headers; body: Uint8Array; finalUrl: string };
82
+ type HopResult = Omit<PublicHttpResult, "finalUrl">;
83
+ export type PublicRequestHop = (url: URL, addresses: PublicAddress[], headers: Record<string, string>, timeoutMs: number, maxBytes: number) => Promise<HopResult>;
84
+
85
+ const requestHop: PublicRequestHop = (url, addresses, headers, timeoutMs, maxBytes) => new Promise((resolve, reject) => {
86
+ let cursor = 0;
87
+ const options: RequestOptions = {
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) return callback(new Error("public HTTP: no validated address matches the requested family"), "", 0);
95
+ callback(null, selected.address, selected.family);
96
+ },
97
+ };
98
+ const req = (url.protocol === "https:" ? httpsRequest : httpRequest)(options, (res) => {
99
+ if (maxBytes === 0) {
100
+ resolve({ status: res.statusCode ?? 0, headers: new Headers(res.headers as Record<string, string>), body: new Uint8Array() });
101
+ res.destroy();
102
+ return;
103
+ }
104
+ const chunks: Buffer[] = [];
105
+ let total = 0;
106
+ res.on("data", (chunk: Buffer) => {
107
+ total += chunk.length;
108
+ if (total > maxBytes) {
109
+ req.destroy(new Error(`public HTTP response exceeds ${maxBytes} bytes`));
110
+ return;
111
+ }
112
+ chunks.push(chunk);
113
+ });
114
+ res.on("end", () => resolve({ status: res.statusCode ?? 0, headers: new Headers(res.headers as Record<string, string>), body: Buffer.concat(chunks) }));
115
+ });
116
+ req.setTimeout(timeoutMs, () => req.destroy(new Error(`public HTTP request timed out after ${timeoutMs}ms`)));
117
+ req.on("error", reject);
118
+ req.end();
119
+ });
120
+
121
+ const SENSITIVE_HEADERS = new Set(["authorization", "cookie", "proxy-authorization"]);
122
+
123
+ /** Public-only GET with DNS pinning, bounded bodies, and per-hop redirect validation. */
124
+ export async function publicHttpGet(rawUrl: string, options: {
125
+ headers?: Record<string, string>; timeoutMs?: number; maxBytes?: number; maxRedirects?: number;
126
+ lookup?: PublicLookup; requestHop?: PublicRequestHop;
127
+ } = {}): Promise<PublicHttpResult> {
128
+ let current = rawUrl;
129
+ let headers = { ...(options.headers ?? {}) };
130
+ const maxRedirects = options.maxRedirects ?? 5;
131
+ for (let hop = 0; hop <= maxRedirects; hop++) {
132
+ const url = await assertPublicUrl(current, options.lookup);
133
+ const addresses = (url as URL & { __publicAddresses: PublicAddress[] }).__publicAddresses;
134
+ const result = await (options.requestHop ?? requestHop)(url, addresses, headers, options.timeoutMs ?? 15_000, options.maxBytes ?? DEFAULT_PUBLIC_HTTP_MAX_BYTES);
135
+ const location = result.headers.get("location");
136
+ if (result.status >= 300 && result.status < 400 && location) {
137
+ const next = new URL(location, url);
138
+ if (next.origin !== url.origin) {
139
+ headers = Object.fromEntries(Object.entries(headers).filter(([key]) => !SENSITIVE_HEADERS.has(key.toLowerCase())));
140
+ }
141
+ current = next.toString();
142
+ continue;
143
+ }
144
+ return { ...result, finalUrl: current };
145
+ }
146
+ throw new Error(`public HTTP: too many redirects (>${maxRedirects}) for ${rawUrl}`);
147
+ }