fullstackgtm 0.48.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/CONTRIBUTING.md +23 -4
- package/DATA-FLOWS.md +7 -2
- package/INSTALL_FOR_AGENTS.md +13 -4
- package/README.md +12 -1
- package/SECURITY.md +27 -3
- package/dist/cli/audit.js +10 -7
- package/dist/cli/auth.js +8 -4
- package/dist/cli/fix.js +20 -7
- package/dist/cli/help.js +10 -6
- package/dist/cli/market.js +180 -2
- package/dist/cli/plans.js +56 -5
- package/dist/cli/ui.js +2 -0
- package/dist/cli.js +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +23 -2
- package/dist/connectors/prospectSources.js +4 -11
- package/dist/connectors/salesforce.js +12 -5
- package/dist/connectors/salesforceAuth.d.ts +9 -0
- package/dist/connectors/salesforceAuth.js +47 -5
- package/dist/connectors/theirstack.d.ts +0 -19
- package/dist/connectors/theirstack.js +3 -10
- package/dist/credentials.js +36 -26
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/market.d.ts +1 -1
- package/dist/market.js +7 -127
- package/dist/marketClassify.d.ts +10 -0
- package/dist/marketClassify.js +52 -1
- package/dist/marketSourcing.js +7 -45
- package/dist/mcp.js +67 -115
- package/dist/planStore.d.ts +28 -1
- package/dist/planStore.js +195 -49
- package/dist/providerError.d.ts +21 -0
- package/dist/providerError.js +30 -0
- package/dist/publicHttp.d.ts +28 -0
- package/dist/publicHttp.js +143 -0
- package/dist/secureFile.d.ts +15 -0
- package/dist/secureFile.js +164 -0
- package/dist/types.d.ts +1 -1
- package/docs/api.md +29 -2
- package/docs/architecture.md +13 -2
- package/llms.txt +7 -0
- package/package.json +5 -3
- package/skills/fullstackgtm/SKILL.md +7 -1
- package/src/cli/audit.ts +10 -7
- package/src/cli/auth.ts +8 -4
- package/src/cli/fix.ts +28 -7
- package/src/cli/help.ts +10 -6
- package/src/cli/market.ts +181 -2
- package/src/cli/plans.ts +66 -5
- package/src/cli/ui.ts +1 -0
- package/src/cli.ts +1 -1
- package/src/config.ts +39 -1
- package/src/connectors/prospectSources.ts +4 -11
- package/src/connectors/salesforce.ts +12 -5
- package/src/connectors/salesforceAuth.ts +46 -6
- package/src/connectors/theirstack.ts +3 -10
- package/src/credentials.ts +47 -28
- package/src/index.ts +4 -0
- package/src/market.ts +7 -111
- package/src/marketClassify.ts +61 -1
- package/src/marketSourcing.ts +7 -43
- package/src/mcp.ts +93 -136
- package/src/planStore.ts +235 -57
- package/src/providerError.ts +37 -0
- package/src/publicHttp.ts +147 -0
- package/src/secureFile.ts +169 -0
- package/src/types.ts +1 -0
package/src/planStore.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { chmodSync, mkdirSync, readdirSync,
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
...stored,
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
+
}
|