fullstackgtm 0.11.0 → 0.11.1
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 +30 -0
- package/dist/connectors/hubspot.js +90 -11
- package/dist/connectors/salesforce.js +57 -9
- package/dist/merge.d.ts +1 -0
- package/dist/merge.js +1 -1
- package/dist/rules.js +2 -1
- package/docs/crm-health-lifecycle.md +135 -0
- package/llms.txt +1 -0
- package/package.json +1 -1
- package/src/connectors/hubspot.ts +90 -11
- package/src/connectors/salesforce.ts +59 -9
- package/src/merge.ts +1 -1
- package/src/rules.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,36 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
5
5
|
and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
|
|
7
7
|
|
|
8
|
+
## [0.11.1] — 2026-06-11
|
|
9
|
+
|
|
10
|
+
Write-path integrity: fixes our own dupe faucets, found auditing the apply
|
|
11
|
+
path for the new [CRM-health lifecycle](./docs/crm-health-lifecycle.md) doc.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **docs/crm-health-lifecycle.md**: the full CRUD lifecycle for keeping a
|
|
16
|
+
CRM healthy — Prevent → Detect → Remediate → Verify/Attribute — grounded
|
|
17
|
+
in verified platform behavior (HubSpot/Salesforce dedupe and merge
|
|
18
|
+
support) and the build order toward governed merges and a resolve gate.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **`create:<Name>` is now resolve-first**: it links to an unambiguous
|
|
23
|
+
existing company/account instead of creating, refuses on ambiguity, and
|
|
24
|
+
creates only on a confirmed miss — and never creates the same name twice
|
|
25
|
+
within one apply run (HubSpot search is eventually consistent, so the
|
|
26
|
+
same-run record is authoritative).
|
|
27
|
+
- **HubSpot compare-and-set on `link_record` is no longer blind**:
|
|
28
|
+
`readField("deal"|"contact", id, "accountId")` reads the actual company
|
|
29
|
+
association (it is not a property), so replaying an applied link returns
|
|
30
|
+
`conflict` instead of silently re-creating companies.
|
|
31
|
+
- **`create_task` is idempotent**: the operation id is stamped into the
|
|
32
|
+
task body as a token and pre-checked (fail-open), so replayed plans no
|
|
33
|
+
longer duplicate merge-review tasks on either provider.
|
|
34
|
+
- **`duplicate-account-domain` normalizes domains** the same way merge
|
|
35
|
+
does — `www.acme.com`, `https://acme.com/about`, and `acme.com` now
|
|
36
|
+
group as duplicates.
|
|
37
|
+
|
|
8
38
|
## [0.11.0] — 2026-06-11
|
|
9
39
|
|
|
10
40
|
Canonicalizes the paths discovered dogfooding against a real portal: the
|
|
@@ -22,6 +22,10 @@ export function createHubspotConnector(options) {
|
|
|
22
22
|
const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
|
|
23
23
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
24
24
|
const mappings = options.fieldMappings;
|
|
25
|
+
// create:<Name> dedup within one connector lifetime (one apply run): the
|
|
26
|
+
// search API is eventually consistent, so a just-created company is
|
|
27
|
+
// invisible to search — this map is the authoritative same-run record.
|
|
28
|
+
const createdCompaniesByName = new Map();
|
|
25
29
|
async function request(path, init = {}) {
|
|
26
30
|
const token = await options.getAccessToken();
|
|
27
31
|
let response;
|
|
@@ -286,21 +290,48 @@ export function createHubspotConnector(options) {
|
|
|
286
290
|
if (!companyId) {
|
|
287
291
|
return { operationId: operation.id, status: "skipped", detail: "link_record needs a target company id." };
|
|
288
292
|
}
|
|
289
|
-
// `create:<Name>`
|
|
290
|
-
//
|
|
291
|
-
// the
|
|
293
|
+
// `create:<Name>` is resolve-first: link to an existing company when one
|
|
294
|
+
// unambiguously matches, refuse on ambiguity, create only on a confirmed
|
|
295
|
+
// miss — and never create the same name twice within one apply run
|
|
296
|
+
// (HubSpot's search API is eventually consistent, so a just-created
|
|
297
|
+
// record is invisible to search for several seconds).
|
|
292
298
|
let createdCompanyName = null;
|
|
299
|
+
let resolvedExisting = false;
|
|
293
300
|
if (companyId.startsWith("create:")) {
|
|
294
301
|
const name = companyId.slice("create:".length).trim();
|
|
295
302
|
if (!name) {
|
|
296
303
|
return { operationId: operation.id, status: "skipped", detail: "create: needs a company name (create:<Name>)." };
|
|
297
304
|
}
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
305
|
+
const nameKey = name.toLowerCase();
|
|
306
|
+
const alreadyCreated = createdCompaniesByName.get(nameKey);
|
|
307
|
+
if (alreadyCreated) {
|
|
308
|
+
companyId = alreadyCreated;
|
|
309
|
+
resolvedExisting = true;
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
const matches = await searchCompaniesByName(name);
|
|
313
|
+
if (matches.length > 1) {
|
|
314
|
+
return {
|
|
315
|
+
operationId: operation.id,
|
|
316
|
+
status: "skipped",
|
|
317
|
+
detail: `create:${name} is ambiguous — ${matches.length} companies already named "${name}" (ids ${matches.join(", ")}). Link an explicit company id instead.`,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (matches.length === 1) {
|
|
321
|
+
companyId = matches[0];
|
|
322
|
+
resolvedExisting = true;
|
|
323
|
+
createdCompaniesByName.set(nameKey, companyId);
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
const created = await request(`/crm/v3/objects/companies`, {
|
|
327
|
+
method: "POST",
|
|
328
|
+
body: JSON.stringify({ properties: { name } }),
|
|
329
|
+
});
|
|
330
|
+
companyId = String(created.id);
|
|
331
|
+
createdCompanyName = name;
|
|
332
|
+
createdCompaniesByName.set(nameKey, companyId);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
304
335
|
}
|
|
305
336
|
await request(`/crm/v4/objects/${fromPath}/${encodeURIComponent(operation.objectId)}/associations/default/companies/${encodeURIComponent(companyId)}`, { method: "PUT" });
|
|
306
337
|
return {
|
|
@@ -308,10 +339,24 @@ export function createHubspotConnector(options) {
|
|
|
308
339
|
status: "applied",
|
|
309
340
|
detail: createdCompanyName
|
|
310
341
|
? `Created company "${createdCompanyName}" (${companyId}) and linked ${fromPath}/${operation.objectId} to it.`
|
|
311
|
-
:
|
|
342
|
+
: resolvedExisting
|
|
343
|
+
? `Linked ${fromPath}/${operation.objectId} to existing company ${companyId} (resolved by name, nothing created).`
|
|
344
|
+
: `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
|
|
312
345
|
providerData: { companyId, ...(createdCompanyName ? { createdCompany: true } : {}) },
|
|
313
346
|
};
|
|
314
347
|
}
|
|
348
|
+
/** Exact-name company lookup for resolve-first creates. Returns matching ids (max 3 fetched). */
|
|
349
|
+
async function searchCompaniesByName(name) {
|
|
350
|
+
const data = await request(`/crm/v3/objects/companies/search`, {
|
|
351
|
+
method: "POST",
|
|
352
|
+
body: JSON.stringify({
|
|
353
|
+
filterGroups: [{ filters: [{ propertyName: "name", operator: "EQ", value: name }] }],
|
|
354
|
+
properties: ["name"],
|
|
355
|
+
limit: 3,
|
|
356
|
+
}),
|
|
357
|
+
});
|
|
358
|
+
return (data?.results ?? []).map((row) => String(row.id));
|
|
359
|
+
}
|
|
315
360
|
async function createTask(operation) {
|
|
316
361
|
const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
|
|
317
362
|
if (associationTypeId === undefined) {
|
|
@@ -322,7 +367,34 @@ export function createHubspotConnector(options) {
|
|
|
322
367
|
};
|
|
323
368
|
}
|
|
324
369
|
const subject = operation.field ? humanizeField(operation.field) : "Follow up";
|
|
325
|
-
|
|
370
|
+
// The operation id doubles as an idempotency token: it is stamped into
|
|
371
|
+
// the task body and pre-checked so a replayed plan does not create the
|
|
372
|
+
// same task twice. Fail-open — a search hiccup must not block the apply.
|
|
373
|
+
const token = `fsgtm ${operation.id.replace(/^op_/, "")}`;
|
|
374
|
+
try {
|
|
375
|
+
const existing = await request(`/crm/v3/objects/tasks/search`, {
|
|
376
|
+
method: "POST",
|
|
377
|
+
body: JSON.stringify({
|
|
378
|
+
filterGroups: [
|
|
379
|
+
{ filters: [{ propertyName: "hs_task_body", operator: "CONTAINS_TOKEN", value: token.split(" ")[1] }] },
|
|
380
|
+
],
|
|
381
|
+
limit: 1,
|
|
382
|
+
}),
|
|
383
|
+
});
|
|
384
|
+
const hit = (existing?.results ?? [])[0];
|
|
385
|
+
if (hit?.id) {
|
|
386
|
+
return {
|
|
387
|
+
operationId: operation.id,
|
|
388
|
+
status: "skipped",
|
|
389
|
+
detail: `Task for this operation already exists (task ${hit.id}); not creating a duplicate.`,
|
|
390
|
+
providerData: { id: hit.id, existing: true },
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
// fall through to create
|
|
396
|
+
}
|
|
397
|
+
const body = `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`;
|
|
326
398
|
const response = await request(`/crm/v3/objects/tasks`, {
|
|
327
399
|
method: "POST",
|
|
328
400
|
body: JSON.stringify({
|
|
@@ -405,6 +477,13 @@ export function createHubspotConnector(options) {
|
|
|
405
477
|
if (!objectPath || !mappingType) {
|
|
406
478
|
throw new Error(`Field reads are only supported for accounts, contacts, and deals.`);
|
|
407
479
|
}
|
|
480
|
+
// accountId is an association in HubSpot, not a property — without this
|
|
481
|
+
// branch the compare-and-set on link_record reads null and passes blind.
|
|
482
|
+
if (field === "accountId" && (objectType === "deal" || objectType === "contact")) {
|
|
483
|
+
const data = await request(`/crm/v4/objects/${objectPath}/${encodeURIComponent(objectId)}/associations/companies?limit=1`);
|
|
484
|
+
const first = (data?.results ?? [])[0];
|
|
485
|
+
return first?.toObjectId !== undefined ? String(first.toObjectId) : null;
|
|
486
|
+
}
|
|
408
487
|
const defaults = HUBSPOT_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
|
|
409
488
|
const property = mappedField(mappings, mappingType, field, defaults[field] ?? field);
|
|
410
489
|
const data = await request(`/crm/v3/objects/${objectPath}/${encodeURIComponent(objectId)}?properties=${encodeURIComponent(property)}`);
|
|
@@ -23,6 +23,8 @@ export function createSalesforceConnector(options) {
|
|
|
23
23
|
const apiVersion = options.apiVersion ?? DEFAULT_API_VERSION;
|
|
24
24
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
25
25
|
const mappings = options.fieldMappings;
|
|
26
|
+
// create:<Name> dedup within one connector lifetime (one apply run).
|
|
27
|
+
const createdAccountsByName = new Map();
|
|
26
28
|
async function request(path, init = {}) {
|
|
27
29
|
const connection = await options.getConnection();
|
|
28
30
|
const url = path.startsWith("http")
|
|
@@ -228,11 +230,28 @@ export function createSalesforceConnector(options) {
|
|
|
228
230
|
detail: "Tasks can be attached to accounts, contacts, and deals.",
|
|
229
231
|
};
|
|
230
232
|
}
|
|
233
|
+
// Idempotency: the operation id is stamped into the Description and
|
|
234
|
+
// pre-checked, so replaying a plan does not duplicate tasks. Fail-open.
|
|
235
|
+
const token = `fsgtm:${operation.id}`;
|
|
236
|
+
try {
|
|
237
|
+
const existing = await query(`SELECT Id FROM Task WHERE Description LIKE '%${token.replace(/'/g, "\\'")}%' LIMIT 1`);
|
|
238
|
+
if (existing.length > 0) {
|
|
239
|
+
return {
|
|
240
|
+
operationId: operation.id,
|
|
241
|
+
status: "skipped",
|
|
242
|
+
detail: `Task for this operation already exists (task ${existing[0].Id}); not creating a duplicate.`,
|
|
243
|
+
providerData: { id: String(existing[0].Id), existing: true },
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// fall through to create
|
|
249
|
+
}
|
|
231
250
|
const response = await request(`/services/data/${apiVersion}/sobjects/Task`, {
|
|
232
251
|
method: "POST",
|
|
233
252
|
body: JSON.stringify({
|
|
234
253
|
Subject: operation.field ? humanizeField(operation.field) : "Follow up",
|
|
235
|
-
Description: String(operation.afterValue ?? operation.reason ?? "")
|
|
254
|
+
Description: `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`,
|
|
236
255
|
Status: "Not Started",
|
|
237
256
|
Priority: "Normal",
|
|
238
257
|
...reference,
|
|
@@ -269,21 +288,50 @@ export function createSalesforceConnector(options) {
|
|
|
269
288
|
// link_record on a deal is just setting AccountId in Salesforce.
|
|
270
289
|
return await setField(operation);
|
|
271
290
|
case "link_record": {
|
|
272
|
-
// `create:<Name>`
|
|
273
|
-
//
|
|
291
|
+
// `create:<Name>` is resolve-first: link to an unambiguous existing
|
|
292
|
+
// Account, refuse on ambiguity, create only on a confirmed miss —
|
|
293
|
+
// and never create the same name twice within one apply run.
|
|
274
294
|
const value = String(operation.afterValue ?? "");
|
|
275
295
|
if (value.startsWith("create:")) {
|
|
276
296
|
const name = value.slice("create:".length).trim();
|
|
277
297
|
if (!name) {
|
|
278
298
|
return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
|
|
279
299
|
}
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
300
|
+
const nameKey = name.toLowerCase();
|
|
301
|
+
let accountId = createdAccountsByName.get(nameKey);
|
|
302
|
+
let createdNew = false;
|
|
303
|
+
if (!accountId) {
|
|
304
|
+
const soqlName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
305
|
+
const matches = await query(`SELECT Id FROM Account WHERE Name = '${soqlName}' LIMIT 3`);
|
|
306
|
+
if (matches.length > 1) {
|
|
307
|
+
return {
|
|
308
|
+
operationId: operation.id,
|
|
309
|
+
status: "skipped",
|
|
310
|
+
detail: `create:${name} is ambiguous — ${matches.length} accounts already named "${name}". Link an explicit account id instead.`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (matches.length === 1) {
|
|
314
|
+
accountId = String(matches[0].Id);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
|
|
318
|
+
method: "POST",
|
|
319
|
+
body: JSON.stringify({ Name: name }),
|
|
320
|
+
});
|
|
321
|
+
accountId = String(created.id);
|
|
322
|
+
createdNew = true;
|
|
323
|
+
}
|
|
324
|
+
createdAccountsByName.set(nameKey, accountId);
|
|
325
|
+
}
|
|
326
|
+
const result = await setField({ ...operation, operation: "set_field", afterValue: accountId });
|
|
285
327
|
return result.status === "applied"
|
|
286
|
-
? {
|
|
328
|
+
? {
|
|
329
|
+
...result,
|
|
330
|
+
detail: createdNew
|
|
331
|
+
? `Created account "${name}" (${accountId}) and linked ${operation.objectType}/${operation.objectId} to it.`
|
|
332
|
+
: `Linked ${operation.objectType}/${operation.objectId} to existing account ${accountId} (resolved by name, nothing created).`,
|
|
333
|
+
providerData: { accountId, ...(createdNew ? { createdAccount: true } : {}) },
|
|
334
|
+
}
|
|
287
335
|
: result;
|
|
288
336
|
}
|
|
289
337
|
return await setField({ ...operation, operation: "set_field" });
|
package/dist/merge.d.ts
CHANGED
|
@@ -42,6 +42,7 @@ export type MergeReport = {
|
|
|
42
42
|
conflicts: MergeConflict[];
|
|
43
43
|
suggestions: MergeSuggestion[];
|
|
44
44
|
};
|
|
45
|
+
export declare function normalizeDomain(domain?: string): string | undefined;
|
|
45
46
|
export declare function mergeSnapshots(snapshots: CanonicalGtmSnapshot[]): {
|
|
46
47
|
snapshot: CanonicalGtmSnapshot;
|
|
47
48
|
report: MergeReport;
|
package/dist/merge.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const CONFLICT_IGNORED_FIELDS = new Set([
|
|
2
2
|
"id", "provider", "crmId", "identities", "raw", "lastSyncAt", "lastActivityAt", "ownerId", "accountId",
|
|
3
3
|
]);
|
|
4
|
-
function normalizeDomain(domain) {
|
|
4
|
+
export function normalizeDomain(domain) {
|
|
5
5
|
if (!domain)
|
|
6
6
|
return undefined;
|
|
7
7
|
return domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/.*$/, "") || undefined;
|
package/dist/rules.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeDomain } from "./merge.js";
|
|
1
2
|
/**
|
|
2
3
|
* Placeholder used as `afterValue` when the right value is a human decision
|
|
3
4
|
* (e.g. which owner to assign). Apply orchestration refuses to write these
|
|
@@ -316,7 +317,7 @@ export const duplicateAccountDomainRule = {
|
|
|
316
317
|
evaluate: ({ snapshot }) => {
|
|
317
318
|
const findings = [];
|
|
318
319
|
const operations = [];
|
|
319
|
-
for (const [domain, accounts] of duplicateGroups(snapshot.accounts, (account) => account.domain)) {
|
|
320
|
+
for (const [domain, accounts] of duplicateGroups(snapshot.accounts, (account) => normalizeDomain(account.domain))) {
|
|
320
321
|
const anchor = accounts[0];
|
|
321
322
|
findings.push({
|
|
322
323
|
id: auditFindingId("duplicate-account-domain", anchor.id),
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# The CRM-health CRUD lifecycle: no new dupes
|
|
2
|
+
|
|
3
|
+
How fullstackgtm keeps a CRM healthy over time — not just episodically
|
|
4
|
+
audited. Grounded in dogfooding against a real portal (an outreach sync
|
|
5
|
+
tripled five open deals; our own `create:` path nearly minted a duplicate
|
|
6
|
+
company) and in what the platforms actually support today (verified 2026-06).
|
|
7
|
+
|
|
8
|
+
## The model: Prevent → Detect → Remediate → Verify/Attribute
|
|
9
|
+
|
|
10
|
+
Every mature dedupe stack (RingLead/ZoomInfo Ops, Insycle, Openprise,
|
|
11
|
+
Dedupely) converged on three layers, because each one leaks:
|
|
12
|
+
|
|
13
|
+
1. **Prevent** at write time — unique keys, upserts, point-of-entry gates.
|
|
14
|
+
Leaks because: HubSpot does not dedupe companies created via API at all,
|
|
15
|
+
deals have no native dedupe on any platform, and Salesforce duplicate
|
|
16
|
+
rules skip standard lead conversion.
|
|
17
|
+
2. **Detect** continuously — scheduled scans, because prevention leaked.
|
|
18
|
+
3. **Remediate** via survivor-rule-driven merge — irreversible on both
|
|
19
|
+
platforms, which is why preview/dry-run is table stakes.
|
|
20
|
+
|
|
21
|
+
fullstackgtm adds the layer the industry mostly lacks: **Verify/Attribute** —
|
|
22
|
+
typed, approved operations with compare-and-set, readback, drift diffs, and
|
|
23
|
+
record-source provenance that names *which writer* created the mess, so you
|
|
24
|
+
fix the faucet instead of mopping the puddle.
|
|
25
|
+
|
|
26
|
+
## C — Create: gate the faucet
|
|
27
|
+
|
|
28
|
+
**Platform facts to exploit before building anything:**
|
|
29
|
+
|
|
30
|
+
| | HubSpot | Salesforce |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| Contacts | API create with existing email → **409 with existing ID** (a free find-or-create) | Duplicate Rules fire on API writes (`DUPLICATES_DETECTED`); Alert-action rules bypassable via `DuplicateRuleHeader`, Block never |
|
|
33
|
+
| Companies/Accounts | **No API dedupe** (UI/import domain-dedupe does not apply to API) | Same duplicate-rule machinery |
|
|
34
|
+
| Deals/Opps | **No native dedupe anywhere** | No standard duplicate rule shipped |
|
|
35
|
+
| Idempotent writes | `batch/upsert` keyed on unique-value custom properties (≤10/object) | Upsert by External ID field — explicitly idempotent |
|
|
36
|
+
| Leak paths | API company creates; anything without email | Standard lead conversion, Quick Create, undelete |
|
|
37
|
+
|
|
38
|
+
**Our primitives and their duties:**
|
|
39
|
+
- `create:<Name>` link values must be **resolve-first**: search the live CRM
|
|
40
|
+
(and the current plan run) for an existing record before creating; link to
|
|
41
|
+
a unique match, refuse on ambiguity, create only on a confirmed miss.
|
|
42
|
+
HubSpot's search API is eventually consistent (~5–10s), so same-run
|
|
43
|
+
creations are deduped in memory, not via search.
|
|
44
|
+
- A standalone **`resolve` gate** (planned, 0.13): given a candidate
|
|
45
|
+
record, return existing match(es) or "safe to create" — for the CLI, the
|
|
46
|
+
library, MCP, and any external writer (sync jobs, agents, webhook
|
|
47
|
+
handlers). Identity keys are the ones the package already uses:
|
|
48
|
+
contact email, normalized account domain, and the open-deal key
|
|
49
|
+
(account + normalized name).
|
|
50
|
+
- **Stamp provenance on our own creates** (HubSpot allows integrations to
|
|
51
|
+
set `hs_object_source_detail_2/3` at create time).
|
|
52
|
+
- Recommend native config in `doctor`/audit: Salesforce duplicate rules
|
|
53
|
+
active? HubSpot unique-value properties defined? Prevention posture is
|
|
54
|
+
auditable configuration, not just record state.
|
|
55
|
+
|
|
56
|
+
## R — Read: watch continuously, attribute the source
|
|
57
|
+
|
|
58
|
+
- The regression primitive exists: `fullstackgtm diff --before a.json
|
|
59
|
+
--after b.json --fail-on-new-findings` exits 2 when a (rule, record) pair
|
|
60
|
+
fires that didn't before. "New" is a stable finding id — the hash of
|
|
61
|
+
(ruleId, objectId).
|
|
62
|
+
- **The nightly watch recipe** ("CRM CI"): scheduled
|
|
63
|
+
`snapshot → audit → diff` against yesterday's snapshot, alert on exit 2.
|
|
64
|
+
- **Attribution** (planned, 0.13): capture HubSpot's read-only
|
|
65
|
+
`hs_object_source`, `hs_object_source_label`, `hs_object_source_id` into
|
|
66
|
+
the canonical model so duplicate findings can say *"all five created by
|
|
67
|
+
integration X"*. The fix for recurring dupes is upstream, in the writer.
|
|
68
|
+
- Incremental reads (`snapshot --since`) exist for all three connectors;
|
|
69
|
+
caveats: HubSpot deltas carry no associations and cap at 10k per object,
|
|
70
|
+
Stripe deltas catch creations only.
|
|
71
|
+
|
|
72
|
+
## U — Update: governed merge
|
|
73
|
+
|
|
74
|
+
**Platform facts:** HubSpot's v3 merge endpoint
|
|
75
|
+
(`POST /crm/v3/objects/{type}/merge`) supports contacts, companies,
|
|
76
|
+
**deals**, and tickets today (the 2019 "no deal merge API" changelog is
|
|
77
|
+
obsolete). Merges are pairwise, the loser is auto-archived, primary's
|
|
78
|
+
values win, **merges cannot be undone**, and a record stops merging after
|
|
79
|
+
250 cumulative merges. Salesforce merge is SOAP/Apex only (no REST), only
|
|
80
|
+
Lead/Contact/Account/Case, max 3 records per call.
|
|
81
|
+
|
|
82
|
+
**The gap:** our three duplicate rules (`duplicate-account-domain`,
|
|
83
|
+
`duplicate-contact-email`, `duplicate-open-deal`) detect groups but emit
|
|
84
|
+
only merge-review *tasks* — detection without remediation.
|
|
85
|
+
|
|
86
|
+
**The plan (0.12):** a `merge_records` operation type —
|
|
87
|
+
`requires_human_survivor_selection` placeholder, survivor heuristics in
|
|
88
|
+
`suggest` (ordered, evidence-based: most engagements → oldest → most
|
|
89
|
+
complete, each with a written reason), high risk, approval required, with
|
|
90
|
+
the irreversibility called out in the plan text. The dry-run plan is the
|
|
91
|
+
preview every commercial tool charges for; the pre-apply snapshot is the
|
|
92
|
+
loser-record archive. HubSpot first; Salesforce merge documented as
|
|
93
|
+
unsupported until an Apex path justifies itself.
|
|
94
|
+
|
|
95
|
+
## D — Delete/Archive: the exit ramp
|
|
96
|
+
|
|
97
|
+
- `archive_record` exists in both connectors (HubSpot DELETE = archive,
|
|
98
|
+
restorable ~90 days; Salesforce DELETE = recycle bin) but no built-in
|
|
99
|
+
rule emits it. It is the endpoint for reviewed orphans; merge losers are
|
|
100
|
+
archived by the merge APIs themselves.
|
|
101
|
+
- All destructive operations stay high-risk, approval-required, and behind
|
|
102
|
+
compare-and-set where the platform exposes a readable before-value.
|
|
103
|
+
|
|
104
|
+
## Write-path integrity rules (our own faucet)
|
|
105
|
+
|
|
106
|
+
Lessons from auditing our own apply path:
|
|
107
|
+
|
|
108
|
+
1. Field writes (`set_field`/`clear_field`/`link_record`) are protected by
|
|
109
|
+
compare-and-set: the live value is read back and a drifted value returns
|
|
110
|
+
`conflict` without writing.
|
|
111
|
+
2. CAS is only as good as `readField` — for HubSpot deals, `accountId` is
|
|
112
|
+
an association, not a property, and must be read via the associations
|
|
113
|
+
API or CAS silently passes on replay (fixed in 0.11.1).
|
|
114
|
+
3. `create:` values are resolve-first and deduped within a plan run
|
|
115
|
+
(0.11.1); `create_task` operations carry an idempotency token in the
|
|
116
|
+
task body and pre-check for it (0.11.1, fail-open on search errors).
|
|
117
|
+
4. Plan replays are blocked at the store (`--plan-id` of an applied plan
|
|
118
|
+
refuses); the `--plan <file>` path relies on CAS — prefer the store.
|
|
119
|
+
|
|
120
|
+
## The operating cadence
|
|
121
|
+
|
|
122
|
+
**Gate** creates (resolve-first, upserts, native rules on) →
|
|
123
|
+
**Watch** nightly (snapshot, diff, exit-2 alert) →
|
|
124
|
+
**Fix** governed (audit → suggest → approve → apply, incl. merge) →
|
|
125
|
+
**Verify** (readback, re-audit, drift report) →
|
|
126
|
+
**Attribute** (provenance names the writer; fix the faucet).
|
|
127
|
+
|
|
128
|
+
## Build order
|
|
129
|
+
|
|
130
|
+
| Release | Scope |
|
|
131
|
+
| --- | --- |
|
|
132
|
+
| 0.11.1 | Fix our own faucet: resolve-first `create:` + plan-scoped dedup, HubSpot association-aware CAS for `link_record`, domain normalization in `duplicate-account-domain`, `create_task` idempotency token |
|
|
133
|
+
| 0.12 | `merge_records` (HubSpot contacts/companies/deals) + survivor suggestions; rules emit governed merges for dupe groups |
|
|
134
|
+
| 0.13 | `resolve` gate (CLI/lib/MCP), provenance capture + attribution in findings, prevention-posture checks |
|
|
135
|
+
| docs | The nightly watch recipe (existing flags, documented as CRM CI) |
|
package/llms.txt
CHANGED
|
@@ -15,6 +15,7 @@ at/above `--fail-on`.
|
|
|
15
15
|
- [README](https://github.com/fullstackgtm/core/blob/main/README.md): install, five-minute loop, auth ladder, MCP setup, programmatic use
|
|
16
16
|
- [INSTALL_FOR_AGENTS](https://github.com/fullstackgtm/core/blob/main/INSTALL_FOR_AGENTS.md): deterministic install-and-verify steps with expected outputs
|
|
17
17
|
- [API reference](https://github.com/fullstackgtm/core/blob/main/docs/api.md): semver-covered surfaces — canonical model, rule interface, plan/apply contract, connector contract, config, CLI, MCP tools
|
|
18
|
+
- [CRM-health lifecycle](https://github.com/fullstackgtm/core/blob/main/docs/crm-health-lifecycle.md): the Prevent → Detect → Remediate → Verify/Attribute model; no-new-dupes design
|
|
18
19
|
- [CHANGELOG](https://github.com/fullstackgtm/core/blob/main/CHANGELOG.md): release history
|
|
19
20
|
|
|
20
21
|
## Key invariants
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM",
|
|
@@ -54,6 +54,10 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
54
54
|
const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
|
|
55
55
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
56
56
|
const mappings = options.fieldMappings;
|
|
57
|
+
// create:<Name> dedup within one connector lifetime (one apply run): the
|
|
58
|
+
// search API is eventually consistent, so a just-created company is
|
|
59
|
+
// invisible to search — this map is the authoritative same-run record.
|
|
60
|
+
const createdCompaniesByName = new Map<string, string>();
|
|
57
61
|
|
|
58
62
|
async function request(path: string, init: RequestInit = {}): Promise<any> {
|
|
59
63
|
const token = await options.getAccessToken();
|
|
@@ -384,21 +388,46 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
384
388
|
if (!companyId) {
|
|
385
389
|
return { operationId: operation.id, status: "skipped", detail: "link_record needs a target company id." };
|
|
386
390
|
}
|
|
387
|
-
// `create:<Name>`
|
|
388
|
-
//
|
|
389
|
-
// the
|
|
391
|
+
// `create:<Name>` is resolve-first: link to an existing company when one
|
|
392
|
+
// unambiguously matches, refuse on ambiguity, create only on a confirmed
|
|
393
|
+
// miss — and never create the same name twice within one apply run
|
|
394
|
+
// (HubSpot's search API is eventually consistent, so a just-created
|
|
395
|
+
// record is invisible to search for several seconds).
|
|
390
396
|
let createdCompanyName: string | null = null;
|
|
397
|
+
let resolvedExisting = false;
|
|
391
398
|
if (companyId.startsWith("create:")) {
|
|
392
399
|
const name = companyId.slice("create:".length).trim();
|
|
393
400
|
if (!name) {
|
|
394
401
|
return { operationId: operation.id, status: "skipped", detail: "create: needs a company name (create:<Name>)." };
|
|
395
402
|
}
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
403
|
+
const nameKey = name.toLowerCase();
|
|
404
|
+
const alreadyCreated = createdCompaniesByName.get(nameKey);
|
|
405
|
+
if (alreadyCreated) {
|
|
406
|
+
companyId = alreadyCreated;
|
|
407
|
+
resolvedExisting = true;
|
|
408
|
+
} else {
|
|
409
|
+
const matches = await searchCompaniesByName(name);
|
|
410
|
+
if (matches.length > 1) {
|
|
411
|
+
return {
|
|
412
|
+
operationId: operation.id,
|
|
413
|
+
status: "skipped",
|
|
414
|
+
detail: `create:${name} is ambiguous — ${matches.length} companies already named "${name}" (ids ${matches.join(", ")}). Link an explicit company id instead.`,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
if (matches.length === 1) {
|
|
418
|
+
companyId = matches[0];
|
|
419
|
+
resolvedExisting = true;
|
|
420
|
+
createdCompaniesByName.set(nameKey, companyId);
|
|
421
|
+
} else {
|
|
422
|
+
const created = await request(`/crm/v3/objects/companies`, {
|
|
423
|
+
method: "POST",
|
|
424
|
+
body: JSON.stringify({ properties: { name } }),
|
|
425
|
+
});
|
|
426
|
+
companyId = String(created.id);
|
|
427
|
+
createdCompanyName = name;
|
|
428
|
+
createdCompaniesByName.set(nameKey, companyId);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
402
431
|
}
|
|
403
432
|
await request(
|
|
404
433
|
`/crm/v4/objects/${fromPath}/${encodeURIComponent(operation.objectId)}/associations/default/companies/${encodeURIComponent(companyId)}`,
|
|
@@ -409,11 +438,26 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
409
438
|
status: "applied",
|
|
410
439
|
detail: createdCompanyName
|
|
411
440
|
? `Created company "${createdCompanyName}" (${companyId}) and linked ${fromPath}/${operation.objectId} to it.`
|
|
412
|
-
:
|
|
441
|
+
: resolvedExisting
|
|
442
|
+
? `Linked ${fromPath}/${operation.objectId} to existing company ${companyId} (resolved by name, nothing created).`
|
|
443
|
+
: `Linked ${fromPath}/${operation.objectId} to company ${companyId}.`,
|
|
413
444
|
providerData: { companyId, ...(createdCompanyName ? { createdCompany: true } : {}) },
|
|
414
445
|
};
|
|
415
446
|
}
|
|
416
447
|
|
|
448
|
+
/** Exact-name company lookup for resolve-first creates. Returns matching ids (max 3 fetched). */
|
|
449
|
+
async function searchCompaniesByName(name: string): Promise<string[]> {
|
|
450
|
+
const data = await request(`/crm/v3/objects/companies/search`, {
|
|
451
|
+
method: "POST",
|
|
452
|
+
body: JSON.stringify({
|
|
453
|
+
filterGroups: [{ filters: [{ propertyName: "name", operator: "EQ", value: name }] }],
|
|
454
|
+
properties: ["name"],
|
|
455
|
+
limit: 3,
|
|
456
|
+
}),
|
|
457
|
+
});
|
|
458
|
+
return ((data?.results ?? []) as Array<{ id: string }>).map((row) => String(row.id));
|
|
459
|
+
}
|
|
460
|
+
|
|
417
461
|
async function createTask(operation: PatchOperation): Promise<PatchOperationResult> {
|
|
418
462
|
const associationTypeId = TASK_ASSOCIATION_TYPE_IDS[operation.objectType];
|
|
419
463
|
if (associationTypeId === undefined) {
|
|
@@ -424,7 +468,33 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
424
468
|
};
|
|
425
469
|
}
|
|
426
470
|
const subject = operation.field ? humanizeField(operation.field) : "Follow up";
|
|
427
|
-
|
|
471
|
+
// The operation id doubles as an idempotency token: it is stamped into
|
|
472
|
+
// the task body and pre-checked so a replayed plan does not create the
|
|
473
|
+
// same task twice. Fail-open — a search hiccup must not block the apply.
|
|
474
|
+
const token = `fsgtm ${operation.id.replace(/^op_/, "")}`;
|
|
475
|
+
try {
|
|
476
|
+
const existing = await request(`/crm/v3/objects/tasks/search`, {
|
|
477
|
+
method: "POST",
|
|
478
|
+
body: JSON.stringify({
|
|
479
|
+
filterGroups: [
|
|
480
|
+
{ filters: [{ propertyName: "hs_task_body", operator: "CONTAINS_TOKEN", value: token.split(" ")[1] }] },
|
|
481
|
+
],
|
|
482
|
+
limit: 1,
|
|
483
|
+
}),
|
|
484
|
+
});
|
|
485
|
+
const hit = (existing?.results ?? [])[0] as { id?: string } | undefined;
|
|
486
|
+
if (hit?.id) {
|
|
487
|
+
return {
|
|
488
|
+
operationId: operation.id,
|
|
489
|
+
status: "skipped",
|
|
490
|
+
detail: `Task for this operation already exists (task ${hit.id}); not creating a duplicate.`,
|
|
491
|
+
providerData: { id: hit.id, existing: true },
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
} catch {
|
|
495
|
+
// fall through to create
|
|
496
|
+
}
|
|
497
|
+
const body = `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`;
|
|
428
498
|
const response = await request(`/crm/v3/objects/tasks`, {
|
|
429
499
|
method: "POST",
|
|
430
500
|
body: JSON.stringify({
|
|
@@ -519,6 +589,15 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
519
589
|
if (!objectPath || !mappingType) {
|
|
520
590
|
throw new Error(`Field reads are only supported for accounts, contacts, and deals.`);
|
|
521
591
|
}
|
|
592
|
+
// accountId is an association in HubSpot, not a property — without this
|
|
593
|
+
// branch the compare-and-set on link_record reads null and passes blind.
|
|
594
|
+
if (field === "accountId" && (objectType === "deal" || objectType === "contact")) {
|
|
595
|
+
const data = await request(
|
|
596
|
+
`/crm/v4/objects/${objectPath}/${encodeURIComponent(objectId)}/associations/companies?limit=1`,
|
|
597
|
+
);
|
|
598
|
+
const first = (data?.results ?? [])[0] as { toObjectId?: number | string } | undefined;
|
|
599
|
+
return first?.toObjectId !== undefined ? String(first.toObjectId) : null;
|
|
600
|
+
}
|
|
522
601
|
const defaults = HUBSPOT_DEFAULT_FIELD_MAPPINGS[mappingType] ?? {};
|
|
523
602
|
const property = mappedField(mappings, mappingType, field, defaults[field] ?? field);
|
|
524
603
|
const data = await request(
|
|
@@ -63,6 +63,8 @@ export function createSalesforceConnector(
|
|
|
63
63
|
const apiVersion = options.apiVersion ?? DEFAULT_API_VERSION;
|
|
64
64
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
65
65
|
const mappings = options.fieldMappings;
|
|
66
|
+
// create:<Name> dedup within one connector lifetime (one apply run).
|
|
67
|
+
const createdAccountsByName = new Map<string, string>();
|
|
66
68
|
|
|
67
69
|
async function request(path: string, init: RequestInit = {}): Promise<any> {
|
|
68
70
|
const connection = await options.getConnection();
|
|
@@ -318,11 +320,29 @@ export function createSalesforceConnector(
|
|
|
318
320
|
detail: "Tasks can be attached to accounts, contacts, and deals.",
|
|
319
321
|
};
|
|
320
322
|
}
|
|
323
|
+
// Idempotency: the operation id is stamped into the Description and
|
|
324
|
+
// pre-checked, so replaying a plan does not duplicate tasks. Fail-open.
|
|
325
|
+
const token = `fsgtm:${operation.id}`;
|
|
326
|
+
try {
|
|
327
|
+
const existing = await query(
|
|
328
|
+
`SELECT Id FROM Task WHERE Description LIKE '%${token.replace(/'/g, "\\'")}%' LIMIT 1`,
|
|
329
|
+
);
|
|
330
|
+
if (existing.length > 0) {
|
|
331
|
+
return {
|
|
332
|
+
operationId: operation.id,
|
|
333
|
+
status: "skipped",
|
|
334
|
+
detail: `Task for this operation already exists (task ${existing[0].Id}); not creating a duplicate.`,
|
|
335
|
+
providerData: { id: String(existing[0].Id), existing: true },
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
} catch {
|
|
339
|
+
// fall through to create
|
|
340
|
+
}
|
|
321
341
|
const response = await request(`/services/data/${apiVersion}/sobjects/Task`, {
|
|
322
342
|
method: "POST",
|
|
323
343
|
body: JSON.stringify({
|
|
324
344
|
Subject: operation.field ? humanizeField(operation.field) : "Follow up",
|
|
325
|
-
Description: String(operation.afterValue ?? operation.reason ?? "")
|
|
345
|
+
Description: `${String(operation.afterValue ?? operation.reason ?? "")}\n\n[${token}]`,
|
|
326
346
|
Status: "Not Started",
|
|
327
347
|
Priority: "Normal",
|
|
328
348
|
...reference,
|
|
@@ -364,21 +384,51 @@ export function createSalesforceConnector(
|
|
|
364
384
|
// link_record on a deal is just setting AccountId in Salesforce.
|
|
365
385
|
return await setField(operation);
|
|
366
386
|
case "link_record": {
|
|
367
|
-
// `create:<Name>`
|
|
368
|
-
//
|
|
387
|
+
// `create:<Name>` is resolve-first: link to an unambiguous existing
|
|
388
|
+
// Account, refuse on ambiguity, create only on a confirmed miss —
|
|
389
|
+
// and never create the same name twice within one apply run.
|
|
369
390
|
const value = String(operation.afterValue ?? "");
|
|
370
391
|
if (value.startsWith("create:")) {
|
|
371
392
|
const name = value.slice("create:".length).trim();
|
|
372
393
|
if (!name) {
|
|
373
394
|
return { operationId: operation.id, status: "skipped", detail: "create: needs an account name (create:<Name>)." };
|
|
374
395
|
}
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
396
|
+
const nameKey = name.toLowerCase();
|
|
397
|
+
let accountId = createdAccountsByName.get(nameKey);
|
|
398
|
+
let createdNew = false;
|
|
399
|
+
if (!accountId) {
|
|
400
|
+
const soqlName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
401
|
+
const matches = await query(
|
|
402
|
+
`SELECT Id FROM Account WHERE Name = '${soqlName}' LIMIT 3`,
|
|
403
|
+
);
|
|
404
|
+
if (matches.length > 1) {
|
|
405
|
+
return {
|
|
406
|
+
operationId: operation.id,
|
|
407
|
+
status: "skipped",
|
|
408
|
+
detail: `create:${name} is ambiguous — ${matches.length} accounts already named "${name}". Link an explicit account id instead.`,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
if (matches.length === 1) {
|
|
412
|
+
accountId = String(matches[0].Id);
|
|
413
|
+
} else {
|
|
414
|
+
const created = await request(`/services/data/${apiVersion}/sobjects/Account`, {
|
|
415
|
+
method: "POST",
|
|
416
|
+
body: JSON.stringify({ Name: name }),
|
|
417
|
+
});
|
|
418
|
+
accountId = String(created.id);
|
|
419
|
+
createdNew = true;
|
|
420
|
+
}
|
|
421
|
+
createdAccountsByName.set(nameKey, accountId);
|
|
422
|
+
}
|
|
423
|
+
const result = await setField({ ...operation, operation: "set_field", afterValue: accountId });
|
|
380
424
|
return result.status === "applied"
|
|
381
|
-
? {
|
|
425
|
+
? {
|
|
426
|
+
...result,
|
|
427
|
+
detail: createdNew
|
|
428
|
+
? `Created account "${name}" (${accountId}) and linked ${operation.objectType}/${operation.objectId} to it.`
|
|
429
|
+
: `Linked ${operation.objectType}/${operation.objectId} to existing account ${accountId} (resolved by name, nothing created).`,
|
|
430
|
+
providerData: { accountId, ...(createdNew ? { createdAccount: true } : {}) },
|
|
431
|
+
}
|
|
382
432
|
: result;
|
|
383
433
|
}
|
|
384
434
|
return await setField({ ...operation, operation: "set_field" });
|
package/src/merge.ts
CHANGED
|
@@ -55,7 +55,7 @@ const CONFLICT_IGNORED_FIELDS = new Set([
|
|
|
55
55
|
"id", "provider", "crmId", "identities", "raw", "lastSyncAt", "lastActivityAt", "ownerId", "accountId",
|
|
56
56
|
]);
|
|
57
57
|
|
|
58
|
-
function normalizeDomain(domain?: string): string | undefined {
|
|
58
|
+
export function normalizeDomain(domain?: string): string | undefined {
|
|
59
59
|
if (!domain) return undefined;
|
|
60
60
|
return domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/.*$/, "") || undefined;
|
|
61
61
|
}
|
package/src/rules.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeDomain } from "./merge.ts";
|
|
1
2
|
import type {
|
|
2
3
|
AuditFinding,
|
|
3
4
|
CanonicalActivity,
|
|
@@ -333,7 +334,7 @@ export const duplicateAccountDomainRule: GtmAuditRule = {
|
|
|
333
334
|
evaluate: ({ snapshot }) => {
|
|
334
335
|
const findings: AuditFinding[] = [];
|
|
335
336
|
const operations = [];
|
|
336
|
-
for (const [domain, accounts] of duplicateGroups(snapshot.accounts, (account) => account.domain)) {
|
|
337
|
+
for (const [domain, accounts] of duplicateGroups(snapshot.accounts, (account) => normalizeDomain(account.domain))) {
|
|
337
338
|
const anchor = accounts[0];
|
|
338
339
|
findings.push({
|
|
339
340
|
id: auditFindingId("duplicate-account-domain", anchor.id),
|