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.
- package/CHANGELOG.md +91 -0
- package/CONTRIBUTING.md +23 -4
- package/DATA-FLOWS.md +8 -2
- package/INSTALL_FOR_AGENTS.md +13 -4
- package/README.md +53 -1
- package/SECURITY.md +27 -3
- package/dist/acquireCheckpoint.d.ts +33 -0
- package/dist/acquireCheckpoint.js +149 -0
- package/dist/acquireLinkedIn.d.ts +2 -0
- package/dist/acquireLinkedIn.js +1 -1
- package/dist/cli/audit.js +10 -7
- package/dist/cli/auth.js +8 -4
- package/dist/cli/enrich.js +283 -79
- package/dist/cli/fix.js +20 -7
- package/dist/cli/help.js +23 -15
- package/dist/cli/market.js +180 -2
- package/dist/cli/plans.js +154 -9
- package/dist/cli/shared.d.ts +3 -1
- package/dist/cli/shared.js +2 -2
- package/dist/cli/ui.d.ts +10 -5
- package/dist/cli/ui.js +18 -5
- package/dist/cli.js +1 -1
- package/dist/config.d.ts +13 -1
- package/dist/config.js +23 -2
- package/dist/connectors/linkedin.d.ts +2 -0
- package/dist/connectors/linkedin.js +5 -0
- package/dist/connectors/prospectSources.d.ts +23 -0
- package/dist/connectors/prospectSources.js +25 -14
- 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/enrich.d.ts +44 -1
- package/dist/hostedAcquireCheckpoint.d.ts +43 -0
- package/dist/hostedAcquireCheckpoint.js +129 -0
- package/dist/hostedPatchPlan.d.ts +87 -0
- package/dist/hostedPatchPlan.js +270 -0
- package/dist/icp.js +13 -2
- package/dist/index.d.ts +7 -3
- package/dist/index.js +6 -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 +42 -1
- package/dist/planStore.js +268 -49
- package/dist/progress.d.ts +2 -2
- package/dist/progress.js +2 -2
- 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 +53 -2
- package/docs/architecture.md +22 -2
- package/llms.txt +7 -0
- package/package.json +5 -3
- package/skills/fullstackgtm/SKILL.md +8 -2
- package/src/acquireCheckpoint.ts +186 -0
- package/src/acquireLinkedIn.ts +3 -1
- package/src/cli/audit.ts +10 -7
- package/src/cli/auth.ts +8 -4
- package/src/cli/enrich.ts +322 -78
- package/src/cli/fix.ts +28 -7
- package/src/cli/help.ts +24 -15
- package/src/cli/market.ts +181 -2
- package/src/cli/plans.ts +159 -9
- package/src/cli/shared.ts +2 -1
- package/src/cli/ui.ts +19 -9
- package/src/cli.ts +1 -1
- package/src/config.ts +39 -1
- package/src/connectors/linkedin.ts +6 -0
- package/src/connectors/prospectSources.ts +50 -15
- 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/enrich.ts +47 -1
- package/src/hostedAcquireCheckpoint.ts +156 -0
- package/src/hostedPatchPlan.ts +286 -0
- package/src/icp.ts +14 -2
- package/src/index.ts +24 -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 +322 -57
- package/src/progress.ts +2 -2
- package/src/providerError.ts +37 -0
- package/src/publicHttp.ts +147 -0
- package/src/secureFile.ts +169 -0
- package/src/types.ts +1 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/** Broker-authenticated mirror for locally governed patch plans. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { getCredential } from "./credentials.ts";
|
|
4
|
+
import type { PlanStore, StoredPlan } from "./planStore.ts";
|
|
5
|
+
import type { CrmProvider, PatchOperation, PatchOperationResult, PatchPlan, PatchPlanRun } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
const ENDPOINT = "/api/cli/patch-plan";
|
|
8
|
+
const TIMEOUT_MS = 5000;
|
|
9
|
+
|
|
10
|
+
type Options = { fetchImpl?: typeof fetch; timeoutMs?: number };
|
|
11
|
+
export type HostedPlanState = {
|
|
12
|
+
patchPlanId: string;
|
|
13
|
+
externalPlanId: string;
|
|
14
|
+
documentSha256: string;
|
|
15
|
+
status: string;
|
|
16
|
+
approvedOperationIds: string[];
|
|
17
|
+
valueOverrides: Record<string, unknown>;
|
|
18
|
+
updatedAt: number;
|
|
19
|
+
runRecord?: PatchPlanRun;
|
|
20
|
+
url: string;
|
|
21
|
+
};
|
|
22
|
+
export type HostedPlanResult =
|
|
23
|
+
| { status: "unpaired" }
|
|
24
|
+
| { status: "missing" }
|
|
25
|
+
| { status: "found" | "saved"; state: HostedPlanState }
|
|
26
|
+
| { status: "conflict"; reason: string }
|
|
27
|
+
| { status: "unavailable"; reason: string };
|
|
28
|
+
|
|
29
|
+
function broker(): { baseUrl: string; token: string } | null {
|
|
30
|
+
const value = getCredential("broker");
|
|
31
|
+
if (!value?.baseUrl || !value.accessToken) return null;
|
|
32
|
+
try {
|
|
33
|
+
const url = new URL(value.baseUrl);
|
|
34
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
35
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) return null;
|
|
36
|
+
return { baseUrl: url.href.replace(/\/+$/, ""), token: value.accessToken };
|
|
37
|
+
} catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Review document intentionally omits evidence/findings and all mutable store security state. */
|
|
41
|
+
export function hostedReviewDocument(plan: PatchPlan) {
|
|
42
|
+
return {
|
|
43
|
+
id: plan.id,
|
|
44
|
+
title: plan.title,
|
|
45
|
+
createdAt: plan.createdAt,
|
|
46
|
+
status: "needs_approval",
|
|
47
|
+
dryRun: true,
|
|
48
|
+
summary: plan.summary,
|
|
49
|
+
operations: plan.operations,
|
|
50
|
+
...(plan.filter ? { filter: plan.filter } : {}),
|
|
51
|
+
...(plan.guards ? { guards: plan.guards } : {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function hostedPlanDigest(plan: PatchPlan): string {
|
|
56
|
+
return createHash("sha256").update(JSON.stringify(hostedReviewDocument(plan))).digest("hex");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function mapOperation(op: PatchOperation) {
|
|
60
|
+
return {
|
|
61
|
+
packageOpId: op.id,
|
|
62
|
+
objectType: op.objectType,
|
|
63
|
+
...(op.objectId ? { objectId: op.objectId } : {}),
|
|
64
|
+
fieldOrAction: op.field ?? op.operation,
|
|
65
|
+
beforeValue: op.beforeValue ?? null,
|
|
66
|
+
afterValue: op.afterValue ?? null,
|
|
67
|
+
...(op.reason ? { reason: op.reason } : {}),
|
|
68
|
+
...(op.sourceRuleOrPolicy ? { sourceRuleOrPolicy: op.sourceRuleOrPolicy } : {}),
|
|
69
|
+
riskLevel: op.riskLevel,
|
|
70
|
+
approvalRequired: op.approvalRequired,
|
|
71
|
+
...(op.rollback ? { rollback: op.rollback } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseState(value: unknown, baseUrl: string): HostedPlanState | null {
|
|
76
|
+
if (!value || typeof value !== "object") return null;
|
|
77
|
+
const row = value as Record<string, unknown>;
|
|
78
|
+
const id = typeof row.patchPlanId === "string" ? row.patchPlanId : null;
|
|
79
|
+
if (!id) return null;
|
|
80
|
+
const approvedOperationIds = Array.isArray(row.approvedOperationIds) ? row.approvedOperationIds.filter((id): id is string => typeof id === "string") : [];
|
|
81
|
+
const runRecord = parseRunRecord(row.runRecord, row.operationReceipts, row.externalPlanId, row.updatedAt);
|
|
82
|
+
return {
|
|
83
|
+
patchPlanId: id,
|
|
84
|
+
externalPlanId: typeof row.externalPlanId === "string" ? row.externalPlanId : "",
|
|
85
|
+
documentSha256: typeof row.documentSha256 === "string" ? row.documentSha256 : "",
|
|
86
|
+
status: typeof row.status === "string" ? row.status : "pending_approval",
|
|
87
|
+
approvedOperationIds,
|
|
88
|
+
valueOverrides: row.valueOverrides && typeof row.valueOverrides === "object" && !Array.isArray(row.valueOverrides) ? row.valueOverrides as Record<string, unknown> : {},
|
|
89
|
+
updatedAt: typeof row.updatedAt === "number" ? row.updatedAt : Date.now(),
|
|
90
|
+
...(runRecord ? { runRecord } : {}),
|
|
91
|
+
url: `${baseUrl}/dashboard/patch-plans?plan=${encodeURIComponent(id)}`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseRunRecord(value: unknown, receiptsValue: unknown, externalPlanId: unknown, updatedAt: unknown): PatchPlanRun | undefined {
|
|
96
|
+
const row = value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
97
|
+
const planId = typeof row?.planId === "string" ? row.planId : typeof externalPlanId === "string" ? externalPlanId : undefined;
|
|
98
|
+
if (!planId) return undefined;
|
|
99
|
+
const rawResults = Array.isArray(row?.results) ? row.results : Array.isArray(receiptsValue) ? receiptsValue : [];
|
|
100
|
+
const results = rawResults.flatMap((entry): PatchOperationResult[] => {
|
|
101
|
+
if (!entry || typeof entry !== "object") return [];
|
|
102
|
+
const receipt = entry as Record<string, unknown>;
|
|
103
|
+
const operationId = typeof receipt.operationId === "string" ? receipt.operationId : typeof receipt.packageOpId === "string" ? receipt.packageOpId : undefined;
|
|
104
|
+
const rawStatus = receipt.status;
|
|
105
|
+
const status = rawStatus === "applied" || rawStatus === "failed" || rawStatus === "skipped" || rawStatus === "conflict" ? rawStatus : undefined;
|
|
106
|
+
if (!operationId || !status) return [];
|
|
107
|
+
return [{ operationId, status, ...(typeof receipt.detail === "string" ? { detail: receipt.detail } : typeof receipt.error === "string" ? { detail: receipt.error } : {}), ...(receipt.providerData !== undefined ? { providerData: receipt.providerData } : {}) }];
|
|
108
|
+
});
|
|
109
|
+
if (results.length === 0) return undefined;
|
|
110
|
+
const timestamp = typeof updatedAt === "number" ? new Date(updatedAt).toISOString() : new Date().toISOString();
|
|
111
|
+
const provider = typeof row?.provider === "string" ? row.provider as CrmProvider : "mock";
|
|
112
|
+
const status = row?.status === "applied" || row?.status === "partial" || row?.status === "failed" || row?.status === "rejected"
|
|
113
|
+
? row.status : results.every((result) => result.status === "applied" || result.status === "skipped") ? "applied" : "partial";
|
|
114
|
+
return {
|
|
115
|
+
planId, provider,
|
|
116
|
+
startedAt: typeof row?.startedAt === "string" ? row.startedAt : timestamp,
|
|
117
|
+
finishedAt: typeof row?.finishedAt === "string" ? row.finishedAt : timestamp,
|
|
118
|
+
status,
|
|
119
|
+
results,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function failure(status: number): HostedPlanResult {
|
|
124
|
+
if (status === 409) return { status: "conflict", reason: "hosted mirror has different immutable content" };
|
|
125
|
+
if (status === 401 || status === 403) return { status: "unavailable", reason: "hosted authentication was rejected; pair the CLI again" };
|
|
126
|
+
return { status: "unavailable", reason: `hosted plan request failed (HTTP ${status})` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function uploadHostedPatchPlan(stored: StoredPlan, options: Options = {}): Promise<HostedPlanResult> {
|
|
130
|
+
const paired = broker();
|
|
131
|
+
if (!paired) return { status: "unpaired" };
|
|
132
|
+
const document = hostedReviewDocument(stored.plan);
|
|
133
|
+
const digest = hostedPlanDigest(stored.plan);
|
|
134
|
+
const riskOrder = { low: 0, medium: 1, high: 2 } as const;
|
|
135
|
+
const riskLevel = stored.plan.operations.reduce<"low" | "medium" | "high">(
|
|
136
|
+
(risk, op) => riskOrder[op.riskLevel] > riskOrder[risk] ? op.riskLevel : risk,
|
|
137
|
+
"low",
|
|
138
|
+
);
|
|
139
|
+
try {
|
|
140
|
+
const response = await (options.fetchImpl ?? fetch)(`${paired.baseUrl}${ENDPOINT}`, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { Authorization: `Bearer ${paired.token}`, "Content-Type": "application/json" },
|
|
143
|
+
body: JSON.stringify({
|
|
144
|
+
externalPlanId: stored.plan.id, documentSha256: digest, externalRevision: stored.revision,
|
|
145
|
+
title: stored.plan.title, summary: stored.plan.summary, riskLevel, document,
|
|
146
|
+
operations: stored.plan.operations.map(mapOperation), createdAt: Date.parse(stored.plan.createdAt),
|
|
147
|
+
}),
|
|
148
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS),
|
|
149
|
+
});
|
|
150
|
+
if (!response.ok) return failure(response.status);
|
|
151
|
+
const raw = await response.json() as Record<string, unknown>;
|
|
152
|
+
const state = parseState({ ...raw, externalPlanId: stored.plan.id, documentSha256: digest, approvedOperationIds: [], valueOverrides: {}, updatedAt: Date.now() }, paired.baseUrl);
|
|
153
|
+
return state ? { status: "saved", state } : { status: "unavailable", reason: "hosted plan response was invalid" };
|
|
154
|
+
} catch { return { status: "unavailable", reason: "hosted plan request was unavailable" }; }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function readHostedPatchPlan(planId: string, options: Options = {}): Promise<HostedPlanResult> {
|
|
158
|
+
const paired = broker();
|
|
159
|
+
if (!paired) return { status: "unpaired" };
|
|
160
|
+
try {
|
|
161
|
+
const response = await (options.fetchImpl ?? fetch)(`${paired.baseUrl}${ENDPOINT}?externalPlanId=${encodeURIComponent(planId)}`, {
|
|
162
|
+
headers: { Authorization: `Bearer ${paired.token}`, Accept: "application/json" },
|
|
163
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS),
|
|
164
|
+
});
|
|
165
|
+
if (response.status === 404) return { status: "missing" };
|
|
166
|
+
if (!response.ok) return failure(response.status);
|
|
167
|
+
const state = parseState(await response.json(), paired.baseUrl);
|
|
168
|
+
return state ? { status: "found", state } : { status: "unavailable", reason: "hosted plan response was invalid" };
|
|
169
|
+
} catch { return { status: "unavailable", reason: "hosted plan request was unavailable" }; }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export type HostedReconcileResult =
|
|
173
|
+
| { status: "unchanged" | "unpaired" | "missing" }
|
|
174
|
+
| { status: "updated"; stored: StoredPlan; remoteStatus: string }
|
|
175
|
+
| { status: "conflict" | "unavailable"; reason: string };
|
|
176
|
+
|
|
177
|
+
/** Pull a peer's newer lifecycle into the local replica without weakening local integrity gates. */
|
|
178
|
+
export async function reconcileHostedPatchPlan(
|
|
179
|
+
store: PlanStore,
|
|
180
|
+
stored: StoredPlan,
|
|
181
|
+
options: Options = {},
|
|
182
|
+
): Promise<HostedReconcileResult> {
|
|
183
|
+
const hosted = await readHostedPatchPlan(stored.plan.id, options);
|
|
184
|
+
if (hosted.status === "unpaired" || hosted.status === "missing") return { status: hosted.status };
|
|
185
|
+
if (hosted.status === "unavailable" || hosted.status === "conflict") return hosted;
|
|
186
|
+
if (hosted.state.documentSha256 !== hostedPlanDigest(stored.plan)) {
|
|
187
|
+
return { status: "conflict", reason: "hosted review content does not match the immutable local plan" };
|
|
188
|
+
}
|
|
189
|
+
if (!(["approved", "rejected", "applied"] as string[]).includes(hosted.state.status)) return { status: "unchanged" };
|
|
190
|
+
try {
|
|
191
|
+
const reconciled = await store.reconcileReplica(stored.plan.id, {
|
|
192
|
+
status: hosted.state.status as "approved" | "rejected" | "applied",
|
|
193
|
+
approvedOperationIds: hosted.state.approvedOperationIds,
|
|
194
|
+
valueOverrides: hosted.state.valueOverrides,
|
|
195
|
+
...(hosted.state.runRecord ? { run: hosted.state.runRecord } : {}),
|
|
196
|
+
});
|
|
197
|
+
return reconciled.changed
|
|
198
|
+
? { status: "updated", stored: reconciled.stored, remoteStatus: hosted.state.status }
|
|
199
|
+
: { status: "unchanged" };
|
|
200
|
+
} catch (error) {
|
|
201
|
+
return { status: "conflict", reason: error instanceof Error ? error.message : "replica reconciliation failed" };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function reportHostedPlanLifecycle(stored: StoredPlan, options: Options & { claimId?: string } = {}): Promise<HostedPlanResult> {
|
|
206
|
+
const paired = broker();
|
|
207
|
+
if (!paired) return { status: "unpaired" };
|
|
208
|
+
const status = stored.status === "applied" ? "applied" : stored.status === "rejected" ? "rejected" : "approved";
|
|
209
|
+
const runRecord = status === "applied" ? stored.runs.at(-1) : undefined;
|
|
210
|
+
const operationReceipts = runRecord?.results.map((result) => ({
|
|
211
|
+
packageOpId: result.operationId,
|
|
212
|
+
status: result.status,
|
|
213
|
+
...(result.detail ? { error: result.detail.slice(0, 1000) } : {}),
|
|
214
|
+
...(result.providerData !== undefined ? { providerData: result.providerData } : {}),
|
|
215
|
+
updatedAt: Date.parse(runRecord.finishedAt),
|
|
216
|
+
}));
|
|
217
|
+
try {
|
|
218
|
+
const response = await (options.fetchImpl ?? fetch)(`${paired.baseUrl}${ENDPOINT}`, {
|
|
219
|
+
method: "POST", headers: { Authorization: `Bearer ${paired.token}`, "Content-Type": "application/json" },
|
|
220
|
+
body: JSON.stringify({ action: "lifecycle", externalPlanId: stored.plan.id, documentSha256: hostedPlanDigest(stored.plan), status, approvedOperationIds: stored.approvedOperationIds, ...(options.claimId ? { claimId: options.claimId } : {}), ...(runRecord ? { runRecord, operationReceipts } : {}) }),
|
|
221
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS),
|
|
222
|
+
});
|
|
223
|
+
if (!response.ok) return failure(response.status);
|
|
224
|
+
const raw = await response.json() as Record<string, unknown>;
|
|
225
|
+
const state = parseState({ ...raw, externalPlanId: stored.plan.id, documentSha256: hostedPlanDigest(stored.plan), approvedOperationIds: stored.approvedOperationIds, valueOverrides: stored.valueOverrides, updatedAt: Date.now() }, paired.baseUrl);
|
|
226
|
+
return state ? { status: "saved", state } : { status: "unavailable", reason: "hosted lifecycle response was invalid" };
|
|
227
|
+
} catch { return { status: "unavailable", reason: "hosted lifecycle request was unavailable" }; }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export type HostedApplyClaimResult =
|
|
231
|
+
| { status: "unpaired" }
|
|
232
|
+
| { status: "claimed"; claimId: string; expiresAt?: number }
|
|
233
|
+
| { status: "applied" }
|
|
234
|
+
| { status: "conflict" | "unavailable"; reason: string };
|
|
235
|
+
|
|
236
|
+
/** Coordinate online replicas before provider I/O; provider idempotency remains the offline fallback. */
|
|
237
|
+
export async function claimHostedPlanApply(stored: StoredPlan, options: Options = {}): Promise<HostedApplyClaimResult> {
|
|
238
|
+
const paired = broker();
|
|
239
|
+
if (!paired) return { status: "unpaired" };
|
|
240
|
+
try {
|
|
241
|
+
const response = await (options.fetchImpl ?? fetch)(`${paired.baseUrl}${ENDPOINT}`, {
|
|
242
|
+
method: "POST", headers: { Authorization: `Bearer ${paired.token}`, "Content-Type": "application/json" },
|
|
243
|
+
body: JSON.stringify({ action: "claim", externalPlanId: stored.plan.id, documentSha256: hostedPlanDigest(stored.plan) }),
|
|
244
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS),
|
|
245
|
+
});
|
|
246
|
+
if (!response.ok) return failure(response.status) as HostedApplyClaimResult;
|
|
247
|
+
const row = await response.json() as Record<string, unknown>;
|
|
248
|
+
if (row.alreadyApplied === true || row.status === "applied") return { status: "applied" };
|
|
249
|
+
if (row.status === "claimed" && typeof row.claimId === "string") {
|
|
250
|
+
return { status: "claimed", claimId: row.claimId, ...(typeof row.expiresAt === "number" ? { expiresAt: row.expiresAt } : {}) };
|
|
251
|
+
}
|
|
252
|
+
return { status: "unavailable", reason: "hosted apply claim response was invalid" };
|
|
253
|
+
} catch { return { status: "unavailable", reason: "hosted apply claim request was unavailable" }; }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Release a shared CLI claim only when preflight aborts before provider I/O. */
|
|
257
|
+
export async function releaseHostedPlanApply(
|
|
258
|
+
stored: StoredPlan,
|
|
259
|
+
claimId: string,
|
|
260
|
+
reason: string,
|
|
261
|
+
options: Options = {},
|
|
262
|
+
): Promise<{ status: "released" | "unpaired" } | { status: "conflict" | "unavailable"; reason: string }> {
|
|
263
|
+
const paired = broker();
|
|
264
|
+
if (!paired) return { status: "unpaired" };
|
|
265
|
+
try {
|
|
266
|
+
const response = await (options.fetchImpl ?? fetch)(`${paired.baseUrl}${ENDPOINT}`, {
|
|
267
|
+
method: "POST",
|
|
268
|
+
headers: { Authorization: `Bearer ${paired.token}`, "Content-Type": "application/json" },
|
|
269
|
+
body: JSON.stringify({
|
|
270
|
+
action: "release_claim",
|
|
271
|
+
externalPlanId: stored.plan.id,
|
|
272
|
+
documentSha256: hostedPlanDigest(stored.plan),
|
|
273
|
+
claimId,
|
|
274
|
+
reason,
|
|
275
|
+
}),
|
|
276
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? TIMEOUT_MS),
|
|
277
|
+
});
|
|
278
|
+
if (!response.ok) return failure(response.status) as { status: "conflict" | "unavailable"; reason: string };
|
|
279
|
+
const row = await response.json() as Record<string, unknown>;
|
|
280
|
+
return row.released === true
|
|
281
|
+
? { status: "released" }
|
|
282
|
+
: { status: "unavailable", reason: "hosted apply claim release response was invalid" };
|
|
283
|
+
} catch {
|
|
284
|
+
return { status: "unavailable", reason: "hosted apply claim release was unavailable" };
|
|
285
|
+
}
|
|
286
|
+
}
|
package/src/icp.ts
CHANGED
|
@@ -193,6 +193,15 @@ const CRUSTDATA_INDUSTRY: Record<string, string[]> = {
|
|
|
193
193
|
"financial services": ["Financial Services"],
|
|
194
194
|
};
|
|
195
195
|
|
|
196
|
+
// Common NAICS groups translated into Crustdata's LinkedIn industry vocabulary.
|
|
197
|
+
// This keeps an ICP authored with provider-neutral NAICS codes from silently
|
|
198
|
+
// dropping its industry constraint on the Pipe0 path.
|
|
199
|
+
const NAICS_CRUSTDATA_INDUSTRY: Record<string, string[]> = {
|
|
200
|
+
"5112": ["Software Development", "Computer Software"],
|
|
201
|
+
"5182": ["IT Services and IT Consulting", "Information Technology & Services"],
|
|
202
|
+
"5415": ["IT Services and IT Consulting", "Information Technology & Services"],
|
|
203
|
+
};
|
|
204
|
+
|
|
196
205
|
/**
|
|
197
206
|
* pipe0 Crustdata people-search config.filters from the ICP. `current_job_titles`
|
|
198
207
|
* matches real LinkedIn title strings (case-sensitive), so keywords are Title
|
|
@@ -219,9 +228,12 @@ export function icpToCrustdataFilters(icp: Icp): Record<string, unknown> {
|
|
|
219
228
|
if (icp.firmographics.geos?.length) {
|
|
220
229
|
f.locations = icp.firmographics.geos.map((g) => COUNTRY_NAMES[g.toLowerCase()] ?? g);
|
|
221
230
|
}
|
|
222
|
-
if (icp.firmographics.industries?.length) {
|
|
231
|
+
if (icp.firmographics.industries?.length || icp.firmographics.naics?.length) {
|
|
223
232
|
const inds = [
|
|
224
|
-
...new Set(
|
|
233
|
+
...new Set([
|
|
234
|
+
...(icp.firmographics.industries ?? []).flatMap((i) => CRUSTDATA_INDUSTRY[i.toLowerCase()] ?? [titleCase(i)]),
|
|
235
|
+
...(icp.firmographics.naics ?? []).flatMap((code) => NAICS_CRUSTDATA_INDUSTRY[code] ?? []),
|
|
236
|
+
]),
|
|
225
237
|
];
|
|
226
238
|
if (inds.length) f.current_employers_linkedin_industries = inds;
|
|
227
239
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
export { auditSnapshot, defaultPolicy } from "./audit.ts";
|
|
2
|
+
export {
|
|
3
|
+
acquireCheckpointId,
|
|
4
|
+
acquireCheckpointsDir,
|
|
5
|
+
createFileAcquireCheckpointStore,
|
|
6
|
+
validateAcquireCheckpoint,
|
|
7
|
+
validateAcquireCheckpointKey,
|
|
8
|
+
type AcquireCheckpoint,
|
|
9
|
+
type AcquireCheckpointKey,
|
|
10
|
+
type AcquireCheckpointStore,
|
|
11
|
+
type AcquireContinuation,
|
|
12
|
+
} from "./acquireCheckpoint.ts";
|
|
2
13
|
export {
|
|
3
14
|
matchesTerritory,
|
|
4
15
|
parseAssignmentPolicy,
|
|
@@ -38,8 +49,10 @@ export {
|
|
|
38
49
|
loadConfig,
|
|
39
50
|
mergePolicy,
|
|
40
51
|
resolveConfiguredRules,
|
|
52
|
+
rulePackageTrustFromCli,
|
|
41
53
|
type FullstackgtmConfig,
|
|
42
54
|
type LoadedConfig,
|
|
55
|
+
type RulePackageTrust,
|
|
43
56
|
} from "./config.ts";
|
|
44
57
|
export { applyPatchPlan, type ApplyPatchPlanOptions } from "./connector.ts";
|
|
45
58
|
export {
|
|
@@ -76,6 +89,7 @@ export {
|
|
|
76
89
|
pollSalesforceDeviceLogin,
|
|
77
90
|
refreshSalesforceToken,
|
|
78
91
|
startSalesforceDeviceLogin,
|
|
92
|
+
validateSalesforceOrigin,
|
|
79
93
|
validateSalesforceToken,
|
|
80
94
|
type SalesforceDeviceAuthorization,
|
|
81
95
|
type SalesforceTokenSet,
|
|
@@ -129,6 +143,7 @@ export {
|
|
|
129
143
|
stagedSourceRecords,
|
|
130
144
|
staleDaysFor,
|
|
131
145
|
type BuildEnrichPlanOptions,
|
|
146
|
+
type AcquireDiscoveryCheckpoint,
|
|
132
147
|
type EnrichAmbiguity,
|
|
133
148
|
type EnrichConfig,
|
|
134
149
|
type EnrichCounts,
|
|
@@ -145,6 +160,14 @@ export {
|
|
|
145
160
|
type EnrichWorkItem,
|
|
146
161
|
type MatchOutcome,
|
|
147
162
|
} from "./enrich.ts";
|
|
163
|
+
export {
|
|
164
|
+
readHostedAcquireCheckpoint,
|
|
165
|
+
writeHostedAcquireCheckpoint,
|
|
166
|
+
type HostedAcquireCheckpoint,
|
|
167
|
+
type HostedCheckpointReadResult,
|
|
168
|
+
type HostedCheckpointWriteResult,
|
|
169
|
+
} from "./hostedAcquireCheckpoint.ts";
|
|
170
|
+
export { claimHostedPlanApply, hostedPlanDigest, hostedReviewDocument, readHostedPatchPlan, reconcileHostedPatchPlan, releaseHostedPlanApply, reportHostedPlanLifecycle, uploadHostedPatchPlan } from "./hostedPatchPlan.ts";
|
|
148
171
|
export {
|
|
149
172
|
scaffoldWorkspace,
|
|
150
173
|
starterEnrichConfig,
|
|
@@ -575,3 +598,4 @@ export type {
|
|
|
575
598
|
RiskLevel,
|
|
576
599
|
SourceFreshness,
|
|
577
600
|
} from "./types.ts";
|
|
601
|
+
export { ProviderHttpError } from "./providerError.ts";
|
package/src/market.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { lookup } from "node:dns/promises";
|
|
3
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { isIP } from "node:net";
|
|
5
3
|
import { join } from "node:path";
|
|
6
4
|
import { credentialsDir } from "./credentials.ts";
|
|
7
5
|
import { MARKET_CAPTURE_STAGES, nullProgressEmitter, type ProgressEmitter } from "./progress.ts";
|
|
8
6
|
import type { GtmEvidence } from "./types.ts";
|
|
7
|
+
import { assertPublicUrl, publicHttpGet } from "./publicHttp.ts";
|
|
8
|
+
export { assertPublicUrl } from "./publicHttp.ts";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* The Market Map: a live model of the competitive category a company sells
|
|
@@ -328,121 +328,17 @@ export type FetchPage = (url: string) => Promise<{ status: number; body: string
|
|
|
328
328
|
* any host that is or resolves to a private/loopback/link-local/metadata
|
|
329
329
|
* address, and (3) follow redirects manually, re-validating each hop.
|
|
330
330
|
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
* competitor pages; a hardened deployment should fetch through an egress proxy.
|
|
331
|
+
* The default transport resolves once, rejects mixed public/private answers,
|
|
332
|
+
* and pins a validated address into socket creation to prevent DNS rebinding.
|
|
334
333
|
*/
|
|
335
334
|
const MAX_REDIRECTS = 5;
|
|
336
335
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
337
336
|
const MAX_BODY_BYTES = 5_000_000;
|
|
338
337
|
|
|
339
|
-
function ipv4IsPrivate(ip: string): boolean {
|
|
340
|
-
const parts = ip.split(".").map((n) => Number(n));
|
|
341
|
-
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
|
|
342
|
-
const [a, b] = parts;
|
|
343
|
-
if (a === 0 || a === 127) return true; // this-host, loopback
|
|
344
|
-
if (a === 10) return true; // private
|
|
345
|
-
if (a === 172 && b >= 16 && b <= 31) return true; // private
|
|
346
|
-
if (a === 192 && b === 168) return true; // private
|
|
347
|
-
if (a === 169 && b === 254) return true; // link-local incl. 169.254.169.254 metadata
|
|
348
|
-
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
|
349
|
-
if (a >= 224) return true; // multicast / reserved
|
|
350
|
-
return false;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function ipIsPrivate(ip: string): boolean {
|
|
354
|
-
const family = isIP(ip);
|
|
355
|
-
if (family === 4) return ipv4IsPrivate(ip);
|
|
356
|
-
if (family === 6) {
|
|
357
|
-
const lower = ip.toLowerCase();
|
|
358
|
-
if (lower === "::1" || lower === "::") return true; // loopback / unspecified
|
|
359
|
-
// IPv4-mapped (::ffff:…) — Node normalizes ::ffff:127.0.0.1 to ::ffff:7f00:1,
|
|
360
|
-
// so accept both the dotted and the hex-pair forms, unwrap, check the v4.
|
|
361
|
-
const mapped = lower.match(/^::ffff:(.+)$/);
|
|
362
|
-
if (mapped) {
|
|
363
|
-
const rest = mapped[1];
|
|
364
|
-
if (rest.includes(".")) return ipv4IsPrivate(rest);
|
|
365
|
-
const groups = rest.split(":");
|
|
366
|
-
if (groups.length === 2) {
|
|
367
|
-
const hi = parseInt(groups[0], 16);
|
|
368
|
-
const lo = parseInt(groups[1], 16);
|
|
369
|
-
if (Number.isNaN(hi) || Number.isNaN(lo)) return true;
|
|
370
|
-
return ipv4IsPrivate(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
|
|
371
|
-
}
|
|
372
|
-
return true; // unrecognized mapped form → refuse
|
|
373
|
-
}
|
|
374
|
-
if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true; // link-local fe80::/10
|
|
375
|
-
if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // unique-local fc00::/7
|
|
376
|
-
return false;
|
|
377
|
-
}
|
|
378
|
-
return true; // not a recognizable IP literal → refuse
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
export async function assertPublicUrl(rawUrl: string): Promise<URL> {
|
|
382
|
-
let url: URL;
|
|
383
|
-
try {
|
|
384
|
-
url = new URL(rawUrl);
|
|
385
|
-
} catch {
|
|
386
|
-
throw new Error(`market capture: "${rawUrl}" is not a valid URL.`);
|
|
387
|
-
}
|
|
388
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
389
|
-
throw new Error(`market capture refuses ${url.protocol} URLs (only http/https): ${rawUrl}`);
|
|
390
|
-
}
|
|
391
|
-
const host = url.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
|
|
392
|
-
if (isIP(host)) {
|
|
393
|
-
if (ipIsPrivate(host)) throw new Error(`market capture refuses private/loopback address ${host} (SSRF guard).`);
|
|
394
|
-
return url;
|
|
395
|
-
}
|
|
396
|
-
// Hostname: resolve and refuse if ANY address is private.
|
|
397
|
-
const addrs = await lookup(host, { all: true });
|
|
398
|
-
for (const { address } of addrs) {
|
|
399
|
-
if (ipIsPrivate(address)) {
|
|
400
|
-
throw new Error(`market capture refuses ${host} — it resolves to private/internal address ${address} (SSRF guard).`);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
return url;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
338
|
const defaultFetchPage: FetchPage = async (url) => {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const controller = new AbortController();
|
|
411
|
-
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
412
|
-
let response: Response;
|
|
413
|
-
try {
|
|
414
|
-
response = await fetch(current, {
|
|
415
|
-
headers: {
|
|
416
|
-
"User-Agent": "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)",
|
|
417
|
-
"Accept-Language": "en-US",
|
|
418
|
-
},
|
|
419
|
-
redirect: "manual",
|
|
420
|
-
signal: controller.signal,
|
|
421
|
-
});
|
|
422
|
-
} finally {
|
|
423
|
-
clearTimeout(timer);
|
|
424
|
-
}
|
|
425
|
-
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
|
|
426
|
-
current = new URL(response.headers.get("location") as string, current).toString();
|
|
427
|
-
continue; // re-validate the redirect target on the next iteration
|
|
428
|
-
}
|
|
429
|
-
const reader = response.body?.getReader();
|
|
430
|
-
if (!reader) return { status: response.status, body: await response.text() };
|
|
431
|
-
const chunks: Uint8Array[] = [];
|
|
432
|
-
let total = 0;
|
|
433
|
-
for (;;) {
|
|
434
|
-
const { done, value } = await reader.read();
|
|
435
|
-
if (done) break;
|
|
436
|
-
total += value.length;
|
|
437
|
-
if (total > MAX_BODY_BYTES) {
|
|
438
|
-
await reader.cancel();
|
|
439
|
-
break;
|
|
440
|
-
}
|
|
441
|
-
chunks.push(value);
|
|
442
|
-
}
|
|
443
|
-
return { status: response.status, body: Buffer.concat(chunks).toString("utf8") };
|
|
444
|
-
}
|
|
445
|
-
throw new Error(`market capture: too many redirects (>${MAX_REDIRECTS}) for ${url}`);
|
|
339
|
+
const response = await publicHttpGet(url, { maxBytes: MAX_BODY_BYTES, maxRedirects: MAX_REDIRECTS,
|
|
340
|
+
timeoutMs: FETCH_TIMEOUT_MS, headers: { "User-Agent": "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)", "Accept-Language": "en-US" } });
|
|
341
|
+
return { status: response.status, body: Buffer.from(response.body).toString("utf8") };
|
|
446
342
|
};
|
|
447
343
|
|
|
448
344
|
export type CaptureOptions = {
|
package/src/marketClassify.ts
CHANGED
|
@@ -276,9 +276,68 @@ export type MarketWorksheet = {
|
|
|
276
276
|
vendor: { id: string; name: string };
|
|
277
277
|
claims: MarketClaim[];
|
|
278
278
|
pages: Array<{ kind: CaptureEntry["kind"]; url: string; captureHash: string; text: string }>;
|
|
279
|
+
/** Deterministic retrieval hints: page lines that contain claim terms. These are not labels. */
|
|
280
|
+
claimHints: Array<{
|
|
281
|
+
claimId: string;
|
|
282
|
+
matches: Array<{ url: string; captureHash: string; term: string; quote: string }>;
|
|
283
|
+
}>;
|
|
279
284
|
instructions: string;
|
|
280
285
|
};
|
|
281
286
|
|
|
287
|
+
const HINT_STOPWORDS = new Set([
|
|
288
|
+
"the", "and", "for", "with", "from", "that", "this", "into", "your", "you", "are", "but", "not", "when", "how", "use", "uses", "using",
|
|
289
|
+
"claim", "claims", "vendor", "vendors", "page", "pages", "loud", "quiet", "absent", "capability", "specific", "differentiating",
|
|
290
|
+
"unknown", "buyer", "buyers", "pricing", "structure", "category", "support", "supported", "primary", "secondary", "mentioned", "mentions",
|
|
291
|
+
]);
|
|
292
|
+
|
|
293
|
+
function normalizeHintText(value: string): string {
|
|
294
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().replace(/\s+/g, " ");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function claimHintTerms(claim: MarketClaim): string[] {
|
|
298
|
+
const exact = [claim.id.replace(/[-_]+/g, " "), claim.capability, ...(claim.terms ?? [])]
|
|
299
|
+
.map(normalizeHintText)
|
|
300
|
+
.filter((term) => term.length >= 3);
|
|
301
|
+
const tokenSource = normalizeHintText(`${claim.capability} ${claim.definition} ${(claim.terms ?? []).join(" ")}`)
|
|
302
|
+
.split(" ")
|
|
303
|
+
.filter((term) => term.length >= 4 && !HINT_STOPWORDS.has(term));
|
|
304
|
+
return Array.from(new Set([...exact, ...tokenSource])).slice(0, 24);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function lineSnippet(line: string, term: string): string {
|
|
308
|
+
const clean = line.trim().replace(/\s+/g, " ");
|
|
309
|
+
if (clean.length <= 300) return clean;
|
|
310
|
+
const lower = clean.toLowerCase();
|
|
311
|
+
const idx = lower.indexOf(term.toLowerCase().split(" ")[0] ?? "");
|
|
312
|
+
const start = Math.max(0, idx < 0 ? 0 : idx - 120);
|
|
313
|
+
return clean.slice(start, start + 300).trim();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function buildClaimHints(
|
|
317
|
+
claims: MarketClaim[],
|
|
318
|
+
pages: Array<{ kind: CaptureEntry["kind"]; url: string; captureHash: string; text: string }>,
|
|
319
|
+
): MarketWorksheet["claimHints"] {
|
|
320
|
+
return claims.map((claim) => {
|
|
321
|
+
const terms = claimHintTerms(claim);
|
|
322
|
+
const matches: Array<{ url: string; captureHash: string; term: string; quote: string }> = [];
|
|
323
|
+
for (const page of pages) {
|
|
324
|
+
const lines = page.text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
325
|
+
for (const term of terms) {
|
|
326
|
+
const normalizedTerm = normalizeHintText(term);
|
|
327
|
+
const line = lines.find((candidate) => normalizeHintText(candidate).includes(normalizedTerm));
|
|
328
|
+
if (!line) continue;
|
|
329
|
+
const quote = lineSnippet(line, term);
|
|
330
|
+
if (quote && !matches.some((m) => m.quote === quote && m.url === page.url)) {
|
|
331
|
+
matches.push({ url: page.url, captureHash: page.captureHash, term, quote });
|
|
332
|
+
}
|
|
333
|
+
if (matches.length >= 4) break;
|
|
334
|
+
}
|
|
335
|
+
if (matches.length >= 4) break;
|
|
336
|
+
}
|
|
337
|
+
return { claimId: claim.id, matches };
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
282
341
|
export function buildWorksheet(
|
|
283
342
|
config: MarketConfig,
|
|
284
343
|
vendorId: string,
|
|
@@ -304,7 +363,8 @@ export function buildWorksheet(
|
|
|
304
363
|
vendor: { id: vendor.id, name: vendor.name },
|
|
305
364
|
claims: config.claims,
|
|
306
365
|
pages,
|
|
366
|
+
claimHints: buildClaimHints(config.claims, pages),
|
|
307
367
|
instructions:
|
|
308
|
-
"Produce one observation per claim
|
|
368
|
+
"Produce one observation per claim from these pages only. Intensity rubric: loud = hero copy, primary positioning, or a named/dedicated product area; quiet = supported by a verbatim page quote but secondary, qualified, or merely available; absent = the pages do not support the claim; unobservable = no usable captured page exists for this vendor, not a synonym for absent. claimHints are deterministic retrieval hints only: use them to find candidate support, but do not mark a claim loud/quiet unless the quote truly supports the claim. Every loud/quiet reading must quote a verbatim span (≤300 chars) from a page's text, with that page's url and captureHash in evidence metadata. Submit as an ObservationSet via `market observe --from <file>` — quotes are mechanically verified against the captures.",
|
|
309
369
|
};
|
|
310
370
|
}
|