deepline 0.3.85 → 0.3.87
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/dist/bundling-sources/sdk/src/client.ts +2 -2
- package/dist/bundling-sources/sdk/src/monitor-fleet-contract.ts +1 -1
- package/dist/bundling-sources/sdk/src/plays/bundle-play-file.ts +1 -1
- package/dist/bundling-sources/sdk/src/release.ts +1 -1
- package/dist/bundling-sources/shared_libs/integration-contracts/AGENTS.md +9 -0
- package/dist/bundling-sources/shared_libs/integration-contracts/README.md +3 -0
- package/dist/bundling-sources/shared_libs/integration-contracts/index.ts +2 -0
- package/dist/bundling-sources/shared_libs/monitors/analytics-install-guides.ts +77 -0
- package/dist/bundling-sources/shared_libs/monitors/billing/charges.ts +125 -0
- package/dist/bundling-sources/shared_libs/monitors/billing/policy-builders.ts +686 -0
- package/dist/bundling-sources/shared_libs/monitors/billing/pricing.ts +54 -0
- package/dist/bundling-sources/shared_libs/monitors/billing/public-pricing.ts +72 -0
- package/dist/bundling-sources/shared_libs/monitors/billing/session.ts +45 -0
- package/dist/bundling-sources/shared_libs/monitors/billing/state.ts +454 -0
- package/dist/bundling-sources/shared_libs/monitors/billing/tamradar.ts +260 -0
- package/dist/bundling-sources/shared_libs/monitors/billing/types.ts +215 -0
- package/dist/bundling-sources/shared_libs/monitors/callback-security.ts +333 -0
- package/dist/bundling-sources/shared_libs/monitors/cursor-page.ts +42 -0
- package/dist/bundling-sources/shared_libs/monitors/framework-managed-payload-fields.ts +71 -0
- package/dist/bundling-sources/shared_libs/monitors/generated/provider-monitor-callback-admission-targets.ts +16 -0
- package/dist/bundling-sources/shared_libs/monitors/http-error-response.ts +267 -0
- package/dist/bundling-sources/shared_libs/monitors/inbound-publish-key.ts +39 -0
- package/dist/bundling-sources/shared_libs/monitors/inbound-queue.ts +82 -0
- package/dist/bundling-sources/shared_libs/monitors/lifecycle-notifications.ts +473 -0
- package/dist/bundling-sources/shared_libs/monitors/org-monitor-limits.ts +1 -1
- package/dist/bundling-sources/shared_libs/monitors/package.json +29 -0
- package/dist/bundling-sources/shared_libs/monitors/provider-callback-verification.ts +1125 -0
- package/dist/bundling-sources/shared_libs/monitors/provider-monitor-capability.ts +964 -0
- package/dist/bundling-sources/shared_libs/monitors/provider-monitor-consumer-health.ts +56 -0
- package/dist/bundling-sources/shared_libs/monitors/provider-monitor-errors.ts +131 -0
- package/dist/bundling-sources/shared_libs/monitors/provider-monitor-identity-claims.ts +527 -0
- package/dist/bundling-sources/shared_libs/monitors/provider-monitor-legacy-aliases.ts +16 -0
- package/dist/bundling-sources/shared_libs/monitors/provider-monitor-priority-execution.ts +56 -0
- package/dist/bundling-sources/shared_libs/monitors/provider-monitor-public-aliases.ts +28 -0
- package/dist/bundling-sources/shared_libs/monitors/provider-monitor-runtime-read-eligibility.ts +38 -0
- package/dist/bundling-sources/shared_libs/monitors/provider-monitor-status.ts +10 -0
- package/dist/bundling-sources/shared_libs/monitors/provider-monitor-upstream-identity.ts +112 -0
- package/dist/bundling-sources/shared_libs/monitors/provider-webhook-monitor-runtime.ts +300 -0
- package/dist/bundling-sources/shared_libs/monitors/quickstart-prompts.ts +22 -0
- package/dist/bundling-sources/shared_libs/monitors/snitcher-install-validation.ts +93 -0
- package/dist/bundling-sources/shared_libs/monitors/snitcher-ip2company.ts +332 -0
- package/dist/bundling-sources/shared_libs/monitors/snitcher-radar.ts +221 -0
- package/dist/bundling-sources/shared_libs/monitors/starter-templates.ts +105 -0
- package/dist/bundling-sources/shared_libs/monitors/tracking-provider-guidance.ts +73 -0
- package/dist/bundling-sources/shared_libs/monitors/tracking-provider-integrations.ts +81 -0
- package/dist/bundling-sources/shared_libs/monitors/tsconfig.json +10 -0
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-env.ts +6 -0
- package/dist/bundling-sources/shared_libs/play-runtime/tool-http-errors.ts +1 -1
- package/dist/bundling-sources/shared_libs/play-runtime/tool-result.ts +1 -1
- package/dist/bundling-sources/shared_libs/plays/authoring-contract.ts +302 -0
- package/dist/bundling-sources/shared_libs/plays/email-status.ts +1 -1
- package/dist/bundling-sources/shared_libs/plays/tool-category-descriptions.ts +2 -2
- package/dist/bundling-sources/shared_libs/runtime-env.ts +6 -0
- package/dist/bundling-sources/shared_libs/security/header-safe-env.ts +28 -0
- package/dist/bundling-sources/shared_libs/security/secret-redaction.ts +267 -0
- package/dist/cli/index.js +25 -14
- package/dist/cli/index.mjs +25 -14
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -3
- package/dist/index.mjs +3 -3
- package/dist/install-integrity.json +45 -2
- package/package.json +3 -1
- /package/dist/bundling-sources/shared_libs/{integrations/bettercontact-execution-policy.ts → integration-contracts/bettercontact/execution-policy.ts} +0 -0
- /package/dist/bundling-sources/shared_libs/{integrations/theirstack-execution-policy.ts → integration-contracts/theirstack/execution-policy.ts} +0 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { createHmac, randomUUID } from 'node:crypto';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import { internal } from '@convex/_generated/api';
|
|
4
|
+
import { hasSnitcherSessionIdentity } from '@deepline/ingestion-v2/snitcher-session-identity';
|
|
5
|
+
import { getRequiredInternalConvexClient } from '@/lib/convex/internal-client';
|
|
6
|
+
|
|
7
|
+
export { hasSnitcherSessionIdentity };
|
|
8
|
+
|
|
9
|
+
const SNITCHER_IP2COMPANY_URL = 'https://api.snitcher.com/company/find';
|
|
10
|
+
const IP2COMPANY_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
export type SnitcherIp2CompanyResult = {
|
|
13
|
+
status:
|
|
14
|
+
| 'identified'
|
|
15
|
+
| 'no_company'
|
|
16
|
+
| 'queued'
|
|
17
|
+
| 'quota_exceeded'
|
|
18
|
+
| 'rate_limited'
|
|
19
|
+
| 'failed'
|
|
20
|
+
| 'skipped_no_ip'
|
|
21
|
+
| 'skipped_non_public_ip';
|
|
22
|
+
ip: string | null;
|
|
23
|
+
networkType?: string | null;
|
|
24
|
+
company?: Record<string, unknown> | null;
|
|
25
|
+
geoIp?: Record<string, unknown> | null;
|
|
26
|
+
error?: string | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type FetchImplementation = typeof fetch;
|
|
30
|
+
|
|
31
|
+
type CachedSnitcherIp2CompanyResult = Omit<SnitcherIp2CompanyResult, 'ip'>;
|
|
32
|
+
|
|
33
|
+
export class SnitcherIp2CompanyResolutionPendingError extends Error {
|
|
34
|
+
constructor() {
|
|
35
|
+
super(
|
|
36
|
+
'A Deepline Analytics IP2Company lookup for this visitor is already in progress. Retry the callback without issuing another provider lookup.',
|
|
37
|
+
);
|
|
38
|
+
this.name = 'SnitcherIp2CompanyResolutionPendingError';
|
|
39
|
+
Object.assign(this, { code: 'snitcher_ip2company_retryable' });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class SnitcherIp2CompanyResolutionUnresolvedError extends Error {
|
|
44
|
+
constructor(status: SnitcherIp2CompanyResult['status']) {
|
|
45
|
+
super(
|
|
46
|
+
`Snitcher IP2Company resolution is ${status}; retry this inbound delivery before deciding whether to materialize the session.`,
|
|
47
|
+
);
|
|
48
|
+
this.name = 'SnitcherIp2CompanyResolutionUnresolvedError';
|
|
49
|
+
Object.assign(this, {
|
|
50
|
+
code:
|
|
51
|
+
status === 'quota_exceeded'
|
|
52
|
+
? 'snitcher_ip2company_quota_exceeded'
|
|
53
|
+
: 'snitcher_ip2company_retryable',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readRecord(value: unknown): Record<string, unknown> | null {
|
|
59
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
60
|
+
? (value as Record<string, unknown>)
|
|
61
|
+
: null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readString(value: unknown) {
|
|
65
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isPublicIpv4(ip: string) {
|
|
69
|
+
const [first, second] = ip.split('.').map(Number);
|
|
70
|
+
return !(
|
|
71
|
+
first === 0 ||
|
|
72
|
+
first === 10 ||
|
|
73
|
+
first === 127 ||
|
|
74
|
+
(first === 100 && second >= 64 && second <= 127) ||
|
|
75
|
+
(first === 169 && second === 254) ||
|
|
76
|
+
(first === 172 && second >= 16 && second <= 31) ||
|
|
77
|
+
(first === 192 && second === 168) ||
|
|
78
|
+
(first === 192 && second === 0) ||
|
|
79
|
+
(first === 192 && second === 2) ||
|
|
80
|
+
(first === 198 && (second === 18 || second === 19 || second === 51)) ||
|
|
81
|
+
(first === 203 && second === 0) ||
|
|
82
|
+
first >= 224
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isPublicIp(value: string) {
|
|
87
|
+
const family = isIP(value);
|
|
88
|
+
if (family === 4) return isPublicIpv4(value);
|
|
89
|
+
if (family !== 6) return false;
|
|
90
|
+
const normalized = value.toLowerCase();
|
|
91
|
+
return !(
|
|
92
|
+
normalized === '::1' ||
|
|
93
|
+
normalized.startsWith('fc') ||
|
|
94
|
+
normalized.startsWith('fd') ||
|
|
95
|
+
normalized.startsWith('fe80:')
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Radar supplies the visitor IP on each event's browser geo context. */
|
|
100
|
+
export function snitcherSessionIp(payload: unknown): string | null {
|
|
101
|
+
const root = readRecord(payload);
|
|
102
|
+
const events = Array.isArray(root?.events) ? root.events : [];
|
|
103
|
+
for (const event of events) {
|
|
104
|
+
const context = readRecord(readRecord(event)?.context);
|
|
105
|
+
const geo = readRecord(context?.geo);
|
|
106
|
+
const ip = readString(geo?.ip);
|
|
107
|
+
if (ip) return ip;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A Radar session is useful to the customer only after Radar identifies a
|
|
114
|
+
* visitor by email or IP2Company identifies a company. Anonymous activity
|
|
115
|
+
* remains available in the authenticated raw-delivery ledger, but must not
|
|
116
|
+
* create a customer-facing session row or trigger a play.
|
|
117
|
+
*/
|
|
118
|
+
/**
|
|
119
|
+
* These are definitive outcomes: no later retry can turn this delivery into a
|
|
120
|
+
* person or company record without a different source payload. Provider
|
|
121
|
+
* outages and queued lookups are deliberately excluded so they remain replay
|
|
122
|
+
* candidates in the raw inbound ledger.
|
|
123
|
+
*/
|
|
124
|
+
export function isSnitcherSessionTerminallyUnidentified(payload: unknown) {
|
|
125
|
+
if (hasSnitcherSessionIdentity(payload)) return false;
|
|
126
|
+
const root = readRecord(payload);
|
|
127
|
+
const enrichment = readRecord(root?._deepline_ip2company);
|
|
128
|
+
return (
|
|
129
|
+
readString(enrichment?.status) === 'no_company' ||
|
|
130
|
+
readString(enrichment?.status) === 'skipped_no_ip' ||
|
|
131
|
+
readString(enrichment?.status) === 'skipped_non_public_ip'
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function snitcherSessionIp2CompanyStatus(
|
|
136
|
+
payload: unknown,
|
|
137
|
+
): SnitcherIp2CompanyResult['status'] | null {
|
|
138
|
+
const root = readRecord(payload);
|
|
139
|
+
const enrichment = readRecord(root?._deepline_ip2company);
|
|
140
|
+
const status = readString(enrichment?.status);
|
|
141
|
+
return status === 'identified' ||
|
|
142
|
+
status === 'no_company' ||
|
|
143
|
+
status === 'queued' ||
|
|
144
|
+
status === 'quota_exceeded' ||
|
|
145
|
+
status === 'rate_limited' ||
|
|
146
|
+
status === 'failed' ||
|
|
147
|
+
status === 'skipped_no_ip' ||
|
|
148
|
+
status === 'skipped_non_public_ip'
|
|
149
|
+
? status
|
|
150
|
+
: null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function isSnitcherIp2CompanyConfigured() {
|
|
154
|
+
return Boolean(process.env.SNITCHER_IP2C_API_KEY?.trim());
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function apiKey() {
|
|
158
|
+
const value = process.env.SNITCHER_IP2C_API_KEY?.trim();
|
|
159
|
+
if (!value) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
'SNITCHER_IP2C_API_KEY must be configured before Deepline Analytics can enrich visitor companies.',
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function ipCacheHash(input: { orgId: string; ip: string }) {
|
|
168
|
+
// The cache is per workspace. Sharing a resolved visitor IP across tenants
|
|
169
|
+
// would cross a customer-data boundary even though it would lower provider
|
|
170
|
+
// spend. Keep the raw IP out of Convex control-plane state.
|
|
171
|
+
return createHmac('sha256', apiKey())
|
|
172
|
+
.update(`${input.orgId}\u0000${input.ip}`)
|
|
173
|
+
.digest('hex');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function cacheableResult(
|
|
177
|
+
result: SnitcherIp2CompanyResult,
|
|
178
|
+
): CachedSnitcherIp2CompanyResult | null {
|
|
179
|
+
if (result.status !== 'identified' && result.status !== 'no_company') {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const { ip: _ip, ...cached } = result;
|
|
183
|
+
return cached;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function resultFromCache(
|
|
187
|
+
value: unknown,
|
|
188
|
+
ip: string,
|
|
189
|
+
): SnitcherIp2CompanyResult | null {
|
|
190
|
+
const cached = readRecord(value);
|
|
191
|
+
const status = readString(cached?.status);
|
|
192
|
+
if (status !== 'identified' && status !== 'no_company') return null;
|
|
193
|
+
return {
|
|
194
|
+
status,
|
|
195
|
+
ip,
|
|
196
|
+
networkType: readString(cached?.networkType),
|
|
197
|
+
company: readRecord(cached?.company),
|
|
198
|
+
geoIp: readRecord(cached?.geoIp),
|
|
199
|
+
error: readString(cached?.error),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Resolves one public IP through Snitcher IP2Company. This is intentionally
|
|
205
|
+
* internal-only: Analytics owns the platform credential and callers never
|
|
206
|
+
* supply or see it.
|
|
207
|
+
*/
|
|
208
|
+
export async function findSnitcherIp2Company(
|
|
209
|
+
ip: string | null | undefined,
|
|
210
|
+
fetchImplementation: FetchImplementation = fetch,
|
|
211
|
+
): Promise<SnitcherIp2CompanyResult> {
|
|
212
|
+
const candidate = readString(ip);
|
|
213
|
+
if (!candidate) return { status: 'skipped_no_ip', ip: null };
|
|
214
|
+
if (!isPublicIp(candidate)) {
|
|
215
|
+
return { status: 'skipped_non_public_ip', ip: candidate };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let response: Response;
|
|
219
|
+
try {
|
|
220
|
+
response = await fetchImplementation(
|
|
221
|
+
`${SNITCHER_IP2COMPANY_URL}?${new URLSearchParams({ ip: candidate })}`,
|
|
222
|
+
{
|
|
223
|
+
method: 'POST',
|
|
224
|
+
headers: {
|
|
225
|
+
Authorization: `Bearer ${apiKey()}`,
|
|
226
|
+
Accept: 'application/json',
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
return {
|
|
232
|
+
status: 'failed',
|
|
233
|
+
ip: candidate,
|
|
234
|
+
error: error instanceof Error ? error.message : 'Network request failed.',
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const body = await response.json().catch(() => null);
|
|
239
|
+
const record = readRecord(body);
|
|
240
|
+
if (response.status === 202) return { status: 'queued', ip: candidate };
|
|
241
|
+
if (response.status === 403)
|
|
242
|
+
return { status: 'quota_exceeded', ip: candidate };
|
|
243
|
+
if (response.status === 429) return { status: 'rate_limited', ip: candidate };
|
|
244
|
+
if (!response.ok) {
|
|
245
|
+
return {
|
|
246
|
+
status: 'failed',
|
|
247
|
+
ip: candidate,
|
|
248
|
+
error: readString(record?.message) ?? `HTTP ${response.status}`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const company = readRecord(record?.company);
|
|
253
|
+
return {
|
|
254
|
+
status: company ? 'identified' : 'no_company',
|
|
255
|
+
ip: candidate,
|
|
256
|
+
networkType: readString(record?.type),
|
|
257
|
+
company,
|
|
258
|
+
geoIp: readRecord(record?.geoIP),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Adds a non-secret enrichment envelope consumed by the typed session writer. */
|
|
263
|
+
export async function enrichSnitcherSessionWithIp2Company(input: {
|
|
264
|
+
orgId: string;
|
|
265
|
+
payload: Record<string, unknown>;
|
|
266
|
+
}): Promise<Record<string, unknown>> {
|
|
267
|
+
const root = readRecord(input.payload);
|
|
268
|
+
if (!root) return input.payload;
|
|
269
|
+
const ip = snitcherSessionIp(root);
|
|
270
|
+
if (!ip || !isPublicIp(ip)) {
|
|
271
|
+
const enrichment = await findSnitcherIp2Company(ip);
|
|
272
|
+
return { ...root, _deepline_ip2company: enrichment };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const cacheKey = ipCacheHash({ orgId: input.orgId, ip });
|
|
276
|
+
const leaseId = randomUUID();
|
|
277
|
+
const client = getRequiredInternalConvexClient();
|
|
278
|
+
const claim = await client.mutation(internal.analyticsIp2cCache.claim, {
|
|
279
|
+
orgId: input.orgId,
|
|
280
|
+
ipHash: cacheKey,
|
|
281
|
+
leaseId,
|
|
282
|
+
now: Date.now(),
|
|
283
|
+
});
|
|
284
|
+
if (claim.kind === 'hit') {
|
|
285
|
+
const enrichment = resultFromCache(claim.result, ip);
|
|
286
|
+
if (!enrichment) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
'Deepline Analytics IP2Company cache returned an invalid resolved value.',
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
return { ...root, _deepline_ip2company: enrichment };
|
|
292
|
+
}
|
|
293
|
+
if (claim.kind === 'pending') {
|
|
294
|
+
throw new SnitcherIp2CompanyResolutionPendingError();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let enrichment: SnitcherIp2CompanyResult;
|
|
298
|
+
try {
|
|
299
|
+
enrichment = await findSnitcherIp2Company(ip);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
await client.mutation(internal.analyticsIp2cCache.release, {
|
|
302
|
+
orgId: input.orgId,
|
|
303
|
+
ipHash: cacheKey,
|
|
304
|
+
leaseId,
|
|
305
|
+
});
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const cached = cacheableResult(enrichment);
|
|
310
|
+
if (cached) {
|
|
311
|
+
const settled = await client.mutation(internal.analyticsIp2cCache.resolve, {
|
|
312
|
+
orgId: input.orgId,
|
|
313
|
+
ipHash: cacheKey,
|
|
314
|
+
leaseId,
|
|
315
|
+
result: cached,
|
|
316
|
+
now: Date.now(),
|
|
317
|
+
expiresAt: Date.now() + IP2COMPANY_CACHE_TTL_MS,
|
|
318
|
+
});
|
|
319
|
+
if (!settled.stored) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
'Deepline Analytics IP2Company cache lease was lost before its result could be stored.',
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
await client.mutation(internal.analyticsIp2cCache.release, {
|
|
326
|
+
orgId: input.orgId,
|
|
327
|
+
ipHash: cacheKey,
|
|
328
|
+
leaseId,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return { ...root, _deepline_ip2company: enrichment };
|
|
332
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { buildProviderWebhookCallbackUrl } from '@deepline/monitors/provider-webhook-monitor-runtime';
|
|
2
|
+
import { DEEPLINE_ANALYTICS_BROWSER_OBJECT } from '@/lib/analytics/public';
|
|
3
|
+
|
|
4
|
+
const SNITCHER_RADAR_API_URL = 'https://api.snitcher.com/radar/operator/v1';
|
|
5
|
+
const SNITCHER_INTERNAL_IDENTIFIER_PREFIX = 'dlr1_';
|
|
6
|
+
|
|
7
|
+
export type SnitcherTrackingControls = {
|
|
8
|
+
formTracking: boolean;
|
|
9
|
+
clickTracking: boolean;
|
|
10
|
+
customEvents: boolean;
|
|
11
|
+
waitForConsent: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_SNITCHER_TRACKING_CONTROLS: SnitcherTrackingControls = {
|
|
15
|
+
formTracking: false,
|
|
16
|
+
clickTracking: false,
|
|
17
|
+
customEvents: true,
|
|
18
|
+
waitForConsent: true,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function requireSecret(
|
|
22
|
+
name: 'SNITCHER_RADAR_API_KEY' | 'SNITCHER_RADAR_WEBHOOK_SECRET',
|
|
23
|
+
) {
|
|
24
|
+
const value = process.env[name]?.trim();
|
|
25
|
+
if (!value)
|
|
26
|
+
throw new Error(
|
|
27
|
+
`${name} must be configured before Snitcher Radar can be used.`,
|
|
28
|
+
);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getSnitcherRadarWebhookSecret() {
|
|
33
|
+
return requireSecret('SNITCHER_RADAR_WEBHOOK_SECRET');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildSnitcherInternalIdentifier(input: {
|
|
37
|
+
orgId: string;
|
|
38
|
+
callbackToken: string;
|
|
39
|
+
}) {
|
|
40
|
+
return `${SNITCHER_INTERNAL_IDENTIFIER_PREFIX}${Buffer.from(
|
|
41
|
+
JSON.stringify({ orgId: input.orgId, token: input.callbackToken }),
|
|
42
|
+
).toString('base64url')}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseSnitcherInternalIdentifier(value: unknown) {
|
|
46
|
+
if (
|
|
47
|
+
typeof value !== 'string' ||
|
|
48
|
+
!value.startsWith(SNITCHER_INTERNAL_IDENTIFIER_PREFIX)
|
|
49
|
+
) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(
|
|
54
|
+
Buffer.from(
|
|
55
|
+
value.slice(SNITCHER_INTERNAL_IDENTIFIER_PREFIX.length),
|
|
56
|
+
'base64url',
|
|
57
|
+
).toString('utf8'),
|
|
58
|
+
) as { orgId?: unknown; token?: unknown };
|
|
59
|
+
if (
|
|
60
|
+
typeof parsed.orgId !== 'string' ||
|
|
61
|
+
!parsed.orgId.trim() ||
|
|
62
|
+
typeof parsed.token !== 'string' ||
|
|
63
|
+
!parsed.token.trim()
|
|
64
|
+
) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return { orgId: parsed.orgId, token: parsed.token };
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function snitcherRadarWebhookEndpoint(callbackBaseUrl?: string | null) {
|
|
74
|
+
return buildProviderWebhookCallbackUrl({
|
|
75
|
+
provider: 'snitcher',
|
|
76
|
+
orgId: 'registry-derived',
|
|
77
|
+
token: 'registry-derived',
|
|
78
|
+
callbackBaseUrl,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function buildSnitcherRadarLoader(input: {
|
|
83
|
+
trackingScriptId: string;
|
|
84
|
+
controls: SnitcherTrackingControls;
|
|
85
|
+
}) {
|
|
86
|
+
const settings = {
|
|
87
|
+
cdn: 'cdn.snitcher.com',
|
|
88
|
+
apiEndpoint: 'radar.snitcher.com',
|
|
89
|
+
profileId: input.trackingScriptId,
|
|
90
|
+
namespace: DEEPLINE_ANALYTICS_BROWSER_OBJECT,
|
|
91
|
+
waitForConsent: input.controls.waitForConsent,
|
|
92
|
+
features: {
|
|
93
|
+
formTracking: input.controls.formTracking,
|
|
94
|
+
clickTracking: input.controls.clickTracking,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
return `<script>\n!function(e){"use strict";var a=e&&e.namespace;if(a&&e.profileId&&e.cdn){var r=window[a];if(r&&Array.isArray(r)||(r=window[a]=[]),!r.initialized&&!r._loaded)if(r._loaded)console&&console.warn("[Radar] Duplicate initialization attempted");else{r._loaded=!0;["track","page","identify","group","alias","ready","debug","on","off","once","trackClick","trackSubmit","trackLink","trackForm","pageview","screen","reset","register","setAnonymousId","addSourceMiddleware","addIntegrationMiddleware","addDestinationMiddleware","giveCookieConsent"].forEach((function(e){var i;r[e]=(i=e,function(){var e=window[a];if(e.initialized)return e[i].apply(e,arguments);var r=[].slice.call(arguments);return r.unshift(i),e.push(r),e})})),r.bootstrap=function(){var a=document.createElement("script");a.async=!0,a.type="text/javascript",a.id="__radar__",a.dataset.settings=JSON.stringify(e),a.src="https://"+e.cdn+"/releases/latest/radar.min.js";var r=document.scripts[0];r.parentNode.insertBefore(a,r)},r.bootstrap()}}else"undefined"!=typeof console&&console.error("[Radar] Configuration incomplete")}(${JSON.stringify(settings)});\n</script>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type SnitcherApiResponse<T> = {
|
|
101
|
+
success?: unknown;
|
|
102
|
+
data?: T;
|
|
103
|
+
message?: unknown;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
type SnitcherTrackingScript = {
|
|
107
|
+
tracking_script_id?: unknown;
|
|
108
|
+
trackingScriptId?: unknown;
|
|
109
|
+
internal_identifier?: unknown;
|
|
110
|
+
active?: unknown;
|
|
111
|
+
domains?: unknown;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
function trackingScriptsFromResponse(value: unknown): SnitcherTrackingScript[] {
|
|
115
|
+
if (Array.isArray(value)) return value as SnitcherTrackingScript[];
|
|
116
|
+
if (!value || typeof value !== 'object') return [];
|
|
117
|
+
const record = value as Record<string, unknown>;
|
|
118
|
+
for (const nested of [
|
|
119
|
+
record.data,
|
|
120
|
+
record.tracking_script,
|
|
121
|
+
record.trackingScript,
|
|
122
|
+
]) {
|
|
123
|
+
if (nested !== undefined) {
|
|
124
|
+
return trackingScriptsFromResponse(nested);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return [record as SnitcherTrackingScript];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readTrackingScriptId(script: SnitcherTrackingScript | null) {
|
|
131
|
+
if (!script) return null;
|
|
132
|
+
const value = script.tracking_script_id ?? script.trackingScriptId;
|
|
133
|
+
if (typeof value === 'string' && value.trim()) return value;
|
|
134
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function snitcherRequest<T>(path: string, init: RequestInit) {
|
|
139
|
+
const response = await fetch(`${SNITCHER_RADAR_API_URL}${path}`, {
|
|
140
|
+
...init,
|
|
141
|
+
headers: {
|
|
142
|
+
Authorization: `Bearer ${requireSecret('SNITCHER_RADAR_API_KEY')}`,
|
|
143
|
+
'Content-Type': 'application/json',
|
|
144
|
+
...(init.headers ?? {}),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
const body = (await response
|
|
148
|
+
.json()
|
|
149
|
+
.catch(() => null)) as SnitcherApiResponse<T> | null;
|
|
150
|
+
if (!response.ok || !body?.success) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`Snitcher Radar API ${init.method ?? 'GET'} ${path} failed (${response.status}): ${
|
|
153
|
+
typeof body?.message === 'string' ? body.message : 'unexpected response'
|
|
154
|
+
}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return body.data;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function createSnitcherTrackingScript(input: {
|
|
161
|
+
internalIdentifier: string;
|
|
162
|
+
description: string;
|
|
163
|
+
domains: string[];
|
|
164
|
+
}) {
|
|
165
|
+
const data = await snitcherRequest<SnitcherTrackingScript[]>(
|
|
166
|
+
'/tracking-scripts',
|
|
167
|
+
{
|
|
168
|
+
method: 'POST',
|
|
169
|
+
body: JSON.stringify({
|
|
170
|
+
internal_identifier: input.internalIdentifier,
|
|
171
|
+
description: input.description,
|
|
172
|
+
domains: input.domains,
|
|
173
|
+
}),
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
const script = trackingScriptsFromResponse(data)[0] ?? null;
|
|
177
|
+
const trackingScriptId = readTrackingScriptId(script);
|
|
178
|
+
if (!trackingScriptId) {
|
|
179
|
+
throw new Error('Snitcher Radar did not return a tracking_script_id.');
|
|
180
|
+
}
|
|
181
|
+
return { trackingScriptId };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Radar's tracking-script creation is externally durable, while our metadata
|
|
186
|
+
* write is local. A timeout or a later setup failure can leave an upstream
|
|
187
|
+
* script without its local ID. Recover it by the opaque, per-monitor
|
|
188
|
+
* identifier instead of attempting another creation.
|
|
189
|
+
*/
|
|
190
|
+
export async function findSnitcherTrackingScriptByInternalIdentifier(
|
|
191
|
+
internalIdentifier: string,
|
|
192
|
+
) {
|
|
193
|
+
const query = new URLSearchParams({
|
|
194
|
+
internal_identifier: internalIdentifier,
|
|
195
|
+
size: '1',
|
|
196
|
+
});
|
|
197
|
+
const data = await snitcherRequest<SnitcherTrackingScript[]>(
|
|
198
|
+
`/tracking-scripts?${query.toString()}`,
|
|
199
|
+
{ method: 'GET' },
|
|
200
|
+
);
|
|
201
|
+
const script = trackingScriptsFromResponse(data).find(
|
|
202
|
+
(candidate) => candidate.internal_identifier === internalIdentifier,
|
|
203
|
+
);
|
|
204
|
+
const trackingScriptId = readTrackingScriptId(script ?? null);
|
|
205
|
+
return trackingScriptId ? { trackingScriptId } : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function configureSnitcherRadarWebhook(
|
|
209
|
+
callbackBaseUrl?: string | null,
|
|
210
|
+
) {
|
|
211
|
+
return snitcherRequest('/webhooks', {
|
|
212
|
+
method: 'PATCH',
|
|
213
|
+
body: JSON.stringify({
|
|
214
|
+
delivery: {
|
|
215
|
+
webhook_url: snitcherRadarWebhookEndpoint(callbackBaseUrl),
|
|
216
|
+
webhook_secret: getSnitcherRadarWebhookSecret(),
|
|
217
|
+
},
|
|
218
|
+
event_aggregation: 'per_session',
|
|
219
|
+
}),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export type MonitorStarterTemplate = {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
description: string;
|
|
5
|
+
prompt: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const MONITOR_STARTER_TEMPLATES: MonitorStarterTemplate[] = [
|
|
9
|
+
{
|
|
10
|
+
id: 'job-post-keywords',
|
|
11
|
+
title: 'Job post keyword monitor',
|
|
12
|
+
description:
|
|
13
|
+
'Watch target accounts for hiring signals that imply new budget, new projects, or urgency.',
|
|
14
|
+
prompt: `Create a Deepline definePlay() workflow for a job post keyword monitor.
|
|
15
|
+
|
|
16
|
+
Input: a list of target accounts, job title or description keywords, optional regions, and optional owner routing.
|
|
17
|
+
Behavior: check each account for recent open roles that match the keywords, dedupe against prior matches, enrich each match with company context and job URL, and rank the signal by relevance.
|
|
18
|
+
Output: one concise rep-ready message per owner with account name, matched role, why it matters, job URL, and recommended next step. Include Slack Block Kit formatting and an email fallback.
|
|
19
|
+
Verification: add a small dry-run input, run deepline plays check, run one sample execution against a scratch output destination, and report exactly what changed and what was not published.`,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 'champion-job-change',
|
|
23
|
+
title: 'Champion job change monitor',
|
|
24
|
+
description:
|
|
25
|
+
'Find when a known buyer, user, or champion joins a new account and route a warm-path action.',
|
|
26
|
+
prompt: `Create a Deepline definePlay() workflow for a champion job change monitor.
|
|
27
|
+
|
|
28
|
+
Input: known champions or contacts from CRM, their prior companies, owner routing rules, and optional target-account filters.
|
|
29
|
+
Behavior: detect likely role or company changes, validate the new company and title, suppress weak matches, and connect the signal back to the owning rep or CSM.
|
|
30
|
+
Output: one routed message per owner with champion name, old company, new company, new title, relationship context, source URL, and suggested outreach angle. Include Slack Block Kit formatting and an email fallback.
|
|
31
|
+
Verification: add a small dry-run input, run deepline plays check, run one sample execution against a scratch output destination, and report exactly what changed and what was not published.`,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'website-page-change',
|
|
35
|
+
title: 'Website page change monitor',
|
|
36
|
+
description:
|
|
37
|
+
'Track pricing, docs, security, integrations, or product pages for GTM-relevant changes.',
|
|
38
|
+
prompt: `Create a Deepline definePlay() workflow for a website page change monitor.
|
|
39
|
+
|
|
40
|
+
Input: a list of account URLs, page categories to watch, a minimum change threshold, and owner routing rules.
|
|
41
|
+
Behavior: fetch current page content, compare it with the prior snapshot, classify meaningful GTM changes, ignore noisy cosmetic edits, and store the new snapshot only after a successful run.
|
|
42
|
+
Output: one routed message per owner with account name, changed URL, before/after summary, why the change matters, confidence, and suggested next step. Include Slack Block Kit formatting and an email fallback.
|
|
43
|
+
Verification: add a small dry-run input, run deepline plays check, run one sample execution against a scratch output destination, and report exactly what changed and what was not published.`,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'website-visitor-intent',
|
|
47
|
+
title: 'Website visitor intent monitor',
|
|
48
|
+
description:
|
|
49
|
+
'Turn repeat high-value website visits into account-specific follow-up for the right owner.',
|
|
50
|
+
prompt: `Create a Deepline definePlay() workflow for a website visitor intent monitor.
|
|
51
|
+
|
|
52
|
+
Input: visitor or account intent events, high-intent page definitions, account matching rules, suppression rules, and owner routing.
|
|
53
|
+
Behavior: group events by account, score intent from page depth and recency, suppress existing active opportunities when configured, and explain why the account is worth actioning.
|
|
54
|
+
Output: one routed message per owner with account name, intent score, pages visited, timing, CRM context, and recommended next step. Include Slack Block Kit formatting and an email fallback.
|
|
55
|
+
Verification: add a small dry-run input, run deepline plays check, run one sample execution against a scratch output destination, and report exactly what changed and what was not published.`,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'tech-stack-change',
|
|
59
|
+
title: 'Tech stack change monitor',
|
|
60
|
+
description:
|
|
61
|
+
'Detect install, removal, or competitor stack changes that create a concrete sales motion.',
|
|
62
|
+
prompt: `Create a Deepline definePlay() workflow for a tech stack change monitor.
|
|
63
|
+
|
|
64
|
+
Input: target accounts, technologies to watch, competitor or complementary-product tags, owner routing, and optional exclusion rules.
|
|
65
|
+
Behavior: detect meaningful install or removal events, confirm with available evidence, classify the motion as displacement, expansion, integration, or renewal risk, and dedupe prior alerts.
|
|
66
|
+
Output: one routed message per owner with account name, detected technology change, evidence, GTM interpretation, and suggested next step. Include Slack Block Kit formatting and an email fallback.
|
|
67
|
+
Verification: add a small dry-run input, run deepline plays check, run one sample execution against a scratch output destination, and report exactly what changed and what was not published.`,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'social-community-mention',
|
|
71
|
+
title: 'Social and community mention monitor',
|
|
72
|
+
description:
|
|
73
|
+
'Catch relevant public mentions, buying questions, complaints, or competitor comparisons.',
|
|
74
|
+
prompt: `Create a Deepline definePlay() workflow for a social and community mention monitor.
|
|
75
|
+
|
|
76
|
+
Input: keywords, target accounts or personas, source allowlist, sentiment or category filters, and owner routing rules.
|
|
77
|
+
Behavior: find recent relevant mentions, classify each mention as buying intent, competitor comparison, complaint, hiring, integration request, or general awareness, and suppress duplicates or low-quality chatter.
|
|
78
|
+
Output: one routed message per owner with mention summary, source link, account/person context, signal category, confidence, and suggested next step. Include Slack Block Kit formatting and an email fallback.
|
|
79
|
+
Verification: add a small dry-run input, run deepline plays check, run one sample execution against a scratch output destination, and report exactly what changed and what was not published.`,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'competitor-churn',
|
|
83
|
+
title: 'Competitor complaint monitor',
|
|
84
|
+
description:
|
|
85
|
+
'Surface competitor dissatisfaction or sunset risk while the account is likely open to change.',
|
|
86
|
+
prompt: `Create a Deepline definePlay() workflow for a competitor complaint monitor.
|
|
87
|
+
|
|
88
|
+
Input: competitor names, complaint or migration keywords, source allowlist, target-account matching rules, and owner routing.
|
|
89
|
+
Behavior: find recent competitor dissatisfaction or sunset-risk mentions, match them to companies where possible, classify the pain, and suppress vague or stale mentions.
|
|
90
|
+
Output: one routed message per owner with account or person, competitor, pain summary, source URL, suggested displacement angle, and confidence. Include Slack Block Kit formatting and an email fallback.
|
|
91
|
+
Verification: add a small dry-run input, run deepline plays check, run one sample execution against a scratch output destination, and report exactly what changed and what was not published.`,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 'customer-usage-renewal',
|
|
95
|
+
title: 'Customer usage and renewal monitor',
|
|
96
|
+
description:
|
|
97
|
+
'Route expansion, risk, and renewal signals from product behavior to the account team.',
|
|
98
|
+
prompt: `Create a Deepline definePlay() workflow for a customer usage and renewal monitor.
|
|
99
|
+
|
|
100
|
+
Input: product usage events, entitlement limits, renewal dates, account health fields, and owner routing.
|
|
101
|
+
Behavior: detect expansion signals, underuse risk, overage pressure, renewal timing, or feature adoption changes; explain the evidence; and suppress noisy repeat alerts.
|
|
102
|
+
Output: one routed message per owner with account name, signal type, usage evidence, renewal context, business impact, and suggested next step. Include Slack Block Kit formatting and an email fallback.
|
|
103
|
+
Verification: add a small dry-run input, run deepline plays check, run one sample execution against a scratch output destination, and report exactly what changed and what was not published.`,
|
|
104
|
+
},
|
|
105
|
+
];
|