fullstackgtm 0.48.0 → 0.50.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 (98) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/CONTRIBUTING.md +23 -4
  3. package/DATA-FLOWS.md +8 -2
  4. package/INSTALL_FOR_AGENTS.md +13 -4
  5. package/README.md +53 -1
  6. package/SECURITY.md +27 -3
  7. package/dist/acquireCheckpoint.d.ts +33 -0
  8. package/dist/acquireCheckpoint.js +149 -0
  9. package/dist/acquireLinkedIn.d.ts +2 -0
  10. package/dist/acquireLinkedIn.js +1 -1
  11. package/dist/cli/audit.js +10 -7
  12. package/dist/cli/auth.js +8 -4
  13. package/dist/cli/enrich.js +283 -79
  14. package/dist/cli/fix.js +20 -7
  15. package/dist/cli/help.js +23 -15
  16. package/dist/cli/market.js +180 -2
  17. package/dist/cli/plans.js +154 -9
  18. package/dist/cli/shared.d.ts +3 -1
  19. package/dist/cli/shared.js +2 -2
  20. package/dist/cli/ui.d.ts +10 -5
  21. package/dist/cli/ui.js +18 -5
  22. package/dist/cli.js +1 -1
  23. package/dist/config.d.ts +13 -1
  24. package/dist/config.js +23 -2
  25. package/dist/connectors/linkedin.d.ts +2 -0
  26. package/dist/connectors/linkedin.js +5 -0
  27. package/dist/connectors/prospectSources.d.ts +23 -0
  28. package/dist/connectors/prospectSources.js +25 -14
  29. package/dist/connectors/salesforce.js +12 -5
  30. package/dist/connectors/salesforceAuth.d.ts +9 -0
  31. package/dist/connectors/salesforceAuth.js +47 -5
  32. package/dist/connectors/theirstack.d.ts +0 -19
  33. package/dist/connectors/theirstack.js +3 -10
  34. package/dist/credentials.js +36 -26
  35. package/dist/enrich.d.ts +44 -1
  36. package/dist/hostedAcquireCheckpoint.d.ts +43 -0
  37. package/dist/hostedAcquireCheckpoint.js +129 -0
  38. package/dist/hostedPatchPlan.d.ts +87 -0
  39. package/dist/hostedPatchPlan.js +270 -0
  40. package/dist/icp.js +13 -2
  41. package/dist/index.d.ts +7 -3
  42. package/dist/index.js +6 -2
  43. package/dist/market.d.ts +1 -1
  44. package/dist/market.js +7 -127
  45. package/dist/marketClassify.d.ts +10 -0
  46. package/dist/marketClassify.js +52 -1
  47. package/dist/marketSourcing.js +7 -45
  48. package/dist/mcp.js +67 -115
  49. package/dist/planStore.d.ts +42 -1
  50. package/dist/planStore.js +268 -49
  51. package/dist/progress.d.ts +2 -2
  52. package/dist/progress.js +2 -2
  53. package/dist/providerError.d.ts +21 -0
  54. package/dist/providerError.js +30 -0
  55. package/dist/publicHttp.d.ts +28 -0
  56. package/dist/publicHttp.js +143 -0
  57. package/dist/secureFile.d.ts +15 -0
  58. package/dist/secureFile.js +164 -0
  59. package/dist/types.d.ts +1 -1
  60. package/docs/api.md +53 -2
  61. package/docs/architecture.md +22 -2
  62. package/llms.txt +7 -0
  63. package/package.json +5 -3
  64. package/skills/fullstackgtm/SKILL.md +8 -2
  65. package/src/acquireCheckpoint.ts +186 -0
  66. package/src/acquireLinkedIn.ts +3 -1
  67. package/src/cli/audit.ts +10 -7
  68. package/src/cli/auth.ts +8 -4
  69. package/src/cli/enrich.ts +322 -78
  70. package/src/cli/fix.ts +28 -7
  71. package/src/cli/help.ts +24 -15
  72. package/src/cli/market.ts +181 -2
  73. package/src/cli/plans.ts +159 -9
  74. package/src/cli/shared.ts +2 -1
  75. package/src/cli/ui.ts +19 -9
  76. package/src/cli.ts +1 -1
  77. package/src/config.ts +39 -1
  78. package/src/connectors/linkedin.ts +6 -0
  79. package/src/connectors/prospectSources.ts +50 -15
  80. package/src/connectors/salesforce.ts +12 -5
  81. package/src/connectors/salesforceAuth.ts +46 -6
  82. package/src/connectors/theirstack.ts +3 -10
  83. package/src/credentials.ts +47 -28
  84. package/src/enrich.ts +47 -1
  85. package/src/hostedAcquireCheckpoint.ts +156 -0
  86. package/src/hostedPatchPlan.ts +286 -0
  87. package/src/icp.ts +14 -2
  88. package/src/index.ts +24 -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 +93 -136
  93. package/src/planStore.ts +322 -57
  94. package/src/progress.ts +2 -2
  95. package/src/providerError.ts +37 -0
  96. package/src/publicHttp.ts +147 -0
  97. package/src/secureFile.ts +169 -0
  98. 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,34 @@ 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
+ hostedClaimId?: string;
36
+ };
37
+ /** Durable journal of apply ownership. An unresolved entry must never be replayed automatically. */
38
+ applyAttempts?: ApplyAttempt[];
26
39
  runs: PatchPlanRun[];
27
40
  createdAt: string;
28
41
  updatedAt: string;
29
42
  };
30
43
 
44
+ export type ApplyAttempt = {
45
+ id: string;
46
+ claimedAt: string;
47
+ provider: string;
48
+ source: "cli" | "fix" | "mcp";
49
+ status: "in_progress" | "preflight_aborted" | "completed" | "uncertain";
50
+ resolvedAt?: string;
51
+ /** Deliberately generic: provider errors can contain CRM data or credentials. */
52
+ note?: string;
53
+ hostedClaimId?: string;
54
+ };
55
+
31
56
  export interface PlanStore {
32
57
  save(plan: PatchPlan): Promise<StoredPlan>;
33
58
  get(planId: string): Promise<StoredPlan | null>;
@@ -38,9 +63,23 @@ export interface PlanStore {
38
63
  valueOverrides?: Record<string, unknown>,
39
64
  ): Promise<StoredPlan>;
40
65
  reject(planId: string): Promise<StoredPlan>;
41
- recordRun(planId: string, run: PatchPlanRun): Promise<StoredPlan>;
66
+ claimApply(planId: string, metadata?: Pick<ApplyAttempt, "provider" | "source">): Promise<{ stored: StoredPlan; claimId: string }>;
67
+ recordHostedClaim(planId: string, claimId: string, hostedClaimId: string): Promise<StoredPlan>;
68
+ recordRun(planId: string, run: PatchPlanRun, claimId: string): Promise<StoredPlan>;
69
+ abortApplyPreflight(planId: string, claimId: string, note: string): Promise<StoredPlan>;
70
+ markApplyUncertain(planId: string, claimId: string): Promise<StoredPlan>;
71
+ recoverApply(planId: string): Promise<StoredPlan>;
72
+ reconcileReplica(planId: string, remote: ReplicaPlanState): Promise<{ stored: StoredPlan; changed: boolean }>;
42
73
  }
43
74
 
75
+ /** A validated lifecycle snapshot received from another plan replica. */
76
+ export type ReplicaPlanState = {
77
+ status: "approved" | "rejected" | "applied";
78
+ approvedOperationIds: string[];
79
+ valueOverrides?: Record<string, unknown>;
80
+ run?: PatchPlanRun;
81
+ };
82
+
44
83
  /**
45
84
  * Plans as JSON files in a directory (default `$FSGTM_HOME/plans`), one file
46
85
  * per plan id. Filesystem-shaped on purpose: greppable, diffable, and any
@@ -49,6 +88,12 @@ export interface PlanStore {
49
88
  export function createFilePlanStore(directory?: string): PlanStore {
50
89
  const dir = directory ?? join(credentialsDir(), "plans");
51
90
 
91
+ function ensurePlanDirectory(): void {
92
+ assertNoSymlinkComponents(dir);
93
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
94
+ assertNoSymlinkComponents(dir);
95
+ }
96
+
52
97
  function pathFor(planId: string) {
53
98
  if (!/^[\w.-]+$/.test(planId)) throw new Error(`Invalid plan id: ${planId}`);
54
99
  return join(dir, `${planId}.json`);
@@ -56,8 +101,10 @@ export function createFilePlanStore(directory?: string): PlanStore {
56
101
 
57
102
  function read(planId: string): StoredPlan | null {
58
103
  try {
59
- return JSON.parse(readFileSync(pathFor(planId), "utf8")) as StoredPlan;
60
- } catch {
104
+ assertNoSymlinkComponents(dir);
105
+ return JSON.parse(readSecureRegularFile(pathFor(planId))) as StoredPlan;
106
+ } catch (error) {
107
+ if (error instanceof UnsafeManagedPathError) throw error;
61
108
  return null;
62
109
  }
63
110
  }
@@ -68,13 +115,17 @@ export function createFilePlanStore(directory?: string): PlanStore {
68
115
  // otherwise an `audit --save` before any `login` would create the home
69
116
  // directory world-readable.
70
117
  if (!directory) ensureSecureHomeDir();
71
- mkdirSync(dir, { recursive: true, mode: 0o700 });
118
+ ensurePlanDirectory();
72
119
  try {
73
120
  chmodSync(dir, 0o700);
74
121
  } catch {
75
122
  // Non-POSIX filesystems ignore chmod.
76
123
  }
77
- const next = { ...stored, updatedAt: new Date().toISOString() };
124
+ const next = {
125
+ ...stored,
126
+ revision: (stored.revision ?? 0) + 1,
127
+ updatedAt: new Date().toISOString(),
128
+ };
78
129
  writeSecureFile(pathFor(stored.plan.id), `${JSON.stringify(next, null, 2)}\n`);
79
130
  return next;
80
131
  }
@@ -85,17 +136,47 @@ export function createFilePlanStore(directory?: string): PlanStore {
85
136
  return stored;
86
137
  }
87
138
 
139
+ function withPlanLock<T>(planId: string, action: () => T): T {
140
+ ensurePlanDirectory();
141
+ const lockPath = `${pathFor(planId)}.lock`;
142
+ let fd: number;
143
+ try {
144
+ fd = openSync(lockPath, "wx", 0o600);
145
+ } catch (error) {
146
+ const code = error instanceof Error && "code" in error ? String(error.code) : "unknown";
147
+ if (code === "EEXIST") {
148
+ throw new Error(`Plan ${planId} is being updated by another process; retry after it finishes.`);
149
+ }
150
+ throw error;
151
+ }
152
+ try {
153
+ return action();
154
+ } finally {
155
+ closeSync(fd);
156
+ try { unlinkSync(lockPath); } catch { /* a missing lock is already released */ }
157
+ }
158
+ }
159
+
88
160
  return {
89
161
  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,
162
+ return withPlanLock(plan.id, () => {
163
+ const existing = read(plan.id);
164
+ if (existing && ["approved", "applying", "applied"].includes(existing.status)) {
165
+ throw new Error(
166
+ `Plan ${plan.id} is already ${existing.status}; refusing to replace its approval or apply history.`,
167
+ );
168
+ }
169
+ const now = new Date().toISOString();
170
+ return write({
171
+ plan,
172
+ status: plan.status,
173
+ approvedOperationIds: [],
174
+ valueOverrides: {},
175
+ revision: 0,
176
+ runs: [],
177
+ createdAt: now,
178
+ updatedAt: now,
179
+ });
99
180
  });
100
181
  },
101
182
 
@@ -104,6 +185,7 @@ export function createFilePlanStore(directory?: string): PlanStore {
104
185
  },
105
186
 
106
187
  async list(status) {
188
+ assertNoSymlinkComponents(dir);
107
189
  let entries: string[] = [];
108
190
  try {
109
191
  entries = readdirSync(dir);
@@ -119,55 +201,238 @@ export function createFilePlanStore(directory?: string): PlanStore {
119
201
  },
120
202
 
121
203
  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}.`);
204
+ return withPlanLock(planId, () => {
205
+ const stored = mustRead(planId);
206
+ if (stored.status === "applied") {
207
+ throw new Error(`Plan ${planId} has already been applied.`);
133
208
  }
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,
209
+ if (stored.status === "rejected") {
210
+ throw new Error(`Plan ${planId} was rejected; re-run the audit for a fresh plan.`);
211
+ }
212
+ if (stored.status === "applying" || stored.applyClaim) {
213
+ throw new Error(`Plan ${planId} is applying; approvals are frozen until the attempt is resolved.`);
214
+ }
215
+ const known = new Set(stored.plan.operations.map((operation) => operation.id));
216
+ for (const operationId of operationIds) {
217
+ if (!known.has(operationId)) {
218
+ throw new Error(`Plan ${planId} has no operation ${operationId}.`);
219
+ }
220
+ }
221
+ const approvedOperationIds = Array.from(
222
+ new Set([...stored.approvedOperationIds, ...operationIds]),
223
+ );
224
+ const mergedOverrides = { ...stored.valueOverrides, ...valueOverrides };
225
+ // Bind the approval to the operation content so apply can detect a
226
+ // post-approval edit. Recompute over ALL approved ops (a later approve
227
+ // call may add overrides that change an earlier op's resolved value).
228
+ const approvalDigests = computeApprovalDigests(
229
+ stored.plan.operations,
230
+ approvedOperationIds,
231
+ mergedOverrides,
232
+ loadOrCreateSigningKey(),
233
+ );
234
+ return write({
235
+ ...stored,
236
+ status: "approved",
237
+ approvedOperationIds,
238
+ valueOverrides: mergedOverrides,
239
+ approvalDigests,
240
+ });
154
241
  });
155
242
  },
156
243
 
157
244
  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: [] });
245
+ return withPlanLock(planId, () => {
246
+ const stored = mustRead(planId);
247
+ if (stored.status === "applied") {
248
+ throw new Error(`Plan ${planId} has already been applied.`);
249
+ }
250
+ if (stored.status === "applying" || stored.applyClaim) {
251
+ throw new Error(`Plan ${planId} is applying and cannot be rejected until the attempt is resolved.`);
252
+ }
253
+ return write({ ...stored, status: "rejected", approvedOperationIds: [], applyClaim: undefined });
254
+ });
163
255
  },
164
256
 
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],
257
+ async claimApply(planId, metadata) {
258
+ return withPlanLock(planId, () => {
259
+ const stored = mustRead(planId);
260
+ if (stored.status === "applying" || stored.applyClaim) {
261
+ throw new Error(
262
+ `Plan ${planId} is already applying; inspect its recorded attempts before any retry.`,
263
+ );
264
+ }
265
+ if (stored.status !== "approved") {
266
+ throw new Error(`Plan ${planId} is ${stored.status}; only an approved plan can be claimed for apply.`);
267
+ }
268
+ const claimId = randomUUID();
269
+ const claimedAt = new Date().toISOString();
270
+ const claimed = write({
271
+ ...stored,
272
+ status: "applying",
273
+ applyClaim: {
274
+ id: claimId,
275
+ claimedAt,
276
+ revision: stored.revision ?? 0,
277
+ },
278
+ applyAttempts: [...(stored.applyAttempts ?? []), {
279
+ id: claimId,
280
+ claimedAt,
281
+ provider: metadata?.provider ?? "unknown",
282
+ source: metadata?.source ?? "cli",
283
+ status: "in_progress",
284
+ }],
285
+ });
286
+ return { stored: claimed, claimId };
287
+ });
288
+ },
289
+
290
+ async recordRun(planId, run, claimId) {
291
+ return withPlanLock(planId, () => {
292
+ const stored = mustRead(planId);
293
+ if (stored.status !== "applying" || stored.applyClaim?.id !== claimId) {
294
+ throw new Error(`Refusing to record run for plan ${planId}: apply claim is missing or does not match.`);
295
+ }
296
+ return write({
297
+ ...stored,
298
+ status: run.status === "applied" || run.status === "partial" ? "applied" : "approved",
299
+ applyClaim: undefined,
300
+ applyAttempts: (stored.applyAttempts ?? []).map((attempt) =>
301
+ attempt.id === claimId
302
+ ? { ...attempt, status: "completed" as const, resolvedAt: new Date().toISOString() }
303
+ : attempt),
304
+ runs: [...stored.runs, run],
305
+ });
306
+ });
307
+ },
308
+
309
+ async recordHostedClaim(planId, claimId, hostedClaimId) {
310
+ return withPlanLock(planId, () => {
311
+ const stored = mustRead(planId);
312
+ if (stored.status !== "applying" || stored.applyClaim?.id !== claimId) {
313
+ throw new Error(`Refusing to attach hosted claim for plan ${planId}: local apply claim does not match.`);
314
+ }
315
+ return write({
316
+ ...stored,
317
+ applyClaim: { ...stored.applyClaim, hostedClaimId },
318
+ applyAttempts: (stored.applyAttempts ?? []).map((attempt) =>
319
+ attempt.id === claimId ? { ...attempt, hostedClaimId } : attempt),
320
+ });
321
+ });
322
+ },
323
+
324
+ async abortApplyPreflight(planId, claimId, note) {
325
+ return withPlanLock(planId, () => {
326
+ const stored = mustRead(planId);
327
+ if (stored.status !== "applying" || stored.applyClaim?.id !== claimId) {
328
+ throw new Error(`Refusing to abort preflight for plan ${planId}: apply claim is missing or does not match.`);
329
+ }
330
+ return write({
331
+ ...stored,
332
+ status: "approved",
333
+ applyClaim: undefined,
334
+ applyAttempts: (stored.applyAttempts ?? []).map((attempt) =>
335
+ attempt.id === claimId
336
+ ? { ...attempt, status: "preflight_aborted" as const, resolvedAt: new Date().toISOString(), note }
337
+ : attempt),
338
+ });
339
+ });
340
+ },
341
+
342
+ async markApplyUncertain(planId, claimId) {
343
+ return withPlanLock(planId, () => {
344
+ const stored = mustRead(planId);
345
+ if (stored.status !== "applying" || stored.applyClaim?.id !== claimId) return stored;
346
+ return write({
347
+ ...stored,
348
+ applyAttempts: (stored.applyAttempts ?? []).map((attempt) =>
349
+ attempt.id === claimId
350
+ ? { ...attempt, status: "uncertain" as const, note: "Apply exited without a completed run; some provider writes may have landed." }
351
+ : attempt),
352
+ });
353
+ });
354
+ },
355
+
356
+ async recoverApply(planId) {
357
+ return withPlanLock(planId, () => {
358
+ const stored = mustRead(planId);
359
+ if (stored.status !== "applying" || !stored.applyClaim) {
360
+ throw new Error(`Plan ${planId} has no unresolved apply attempt.`);
361
+ }
362
+ // Recovery is deliberately privilege-decaying: discard the old approval.
363
+ // An operator must inspect the provider, then explicitly approve a fresh attempt.
364
+ return write({
365
+ ...stored,
366
+ status: "needs_approval",
367
+ applyClaim: undefined,
368
+ approvedOperationIds: [],
369
+ valueOverrides: {},
370
+ approvalDigests: undefined,
371
+ applyAttempts: (stored.applyAttempts ?? []).map((attempt) =>
372
+ attempt.id === stored.applyClaim!.id
373
+ ? { ...attempt, status: "uncertain" as const, resolvedAt: new Date().toISOString(), note: "Operator acknowledged uncertain provider state; approval cleared before any retry." }
374
+ : attempt),
375
+ });
376
+ });
377
+ },
378
+
379
+ async reconcileReplica(planId, remote) {
380
+ return withPlanLock(planId, () => {
381
+ const stored = mustRead(planId);
382
+ if (stored.status === "applying" || stored.applyClaim) {
383
+ throw new Error(`Plan ${planId} is applying locally; replica reconciliation will retry after it finishes.`);
384
+ }
385
+ const known = new Set(stored.plan.operations.map((operation) => operation.id));
386
+ const approvedOperationIds = Array.from(new Set(remote.approvedOperationIds));
387
+ if (approvedOperationIds.some((id) => !known.has(id))) {
388
+ throw new Error(`Replica state for ${planId} contains an unknown operation.`);
389
+ }
390
+ // Terminal receipts are monotonic. A delayed approval or rejection can
391
+ // never roll an already-applied local replica backwards.
392
+ if (stored.status === "applied" && remote.status !== "applied") {
393
+ return { stored, changed: false };
394
+ }
395
+ if (remote.status === "rejected") {
396
+ if (stored.status === "rejected") return { stored, changed: false };
397
+ return { stored: write({
398
+ ...stored, status: "rejected", approvedOperationIds: [], valueOverrides: {},
399
+ approvalDigests: undefined, applyClaim: undefined,
400
+ }), changed: true };
401
+ }
402
+ if (approvedOperationIds.length === 0) {
403
+ throw new Error(`Replica ${remote.status} state for ${planId} has no approved operations.`);
404
+ }
405
+ const valueOverrides = remote.valueOverrides ?? {};
406
+ const approvalDigests = computeApprovalDigests(
407
+ stored.plan.operations, approvedOperationIds, valueOverrides, loadOrCreateSigningKey(),
408
+ );
409
+ if (remote.status === "approved") {
410
+ const unchanged = stored.status === "approved"
411
+ && JSON.stringify(stored.approvedOperationIds) === JSON.stringify(approvedOperationIds)
412
+ && JSON.stringify(stored.valueOverrides) === JSON.stringify(valueOverrides);
413
+ if (unchanged) return { stored, changed: false };
414
+ return { stored: write({
415
+ ...stored, status: "approved", approvedOperationIds, valueOverrides, approvalDigests,
416
+ }), changed: true };
417
+ }
418
+ if (!remote.run || remote.run.planId !== planId) {
419
+ throw new Error(`Replica applied state for ${planId} is missing a matching execution receipt.`);
420
+ }
421
+ if (remote.run.results.some((result) => !known.has(result.operationId))) {
422
+ throw new Error(`Replica execution receipt for ${planId} contains an unknown operation.`);
423
+ }
424
+ const runKey = (run: PatchPlanRun) => `${run.startedAt}\0${run.finishedAt}\0${run.provider}`;
425
+ const alreadyRecorded = stored.runs.some((run) => runKey(run) === runKey(remote.run!));
426
+ if (stored.status === "applied" && alreadyRecorded) return { stored, changed: false };
427
+ return { stored: write({
428
+ ...stored,
429
+ status: "applied",
430
+ approvedOperationIds,
431
+ valueOverrides,
432
+ approvalDigests,
433
+ applyClaim: undefined,
434
+ runs: alreadyRecorded ? stored.runs : [...stored.runs, remote.run],
435
+ }), changed: true };
171
436
  });
172
437
  },
173
438
  };
package/src/progress.ts CHANGED
@@ -186,8 +186,8 @@ export const RUNS_REPLAY_STAGES = ["runs", "health"] as const;
186
186
 
187
187
  export const APPLY_STAGES = ["preflight", "operations", "results"] as const;
188
188
 
189
- /** `enrich acquire` — routing sourced candidate rows into a create plan. */
190
- export const ACQUIRE_STAGES = ["candidates"] as const;
189
+ /** `enrich acquire` — discovery through a governed create plan. */
190
+ export const ACQUIRE_STAGES = ["discovery", "resolution", "plan"] as const;
191
191
 
192
192
  export const MARKET_CAPTURE_STAGES = ["sources", "capture", "classify", "persist"] as const;
193
193
 
@@ -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
+ }