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
|
@@ -9,6 +9,41 @@
|
|
|
9
9
|
|
|
10
10
|
const DEFAULT_LOGIN_URL = "https://login.salesforce.com";
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Normalize and validate a Salesforce credential-bearing origin.
|
|
14
|
+
*
|
|
15
|
+
* Salesforce API hosts are either the standard login/sandbox hosts or a
|
|
16
|
+
* Salesforce-owned instance/My Domain below salesforce.com. Deliberately
|
|
17
|
+
* return an origin (not the input URL) so callers cannot accidentally retain
|
|
18
|
+
* paths, queries, credentials, or fragments from configuration.
|
|
19
|
+
*/
|
|
20
|
+
export function validateSalesforceOrigin(value: string, label = "Salesforce URL"): string {
|
|
21
|
+
let url: URL;
|
|
22
|
+
try {
|
|
23
|
+
url = new URL(value);
|
|
24
|
+
} catch {
|
|
25
|
+
throw new Error(`${label} must be a valid HTTPS Salesforce URL.`);
|
|
26
|
+
}
|
|
27
|
+
if (url.protocol !== "https:") throw new Error(`${label} must use HTTPS.`);
|
|
28
|
+
if (url.username || url.password) throw new Error(`${label} must not contain user information.`);
|
|
29
|
+
if (url.hash) throw new Error(`${label} must not contain a fragment.`);
|
|
30
|
+
if (url.search) throw new Error(`${label} must not contain a query string.`);
|
|
31
|
+
if (url.pathname !== "/" && url.pathname !== "") throw new Error(`${label} must be an origin without a path.`);
|
|
32
|
+
if (url.port && url.port !== "443") throw new Error(`${label} must use the standard HTTPS port.`);
|
|
33
|
+
|
|
34
|
+
const hostname = url.hostname.toLowerCase();
|
|
35
|
+
const trusted =
|
|
36
|
+
hostname === "login.salesforce.com" ||
|
|
37
|
+
hostname === "test.salesforce.com" ||
|
|
38
|
+
(hostname.endsWith(".salesforce.com") && hostname.length > ".salesforce.com".length);
|
|
39
|
+
if (!trusted) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`${label} must use login.salesforce.com, test.salesforce.com, or a Salesforce instance/My Domain host.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return url.origin;
|
|
45
|
+
}
|
|
46
|
+
|
|
12
47
|
export type SalesforceTokenSet = {
|
|
13
48
|
accessToken: string;
|
|
14
49
|
refreshToken?: string;
|
|
@@ -27,7 +62,7 @@ export type SalesforceDeviceAuthorization = {
|
|
|
27
62
|
const SESSION_TTL_MS = 2 * 60 * 60 * 1000;
|
|
28
63
|
|
|
29
64
|
function tokenUrl(loginUrl: string) {
|
|
30
|
-
return `${loginUrl
|
|
65
|
+
return `${validateSalesforceOrigin(loginUrl, "Salesforce login URL")}/services/oauth2/token`;
|
|
31
66
|
}
|
|
32
67
|
|
|
33
68
|
/**
|
|
@@ -55,6 +90,7 @@ export async function startSalesforceDeviceLogin(options: {
|
|
|
55
90
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
56
91
|
const response = await fetchImpl(tokenUrl(options.loginUrl ?? DEFAULT_LOGIN_URL), {
|
|
57
92
|
method: "POST",
|
|
93
|
+
redirect: "manual",
|
|
58
94
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
59
95
|
body: new URLSearchParams({
|
|
60
96
|
response_type: "device_code",
|
|
@@ -91,6 +127,7 @@ export async function pollSalesforceDeviceLogin(options: {
|
|
|
91
127
|
await new Promise((resolveSleep) => setTimeout(resolveSleep, intervalMs));
|
|
92
128
|
const response = await fetchImpl(url, {
|
|
93
129
|
method: "POST",
|
|
130
|
+
redirect: "manual",
|
|
94
131
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
95
132
|
body: new URLSearchParams({
|
|
96
133
|
grant_type: "device",
|
|
@@ -100,10 +137,11 @@ export async function pollSalesforceDeviceLogin(options: {
|
|
|
100
137
|
});
|
|
101
138
|
const data = await response.json().catch(() => ({}));
|
|
102
139
|
if (response.ok && data.access_token && data.instance_url) {
|
|
140
|
+
const instanceUrl = validateSalesforceOrigin(String(data.instance_url), "Salesforce token instance_url");
|
|
103
141
|
return {
|
|
104
142
|
accessToken: data.access_token,
|
|
105
143
|
refreshToken: data.refresh_token,
|
|
106
|
-
instanceUrl
|
|
144
|
+
instanceUrl,
|
|
107
145
|
expiresAt: Date.now() + SESSION_TTL_MS,
|
|
108
146
|
};
|
|
109
147
|
}
|
|
@@ -128,6 +166,7 @@ export async function refreshSalesforceToken(options: {
|
|
|
128
166
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
129
167
|
const response = await fetchImpl(tokenUrl(options.loginUrl ?? DEFAULT_LOGIN_URL), {
|
|
130
168
|
method: "POST",
|
|
169
|
+
redirect: "manual",
|
|
131
170
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
132
171
|
body: new URLSearchParams({
|
|
133
172
|
grant_type: "refresh_token",
|
|
@@ -145,7 +184,7 @@ export async function refreshSalesforceToken(options: {
|
|
|
145
184
|
return {
|
|
146
185
|
accessToken: data.access_token,
|
|
147
186
|
refreshToken: data.refresh_token ?? options.refreshToken,
|
|
148
|
-
instanceUrl: data.instance_url,
|
|
187
|
+
instanceUrl: validateSalesforceOrigin(String(data.instance_url), "Salesforce token instance_url"),
|
|
149
188
|
expiresAt: Date.now() + SESSION_TTL_MS,
|
|
150
189
|
};
|
|
151
190
|
}
|
|
@@ -155,17 +194,18 @@ export async function validateSalesforceToken(
|
|
|
155
194
|
instanceUrl: string,
|
|
156
195
|
fetchImpl: typeof fetch = fetch,
|
|
157
196
|
): Promise<{ ok: boolean; detail: string }> {
|
|
197
|
+
const trustedInstanceUrl = validateSalesforceOrigin(instanceUrl, "Salesforce instance URL");
|
|
158
198
|
let response: Response;
|
|
159
199
|
try {
|
|
160
200
|
response = await fetchImpl(
|
|
161
|
-
`${
|
|
162
|
-
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
|
201
|
+
`${trustedInstanceUrl}/services/oauth2/userinfo`,
|
|
202
|
+
{ redirect: "manual", headers: { Authorization: `Bearer ${accessToken}` } },
|
|
163
203
|
);
|
|
164
204
|
} catch (error) {
|
|
165
205
|
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
166
206
|
return {
|
|
167
207
|
ok: false,
|
|
168
|
-
detail: `Cannot reach Salesforce at ${
|
|
208
|
+
detail: `Cannot reach Salesforce at ${trustedInstanceUrl}${cause}. Check the --instance-url (your My Domain URL, e.g. https://yourco.my.salesforce.com) and network access.`,
|
|
169
209
|
};
|
|
170
210
|
}
|
|
171
211
|
if (response.ok) {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* `metadata.total_results` with include_total_results:true; limit must be ≥ 1
|
|
18
18
|
* (limit:0 → 422, the count gotcha — cf. Explorium's `size`-caps-the-total).
|
|
19
19
|
*/
|
|
20
|
+
import { ProviderHttpError } from "../providerError.ts";
|
|
20
21
|
|
|
21
22
|
type FetchImpl = typeof fetch;
|
|
22
23
|
|
|
@@ -44,14 +45,6 @@ export function theirStackPullCost(companies: number, usdPerCredit?: number): Th
|
|
|
44
45
|
};
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
async function safeText(res: { text(): Promise<string> }): Promise<string> {
|
|
48
|
-
try {
|
|
49
|
-
return (await res.text()).slice(0, 300);
|
|
50
|
-
} catch {
|
|
51
|
-
return "";
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
48
|
/** Firmographic + technographic filter for TheirStack company search. */
|
|
56
49
|
export type TheirStackFilters = {
|
|
57
50
|
/** technology slugs, OR-matched — e.g. ["salesforce","hubspot","pipedrive"]. */
|
|
@@ -136,7 +129,7 @@ export async function theirStackCountCompanies(opts: {
|
|
|
136
129
|
body: buildBody(opts.filters, 1, 0, true),
|
|
137
130
|
});
|
|
138
131
|
if (!res.ok) {
|
|
139
|
-
throw new
|
|
132
|
+
throw new ProviderHttpError("TheirStack", "count", res.status);
|
|
140
133
|
}
|
|
141
134
|
return readTheirStackTotal(await res.json());
|
|
142
135
|
}
|
|
@@ -162,7 +155,7 @@ export async function theirStackSearchCompanies(opts: {
|
|
|
162
155
|
body: buildBody(opts.filters, limit, opts.page ?? 0, true),
|
|
163
156
|
});
|
|
164
157
|
if (!res.ok) {
|
|
165
|
-
throw new
|
|
158
|
+
throw new ProviderHttpError("TheirStack", "search", res.status);
|
|
166
159
|
}
|
|
167
160
|
const body = (await res.json()) as { data?: Array<Record<string, unknown>>; results?: Array<Record<string, unknown>> };
|
|
168
161
|
const rows = body.data ?? body.results ?? [];
|
package/src/credentials.ts
CHANGED
|
@@ -3,17 +3,20 @@ import {
|
|
|
3
3
|
existsSync,
|
|
4
4
|
mkdirSync,
|
|
5
5
|
readdirSync,
|
|
6
|
-
readFileSync,
|
|
7
|
-
statSync,
|
|
8
6
|
unlinkSync,
|
|
9
|
-
writeFileSync,
|
|
10
7
|
} from "node:fs";
|
|
11
8
|
import { createHash } from "node:crypto";
|
|
12
9
|
import { homedir } from "node:os";
|
|
13
10
|
import { join } from "node:path";
|
|
14
11
|
import { refreshHubspotToken } from "./connectors/hubspotAuth.ts";
|
|
15
|
-
import { refreshSalesforceToken } from "./connectors/salesforceAuth.ts";
|
|
12
|
+
import { refreshSalesforceToken, validateSalesforceOrigin } from "./connectors/salesforceAuth.ts";
|
|
16
13
|
import { detectKeychainBackend } from "./keychain.ts";
|
|
14
|
+
import {
|
|
15
|
+
assertNoSymlinkComponents,
|
|
16
|
+
readSecureRegularFile,
|
|
17
|
+
UnsafeManagedPathError,
|
|
18
|
+
writeSecureFileAtomic,
|
|
19
|
+
} from "./secureFile.ts";
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
22
|
* Local CLI credential store: ~/.fullstackgtm/credentials.json (0600), or
|
|
@@ -126,7 +129,9 @@ export function ensureSecureHomeDir(): string {
|
|
|
126
129
|
const levels =
|
|
127
130
|
dir === baseHomeDir() ? [dir] : [baseHomeDir(), join(baseHomeDir(), "profiles"), dir];
|
|
128
131
|
for (const level of levels) {
|
|
132
|
+
assertNoSymlinkComponents(level);
|
|
129
133
|
mkdirSync(level, { recursive: true, mode: 0o700 });
|
|
134
|
+
assertNoSymlinkComponents(level);
|
|
130
135
|
try {
|
|
131
136
|
chmodSync(level, 0o700);
|
|
132
137
|
} catch {
|
|
@@ -138,12 +143,7 @@ export function ensureSecureHomeDir(): string {
|
|
|
138
143
|
|
|
139
144
|
/** Write a 0600 file under the home, enforcing the mode even on rewrite. */
|
|
140
145
|
export function writeSecureFile(path: string, contents: string) {
|
|
141
|
-
|
|
142
|
-
try {
|
|
143
|
-
chmodSync(path, 0o600);
|
|
144
|
-
} catch {
|
|
145
|
-
// Non-POSIX filesystems ignore chmod.
|
|
146
|
-
}
|
|
146
|
+
writeSecureFileAtomic(path, contents);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
/**
|
|
@@ -153,19 +153,14 @@ export function writeSecureFile(path: string, contents: string) {
|
|
|
153
153
|
* mode on read too — re-tighten to 0600 and warn once — so a world-readable
|
|
154
154
|
* credential store can't sit there silently leaking the token to other users.
|
|
155
155
|
*/
|
|
156
|
-
function
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
} catch {
|
|
167
|
-
// Missing file or non-POSIX filesystem: nothing to enforce.
|
|
168
|
-
}
|
|
156
|
+
function readCredentialFile(path: string): string {
|
|
157
|
+
return readSecureRegularFile(path, {
|
|
158
|
+
tightenMode: 0o600,
|
|
159
|
+
onModeTightened: (mode) => console.error(
|
|
160
|
+
`fullstackgtm: tightened ${path} from ${mode.toString(8).padStart(3, "0")} to 600 ` +
|
|
161
|
+
"(it was readable or writable by other users).",
|
|
162
|
+
),
|
|
163
|
+
});
|
|
169
164
|
}
|
|
170
165
|
|
|
171
166
|
/**
|
|
@@ -190,7 +185,7 @@ function activeKeychain(): { account: string; backend: NonNullable<ReturnType<ty
|
|
|
190
185
|
function migratePlaintextToKeychain(keychain: NonNullable<ReturnType<typeof activeKeychain>>): void {
|
|
191
186
|
if (!existsSync(credentialsPath())) return;
|
|
192
187
|
try {
|
|
193
|
-
const fileParsed = JSON.parse(
|
|
188
|
+
const fileParsed = JSON.parse(readCredentialFile(credentialsPath()));
|
|
194
189
|
if (fileParsed && fileParsed.version === 1 && fileParsed.providers) {
|
|
195
190
|
const current = keychain.backend.get(keychain.account);
|
|
196
191
|
const existing = current ? (JSON.parse(current).providers ?? {}) : {};
|
|
@@ -209,14 +204,15 @@ function readFile(): CredentialsFile {
|
|
|
209
204
|
const keychain = activeKeychain();
|
|
210
205
|
if (keychain) migratePlaintextToKeychain(keychain);
|
|
211
206
|
try {
|
|
212
|
-
const raw = keychain ? keychain.backend.get(keychain.account) : (
|
|
207
|
+
const raw = keychain ? keychain.backend.get(keychain.account) : readCredentialFile(credentialsPath());
|
|
213
208
|
if (raw) {
|
|
214
209
|
const parsed = JSON.parse(raw);
|
|
215
210
|
if (parsed && typeof parsed === "object" && parsed.version === 1 && parsed.providers) {
|
|
216
211
|
return parsed as CredentialsFile;
|
|
217
212
|
}
|
|
218
213
|
}
|
|
219
|
-
} catch {
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (error instanceof UnsafeManagedPathError) throw error;
|
|
220
216
|
// Missing or unreadable store falls through to an empty one.
|
|
221
217
|
}
|
|
222
218
|
return { version: 1, providers: {} };
|
|
@@ -238,6 +234,18 @@ export function getCredential(provider: string): StoredCredential | null {
|
|
|
238
234
|
}
|
|
239
235
|
|
|
240
236
|
export function storeCredential(provider: string, credential: StoredCredential) {
|
|
237
|
+
if (provider === "salesforce") {
|
|
238
|
+
if (!credential.instanceUrl) {
|
|
239
|
+
throw new Error("Salesforce credentials require an instance URL.");
|
|
240
|
+
}
|
|
241
|
+
credential = {
|
|
242
|
+
...credential,
|
|
243
|
+
instanceUrl: validateSalesforceOrigin(credential.instanceUrl, "Salesforce credential instance URL"),
|
|
244
|
+
...(credential.loginUrl
|
|
245
|
+
? { loginUrl: validateSalesforceOrigin(credential.loginUrl, "Salesforce credential login URL") }
|
|
246
|
+
: {}),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
241
249
|
const file = readFile();
|
|
242
250
|
file.providers[provider] = credential;
|
|
243
251
|
persist(file);
|
|
@@ -315,12 +323,19 @@ export async function resolveSalesforceConnection(
|
|
|
315
323
|
"Stored Salesforce credential has no instance URL. Run `fullstackgtm login salesforce` again.",
|
|
316
324
|
);
|
|
317
325
|
}
|
|
326
|
+
const instanceUrl = validateSalesforceOrigin(
|
|
327
|
+
credential.instanceUrl,
|
|
328
|
+
"Stored Salesforce credential instance URL",
|
|
329
|
+
);
|
|
330
|
+
if (credential.loginUrl) {
|
|
331
|
+
validateSalesforceOrigin(credential.loginUrl, "Stored Salesforce credential login URL");
|
|
332
|
+
}
|
|
318
333
|
const needsRefresh =
|
|
319
334
|
credential.kind === "oauth" &&
|
|
320
335
|
credential.expiresAt !== undefined &&
|
|
321
336
|
Date.now() > credential.expiresAt - REFRESH_SKEW_MS;
|
|
322
337
|
if (!needsRefresh) {
|
|
323
|
-
return { accessToken: credential.accessToken, instanceUrl
|
|
338
|
+
return { accessToken: credential.accessToken, instanceUrl };
|
|
324
339
|
}
|
|
325
340
|
if (!credential.refreshToken || !credential.clientId) {
|
|
326
341
|
throw new Error(
|
|
@@ -349,9 +364,13 @@ export async function resolveSalesforceConnection(
|
|
|
349
364
|
if (!minted.instanceUrl) {
|
|
350
365
|
throw new Error("The hosted deployment returned no Salesforce instance URL.");
|
|
351
366
|
}
|
|
367
|
+
const instanceUrl = validateSalesforceOrigin(
|
|
368
|
+
minted.instanceUrl,
|
|
369
|
+
"Hosted Salesforce credential instance URL",
|
|
370
|
+
);
|
|
352
371
|
return {
|
|
353
372
|
accessToken: minted.accessToken,
|
|
354
|
-
instanceUrl
|
|
373
|
+
instanceUrl,
|
|
355
374
|
fieldMappings: minted.fieldMappings,
|
|
356
375
|
};
|
|
357
376
|
}
|
package/src/enrich.ts
CHANGED
|
@@ -111,6 +111,8 @@ export type AcquireDiscoveryConfig = {
|
|
|
111
111
|
provider: "explorium" | "pipe0" | "linkedin" | "heyreach";
|
|
112
112
|
filters?: Record<string, unknown>;
|
|
113
113
|
size?: number;
|
|
114
|
+
/** Maximum raw provider candidates scanned per run while seeking fresh leads. */
|
|
115
|
+
scanLimit?: number;
|
|
114
116
|
resolveEmailsWith?: "pipe0";
|
|
115
117
|
/** LinkedIn sources only: the provider-native list id to read (HeyReach lead-list id). */
|
|
116
118
|
listId?: string;
|
|
@@ -1326,7 +1328,49 @@ export function selectStaleWork(
|
|
|
1326
1328
|
// Checkpoint (cursor), staleness ledger (stamps), and observability surface
|
|
1327
1329
|
// (enrich status) in one structure — mirrors the market observations store.
|
|
1328
1330
|
|
|
1329
|
-
export type EnrichRunMode = EnrichMode | "ingest";
|
|
1331
|
+
export type EnrichRunMode = EnrichMode | "ingest" | "acquire";
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Provider continuation state for an acquisition query. The fingerprint binds
|
|
1335
|
+
* a cursor/offset to the ICP + provider filters that produced it, preventing a
|
|
1336
|
+
* changed query from accidentally resuming in the middle of the old audience.
|
|
1337
|
+
*/
|
|
1338
|
+
export type AcquireDiscoveryCheckpoint = {
|
|
1339
|
+
queryFingerprint: string;
|
|
1340
|
+
/** Opaque provider cursor (Pipe0 and other cursor-based sources). */
|
|
1341
|
+
cursor?: string | null;
|
|
1342
|
+
/** Zero-based provider offset (LinkedIn/HeyReach and other offset sources). */
|
|
1343
|
+
offset?: number | null;
|
|
1344
|
+
/** True only when the provider has positively reported no next page. */
|
|
1345
|
+
exhausted: boolean;
|
|
1346
|
+
};
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* Truthful acquisition funnel for one run. Each field counts candidates, not
|
|
1350
|
+
* provider requests, making duplicate saturation and resolution loss visible
|
|
1351
|
+
* instead of reporting every run as zero.
|
|
1352
|
+
*/
|
|
1353
|
+
export type AcquireRunFunnel = {
|
|
1354
|
+
/** Raw candidates returned across all discovery pages scanned this run. */
|
|
1355
|
+
discovered: number;
|
|
1356
|
+
/** Candidates that met the configured ICP threshold. */
|
|
1357
|
+
qualified: number;
|
|
1358
|
+
/** Qualified candidates removed by the CRM pre-email dedupe. */
|
|
1359
|
+
skippedCrm: number;
|
|
1360
|
+
/** Qualified candidates removed by the cross-run seen ledger. */
|
|
1361
|
+
skippedSeen: number;
|
|
1362
|
+
/** Fresh candidates carrying the configured resolve-first key. */
|
|
1363
|
+
resolved: number;
|
|
1364
|
+
/** Governed create operations emitted into the saved/dry-run plan. */
|
|
1365
|
+
proposed: number;
|
|
1366
|
+
/** Otherwise-proposable candidates held back by the acquire meter. */
|
|
1367
|
+
withheldByMeter: number;
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
export type AcquireRunTelemetry = {
|
|
1371
|
+
funnel: AcquireRunFunnel;
|
|
1372
|
+
discovery: AcquireDiscoveryCheckpoint;
|
|
1373
|
+
};
|
|
1330
1374
|
|
|
1331
1375
|
export type EnrichRun = {
|
|
1332
1376
|
id: string;
|
|
@@ -1339,6 +1383,8 @@ export type EnrichRun = {
|
|
|
1339
1383
|
/** Resume point for an interrupted pull (last processed pull key). */
|
|
1340
1384
|
cursor: string | null;
|
|
1341
1385
|
counts: EnrichCounts;
|
|
1386
|
+
/** Acquisition-only funnel + provider continuation state (absent on legacy runs). */
|
|
1387
|
+
acquireTelemetry?: AcquireRunTelemetry;
|
|
1342
1388
|
planIds: string[];
|
|
1343
1389
|
stamps: EnrichStamp[];
|
|
1344
1390
|
/** Staged source rows (ingest mode only), consumed by append/refresh. */
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated acquisition-checkpoint transport for paired CLI installs.
|
|
3
|
+
*
|
|
4
|
+
* The local checkpoint remains authoritative for running a job. This module
|
|
5
|
+
* only mirrors/query-scoped continuation state to the paired hosted app so a
|
|
6
|
+
* different authenticated worker can resume it. Writes use compare-and-swap:
|
|
7
|
+
* a stale client receives a conflict and must explicitly reconcile instead of
|
|
8
|
+
* silently moving a provider cursor backwards or overwriting another worker.
|
|
9
|
+
*/
|
|
10
|
+
import { getCredential } from "./credentials.ts";
|
|
11
|
+
import type { AcquireDiscoveryCheckpoint } from "./enrich.ts";
|
|
12
|
+
|
|
13
|
+
const ENDPOINT = "/api/cli/acquisition-checkpoint";
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 4000;
|
|
15
|
+
const CHECKPOINT_KEY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
|
16
|
+
|
|
17
|
+
export type HostedAcquireCheckpoint = {
|
|
18
|
+
checkpoint: AcquireDiscoveryCheckpoint;
|
|
19
|
+
/** Opaque server revision used only for compare-and-swap. */
|
|
20
|
+
version: number;
|
|
21
|
+
updatedAt: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type HostedCheckpointReadResult =
|
|
25
|
+
| { status: "unpaired" }
|
|
26
|
+
| { status: "missing" }
|
|
27
|
+
| { status: "found"; record: HostedAcquireCheckpoint }
|
|
28
|
+
| { status: "unavailable"; reason: string };
|
|
29
|
+
|
|
30
|
+
export type HostedCheckpointWriteResult =
|
|
31
|
+
| { status: "unpaired" }
|
|
32
|
+
| { status: "saved"; record: HostedAcquireCheckpoint }
|
|
33
|
+
| { status: "conflict"; current: HostedAcquireCheckpoint | null }
|
|
34
|
+
| { status: "unavailable"; reason: string };
|
|
35
|
+
|
|
36
|
+
type Options = { fetchImpl?: typeof fetch; timeoutMs?: number };
|
|
37
|
+
|
|
38
|
+
function validKey(key: string): string {
|
|
39
|
+
if (!CHECKPOINT_KEY.test(key)) {
|
|
40
|
+
throw new Error("Acquisition checkpoint key must be 1-256 URL-safe characters.");
|
|
41
|
+
}
|
|
42
|
+
return key;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function broker(): { baseUrl: string; accessToken: string } | null {
|
|
46
|
+
const credential = getCredential("broker");
|
|
47
|
+
if (!credential?.baseUrl || !credential.accessToken) return null;
|
|
48
|
+
let url: URL;
|
|
49
|
+
try {
|
|
50
|
+
url = new URL(credential.baseUrl);
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
55
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) return null;
|
|
56
|
+
return { baseUrl: url.href.replace(/\/+$/, ""), accessToken: credential.accessToken };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isCheckpoint(value: unknown): value is AcquireDiscoveryCheckpoint {
|
|
60
|
+
if (!value || typeof value !== "object") return false;
|
|
61
|
+
const item = value as Record<string, unknown>;
|
|
62
|
+
return (
|
|
63
|
+
typeof item.queryFingerprint === "string" &&
|
|
64
|
+
item.queryFingerprint.length > 0 &&
|
|
65
|
+
typeof item.exhausted === "boolean" &&
|
|
66
|
+
(item.cursor === undefined || item.cursor === null || typeof item.cursor === "string") &&
|
|
67
|
+
(item.offset === undefined || item.offset === null ||
|
|
68
|
+
(typeof item.offset === "number" && Number.isSafeInteger(item.offset) && item.offset >= 0))
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseRecord(value: unknown): HostedAcquireCheckpoint | null {
|
|
73
|
+
if (!value || typeof value !== "object") return null;
|
|
74
|
+
const body = value as Record<string, unknown>;
|
|
75
|
+
if (!isCheckpoint(body.checkpoint) || !Number.isSafeInteger(body.version) || (body.version as number) < 1) return null;
|
|
76
|
+
if (!Number.isSafeInteger(body.updatedAt) || (body.updatedAt as number) < 0) return null;
|
|
77
|
+
return {
|
|
78
|
+
checkpoint: body.checkpoint,
|
|
79
|
+
version: body.version as number,
|
|
80
|
+
updatedAt: body.updatedAt as number,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function reasonFor(status: number): string {
|
|
85
|
+
if (status === 401 || status === 403) return "hosted authentication was rejected; pair the CLI again";
|
|
86
|
+
return `hosted checkpoint request failed (HTTP ${status})`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Read a query/list checkpoint. Inert unless this profile is paired. */
|
|
90
|
+
export async function readHostedAcquireCheckpoint(
|
|
91
|
+
key: string,
|
|
92
|
+
options: Options = {},
|
|
93
|
+
): Promise<HostedCheckpointReadResult> {
|
|
94
|
+
validKey(key);
|
|
95
|
+
const paired = broker();
|
|
96
|
+
if (!paired) return { status: "unpaired" };
|
|
97
|
+
try {
|
|
98
|
+
const response = await (options.fetchImpl ?? fetch)(
|
|
99
|
+
`${paired.baseUrl}${ENDPOINT}?key=${encodeURIComponent(key)}`,
|
|
100
|
+
{
|
|
101
|
+
headers: { Authorization: `Bearer ${paired.accessToken}`, Accept: "application/json" },
|
|
102
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
if (response.status === 404) return { status: "missing" };
|
|
106
|
+
if (!response.ok) return { status: "unavailable", reason: reasonFor(response.status) };
|
|
107
|
+
const record = parseRecord(await response.json());
|
|
108
|
+
return record
|
|
109
|
+
? { status: "found", record }
|
|
110
|
+
: { status: "unavailable", reason: "hosted checkpoint response was invalid" };
|
|
111
|
+
} catch {
|
|
112
|
+
return { status: "unavailable", reason: "hosted checkpoint request was unavailable" };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Mirror a local checkpoint using compare-and-swap. Pass null only when the
|
|
118
|
+
* caller observed no hosted record (sent as version zero); pass the exact
|
|
119
|
+
* positive version returned by read for an update.
|
|
120
|
+
*/
|
|
121
|
+
export async function writeHostedAcquireCheckpoint(
|
|
122
|
+
key: string,
|
|
123
|
+
checkpoint: AcquireDiscoveryCheckpoint,
|
|
124
|
+
expectedVersion: number | null,
|
|
125
|
+
options: Options = {},
|
|
126
|
+
): Promise<HostedCheckpointWriteResult> {
|
|
127
|
+
validKey(key);
|
|
128
|
+
if (!isCheckpoint(checkpoint)) throw new Error("Refusing to sync an invalid acquisition checkpoint.");
|
|
129
|
+
if (expectedVersion !== null && (!Number.isSafeInteger(expectedVersion) || expectedVersion < 1)) {
|
|
130
|
+
throw new Error("expectedVersion must be null or a positive integer.");
|
|
131
|
+
}
|
|
132
|
+
const paired = broker();
|
|
133
|
+
if (!paired) return { status: "unpaired" };
|
|
134
|
+
try {
|
|
135
|
+
const response = await (options.fetchImpl ?? fetch)(`${paired.baseUrl}${ENDPOINT}`, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: { Authorization: `Bearer ${paired.accessToken}`, "Content-Type": "application/json" },
|
|
138
|
+
body: JSON.stringify({ key, checkpoint, expectedVersion: expectedVersion ?? 0 }),
|
|
139
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
140
|
+
});
|
|
141
|
+
if (response.status === 409) {
|
|
142
|
+
const body: unknown = await response.json().catch(() => null);
|
|
143
|
+
const current = body && typeof body === "object"
|
|
144
|
+
? parseRecord((body as Record<string, unknown>).current)
|
|
145
|
+
: null;
|
|
146
|
+
return { status: "conflict", current };
|
|
147
|
+
}
|
|
148
|
+
if (!response.ok) return { status: "unavailable", reason: reasonFor(response.status) };
|
|
149
|
+
const record = parseRecord(await response.json());
|
|
150
|
+
return record
|
|
151
|
+
? { status: "saved", record }
|
|
152
|
+
: { status: "unavailable", reason: "hosted checkpoint response was invalid" };
|
|
153
|
+
} catch {
|
|
154
|
+
return { status: "unavailable", reason: "hosted checkpoint request was unavailable" };
|
|
155
|
+
}
|
|
156
|
+
}
|