fullstackgtm 0.25.1 → 0.26.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 +97 -0
- package/dist/bulkUpdate.js +6 -1
- package/dist/cli.js +67 -2
- package/dist/connector.js +90 -1
- package/dist/connectors/hubspot.js +5 -2
- package/dist/connectors/salesforce.js +4 -2
- package/dist/connectors/stripe.js +4 -2
- package/dist/credentials.js +22 -1
- package/dist/dedupe.d.ts +6 -0
- package/dist/dedupe.js +24 -1
- package/dist/enrich.js +24 -2
- package/dist/enrichApollo.js +5 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/integrity.d.ts +30 -0
- package/dist/integrity.js +128 -0
- package/dist/market.d.ts +1 -0
- package/dist/market.js +144 -8
- package/dist/marketReport.d.ts +9 -0
- package/dist/marketReport.js +29 -4
- package/dist/marketTaxonomy.d.ts +41 -0
- package/dist/marketTaxonomy.js +193 -0
- package/dist/planStore.d.ts +6 -0
- package/dist/planStore.js +10 -2
- package/dist/schedule.d.ts +17 -0
- package/dist/schedule.js +87 -2
- package/dist/types.d.ts +16 -0
- package/package.json +1 -1
- package/src/bulkUpdate.ts +6 -1
- package/src/cli.ts +80 -1
- package/src/connector.ts +96 -1
- package/src/connectors/hubspot.ts +5 -2
- package/src/connectors/salesforce.ts +4 -2
- package/src/connectors/stripe.ts +4 -2
- package/src/credentials.ts +24 -0
- package/src/dedupe.ts +23 -1
- package/src/enrich.ts +25 -2
- package/src/enrichApollo.ts +5 -2
- package/src/index.ts +8 -0
- package/src/integrity.ts +146 -0
- package/src/market.ts +129 -8
- package/src/marketReport.ts +30 -4
- package/src/marketTaxonomy.ts +288 -0
- package/src/planStore.ts +23 -4
- package/src/schedule.ts +98 -2
- package/src/types.ts +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,103 @@ 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.26.0] — 2026-06-15
|
|
9
|
+
|
|
10
|
+
Write-path integrity — the "no write without approval" guarantee now binds to
|
|
11
|
+
operation *content*, and the two irreversible operations finally get a guard.
|
|
12
|
+
Each fix verified by a refute-by-default re-attack; the integrity binding took
|
|
13
|
+
three rounds (the re-attack kept finding unsigned fields that reach a write).
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Plan-approval integrity signatures.** `plans approve` now HMAC-signs each
|
|
18
|
+
approved operation's full apply-relevant content (operation, object, field,
|
|
19
|
+
before/after value, group, preconditions, force flags, the approved value
|
|
20
|
+
override, and reason) with a per-install key (`$FSGTM_HOME/.plan-signing-key`,
|
|
21
|
+
0600). `apply --plan-id` re-verifies and refuses the whole apply if any
|
|
22
|
+
approved operation changed since approval, if the plan was approved without
|
|
23
|
+
signatures (downgrade guard), or if the signing key is absent (a plan
|
|
24
|
+
approved on another machine fails closed). The invariant: **what gets written
|
|
25
|
+
equals what the human signed** — a plan file edited between approval and apply
|
|
26
|
+
(by a synced/backed-up copy, a co-tenant, or a compromised dependency) is
|
|
27
|
+
caught instead of executed. apply-time `--value` is folded into the check, and
|
|
28
|
+
a scheduled `apply` may not take `--value` (it must write exactly the signed
|
|
29
|
+
values).
|
|
30
|
+
- **Recovery snapshots on irreversible operations.** `dedupe` merge ops and
|
|
31
|
+
`bulk-update --archive` ops now carry `recoverySnapshot` — the field values of
|
|
32
|
+
every record that will be destroyed — so the rollback instruction ("recreate
|
|
33
|
+
it by hand") is backed by actual data in the plan, which is the backup.
|
|
34
|
+
|
|
35
|
+
### Security
|
|
36
|
+
|
|
37
|
+
- **Apply-time guard against destroying duplicates (the benchmark self-own).**
|
|
38
|
+
`apply` now refuses any `archive_record` whose target still shares an identity
|
|
39
|
+
key (account domain / contact email) with another live record — unless the
|
|
40
|
+
human explicitly forced it (`--force-archive-duplicates`, which is recorded on
|
|
41
|
+
the operation and signed). This catches every path (agent-driven, hand-edited,
|
|
42
|
+
audit), not just `bulk-update`, so an agent on a dedupe task can no longer
|
|
43
|
+
silently archive a record where it should merge — the rail is safe regardless
|
|
44
|
+
of model strength.
|
|
45
|
+
- **Drift guard for irreversible operations.** `merge_records` and
|
|
46
|
+
`archive_record` got no compare-and-set (there is no single field to compare).
|
|
47
|
+
Apply now checks a fresh snapshot: a merge whose survivor is gone, or whose
|
|
48
|
+
duplicates are already merged, and an archive of a record that no longer
|
|
49
|
+
exists, all conflict out instead of firing an irreversible, replay-unsafe write.
|
|
50
|
+
|
|
51
|
+
### Notes
|
|
52
|
+
|
|
53
|
+
- The CAS empty/null equivalence in field compare-and-set is intentional (CRMs
|
|
54
|
+
normalize `""`↔`null` server-side; distinguishing them would cause false
|
|
55
|
+
conflicts). Known residuals for a follow-up: the archive-duplicate guard keys
|
|
56
|
+
on domain/email only, so `dedupe --key name` (and deal dedupe, which has no
|
|
57
|
+
identity key) are not guarded against destructive archive — use `dedupe`
|
|
58
|
+
(merge) for those; and `marketMapToMarkdown` is not HTML-escaped (the HTML
|
|
59
|
+
report is).
|
|
60
|
+
|
|
61
|
+
## [0.25.2] — 2026-06-15
|
|
62
|
+
|
|
63
|
+
Security hardening I — confirmed fixes from an adversarial audit (each verified
|
|
64
|
+
by a refute-by-default re-attack; the crontab and report fixes took three
|
|
65
|
+
rounds because the re-attack kept finding deeper paths).
|
|
66
|
+
|
|
67
|
+
### Security
|
|
68
|
+
|
|
69
|
+
- **Crontab injection via `schedule install` (was: arbitrary code execution).**
|
|
70
|
+
`schedule add --label` rejects newlines/control chars; `renderManagedBlock`
|
|
71
|
+
now refuses to render any entry (or CLI invocation) whose interpolated
|
|
72
|
+
fields — label, cron, id, profile, argv, **and the resolved node/script
|
|
73
|
+
path + `FSGTM_HOME`** — carry a control character, so a hand-edited
|
|
74
|
+
`schedules.json` or a newline in `FSGTM_HOME` can no longer inject a live
|
|
75
|
+
crontab line. `parseCron` now accepts ASCII space/tab only (rejects Unicode
|
|
76
|
+
whitespace), and a stray `%` in a path is escaped (`\%`) so it can't truncate
|
|
77
|
+
the managed line.
|
|
78
|
+
- **SSRF in `market capture`.** Page fetches now allow only http/https, refuse
|
|
79
|
+
any host that is or resolves to a private/loopback/link-local/CGNAT/metadata
|
|
80
|
+
address (IPv4, IPv6, and IPv4-mapped IPv6 in dotted or hex form), follow
|
|
81
|
+
redirects manually with per-hop re-validation, and cap time/body size.
|
|
82
|
+
- **Stored XSS in the market HTML report.** The embedded JSON data island is
|
|
83
|
+
serialized with `<`/`>`/`&`/U+2028/U+2029 escaped (no `</script>` breakout),
|
|
84
|
+
the tooltip is built with `textContent` (no `innerHTML`), and the two
|
|
85
|
+
remaining raw sinks (anchor vendor name, evidence-appendix confidence) are
|
|
86
|
+
now `escapeHtml`'d; `validateObservationSet` rejects a non-enum `confidence`
|
|
87
|
+
so an `observe --from` file can't smuggle markup.
|
|
88
|
+
- **Provider response bodies no longer leak into errors.** HubSpot, Salesforce,
|
|
89
|
+
Apollo, and Stripe connectors throw status-line-only errors (a 4xx body can
|
|
90
|
+
echo submitted emails/domains or the key, and these errors are persisted into
|
|
91
|
+
scheduled-run records).
|
|
92
|
+
- **CSV/formula injection neutralized at the enrich write path.** Ingested
|
|
93
|
+
string values beginning with `= + - @` / tab / CR are prefixed with `'` so
|
|
94
|
+
they can't execute if the CRM is later exported to a spreadsheet; numeric
|
|
95
|
+
values keep full fidelity.
|
|
96
|
+
- **Credential-store mode enforced on read, not just write.** A pre-existing
|
|
97
|
+
`credentials.json` with group/other permissions is re-tightened to 0600 (and
|
|
98
|
+
warned) on read, closing the inherited-loose-permissions gap.
|
|
99
|
+
|
|
100
|
+
Known residuals tracked for follow-up: `marketMapToMarkdown` does not
|
|
101
|
+
HTML-escape (safe in terminals/GitHub; only a risk if a downstream renderer
|
|
102
|
+
trusts raw HTML — to be addressed with the report work); the credential read
|
|
103
|
+
check is reactive (a loose file is exposed until the next CLI read).
|
|
104
|
+
|
|
8
105
|
## [0.25.1] — 2026-06-12
|
|
9
106
|
|
|
10
107
|
Docs-sync release — no code changes.
|
package/dist/bulkUpdate.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* `account.ownerId`, `account.contactCount`; accounts get `contactCount`
|
|
28
28
|
* and `openDealCount`.
|
|
29
29
|
*/
|
|
30
|
+
import { recoverableFields } from "./dedupe.js";
|
|
30
31
|
import { normalizeDomain } from "./merge.js";
|
|
31
32
|
import { stableHash } from "./rules.js";
|
|
32
33
|
const FIELD_PATTERN = "[a-zA-Z][a-zA-Z0-9_]*(?:\\.[a-zA-Z][a-zA-Z0-9_]*)?";
|
|
@@ -319,7 +320,11 @@ export function buildBulkUpdatePlan(snapshot, options) {
|
|
|
319
320
|
beforeValue: null,
|
|
320
321
|
afterValue: null,
|
|
321
322
|
riskLevel: "high",
|
|
322
|
-
|
|
323
|
+
// Carry the human's explicit force decision to the apply-time guard, and
|
|
324
|
+
// snapshot the record so it can be recreated if the archive was wrong.
|
|
325
|
+
...(options.forceArchiveDuplicates ? { forceArchiveDuplicate: true } : {}),
|
|
326
|
+
recoverySnapshot: [recoverableFields(record)],
|
|
327
|
+
rollback: "Archived records can be restored from the provider's recycle bin within its retention window; recoverySnapshot also retains the field values.",
|
|
323
328
|
});
|
|
324
329
|
continue;
|
|
325
330
|
}
|
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ import { activeProfile, credentialsPath, DEFAULT_PROFILE, deleteCredential, getC
|
|
|
13
13
|
import { generateDemoSnapshot } from "./demo.js";
|
|
14
14
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
15
15
|
import { mergeSnapshots } from "./merge.js";
|
|
16
|
+
import { verifyApprovalDigests } from "./integrity.js";
|
|
16
17
|
import { createFilePlanStore } from "./planStore.js";
|
|
17
18
|
import { auditReportToHtml, auditReportToMarkdown } from "./report.js";
|
|
18
19
|
import { builtinAuditRules } from "./rules.js";
|
|
@@ -22,12 +23,13 @@ import { captureMarket, computeFrontStates, createFileObservationStore, diffFron
|
|
|
22
23
|
import { assessAxes, axesReportToText } from "./marketAxes.js";
|
|
23
24
|
import { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, } from "./marketOverlay.js";
|
|
24
25
|
import { computeScaleIndex, scaleReportToText } from "./marketScale.js";
|
|
26
|
+
import { suggestMarketConfig } from "./marketTaxonomy.js";
|
|
25
27
|
import { buildWorksheet, classifyMarket } from "./marketClassify.js";
|
|
26
28
|
import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
|
|
27
29
|
import { DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
|
|
28
30
|
import { buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor, } from "./enrich.js";
|
|
29
31
|
import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, } from "./enrichApollo.js";
|
|
30
|
-
import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, scheduleId, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, } from "./schedule.js";
|
|
32
|
+
import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, assertSingleLineLabel, hasControlChar, scheduleId, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, } from "./schedule.js";
|
|
31
33
|
import { resolveRecord } from "./resolve.js";
|
|
32
34
|
import { buildBulkUpdatePlan } from "./bulkUpdate.js";
|
|
33
35
|
import { buildDedupePlan } from "./dedupe.js";
|
|
@@ -827,6 +829,8 @@ async function marketCommand(args) {
|
|
|
827
829
|
if (!subcommand || subcommand === "--help" || subcommand === "-h" || rest.includes("--help") || rest.includes("-h")) {
|
|
828
830
|
console.log(`Usage:
|
|
829
831
|
market init --category <name> [--out <path>] write a starter market.config.json
|
|
832
|
+
market init --category <name> --auto --vendor <url> [--vendor <url>...] [--anchor <url>] [--max-claims n]
|
|
833
|
+
LLM-propose vendors + claim taxonomy from seed pages (needs an API key)
|
|
830
834
|
market capture [--config <path>] [--run <label>]
|
|
831
835
|
market classify [--run <label>] [--capture-run <label>] [--vendor <id>] [--model m] [--out <path>]
|
|
832
836
|
market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
|
|
@@ -877,6 +881,27 @@ recomputed deterministically on every invocation — never stored.`);
|
|
|
877
881
|
const outPath = resolve(process.cwd(), option(rest, "--out") ?? "market.config.json");
|
|
878
882
|
if (existsSync(outPath))
|
|
879
883
|
throw new Error(`${outPath} already exists — refusing to overwrite`);
|
|
884
|
+
if (rest.includes("--auto")) {
|
|
885
|
+
const vendorUrls = repeatedOption(rest, "--vendor");
|
|
886
|
+
if (vendorUrls.length === 0) {
|
|
887
|
+
throw new Error("market init --auto requires at least one --vendor <url> (the competitor homepages to seed from)");
|
|
888
|
+
}
|
|
889
|
+
const anchorUrl = option(rest, "--anchor");
|
|
890
|
+
const credential = await requireLlmCredential("market classify");
|
|
891
|
+
console.error(`Capturing ${vendorUrls.length} seed page(s) and proposing a claim taxonomy with ${credential.provider}…`);
|
|
892
|
+
const { config, unreadableVendorIds, model } = await suggestMarketConfig({
|
|
893
|
+
category,
|
|
894
|
+
vendors: vendorUrls.map((url) => ({ url, anchor: anchorUrl ? url === anchorUrl : false })),
|
|
895
|
+
llm: { ...credential, model: option(rest, "--model") ?? undefined },
|
|
896
|
+
maxClaims: numericOption(rest, "--max-claims"),
|
|
897
|
+
});
|
|
898
|
+
writeFileSync(outPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
899
|
+
if (unreadableVendorIds.length > 0) {
|
|
900
|
+
console.error(`Note: no readable text for ${unreadableVendorIds.join(", ")} — excluded from taxonomy grounding.`);
|
|
901
|
+
}
|
|
902
|
+
console.log(`Wrote ${outPath}: ${config.vendors.length} vendors, ${config.claims.length} proposed claims (${model}). Review it, then: fullstackgtm market refresh`);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
880
905
|
writeFileSync(outPath, `${JSON.stringify(starterMarketConfig(category), null, 2)}\n`);
|
|
881
906
|
console.log(`Wrote ${outPath}. Fill in vendors and claims, then: fullstackgtm market capture`);
|
|
882
907
|
return;
|
|
@@ -1614,6 +1639,7 @@ trigger: manual. status shows next firing and surfaces missed firings
|
|
|
1614
1639
|
const createdAt = new Date().toISOString();
|
|
1615
1640
|
const label = option(rest, "--label") ??
|
|
1616
1641
|
argv.filter((arg) => !arg.startsWith("--")).slice(0, 2).join("-").replace(/[^\w.-]+/g, "-");
|
|
1642
|
+
assertSingleLineLabel(label);
|
|
1617
1643
|
const entry = {
|
|
1618
1644
|
id: scheduleId(label, cron.source, argv, createdAt),
|
|
1619
1645
|
label,
|
|
@@ -1819,13 +1845,27 @@ function scheduleCliInvocation() {
|
|
|
1819
1845
|
if (!script || !existsSync(script)) {
|
|
1820
1846
|
throw new Error("Cannot resolve the fullstackgtm entry point for crontab lines (process.argv[1] is missing).");
|
|
1821
1847
|
}
|
|
1848
|
+
// A newline/control char in any of these flows verbatim into the crontab
|
|
1849
|
+
// executable line; single-quote escaping defends the shell, not cron's line
|
|
1850
|
+
// parser. Refuse early with a clear message (renderManagedBlock re-checks).
|
|
1851
|
+
for (const [name, value] of [
|
|
1852
|
+
["FSGTM_HOME", process.env.FSGTM_HOME],
|
|
1853
|
+
["the node executable path", process.execPath],
|
|
1854
|
+
["the CLI script path", script],
|
|
1855
|
+
]) {
|
|
1856
|
+
if (value && hasControlChar(value)) {
|
|
1857
|
+
throw new Error(`Cannot install schedules: ${name} contains a newline or control character.`);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1822
1860
|
const quote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
1823
1861
|
const parts = [quote(process.execPath)];
|
|
1824
1862
|
if (script.endsWith(".ts"))
|
|
1825
1863
|
parts.push("--experimental-strip-types");
|
|
1826
1864
|
parts.push(quote(script));
|
|
1827
1865
|
const home = process.env.FSGTM_HOME ? `FSGTM_HOME=${quote(process.env.FSGTM_HOME)} ` : "";
|
|
1828
|
-
|
|
1866
|
+
// cron treats an unescaped `%` in the command field as a newline/stdin split.
|
|
1867
|
+
// Escape it as `\%` so a stray `%` in a path can't truncate the managed line.
|
|
1868
|
+
return (home + parts.join(" ")).replace(/%/g, "\\%");
|
|
1829
1869
|
}
|
|
1830
1870
|
/**
|
|
1831
1871
|
* The single provider entry point: execute the scheduled command in-process
|
|
@@ -2262,7 +2302,32 @@ async function apply(args) {
|
|
|
2262
2302
|
}
|
|
2263
2303
|
plan = stored.plan;
|
|
2264
2304
|
approvedOperationIds = stored.approvedOperationIds;
|
|
2305
|
+
// Downgrade guard: an approved plan with no signatures is either pre-0.26
|
|
2306
|
+
// (re-approve to gain them) or had its approvalDigests stripped to skip the
|
|
2307
|
+
// integrity check. Either way, refuse rather than fall back to trusting the
|
|
2308
|
+
// file. (A plan with zero approved operations has nothing to apply anyway.)
|
|
2309
|
+
if (stored.approvedOperationIds.length > 0 && !stored.approvalDigests) {
|
|
2310
|
+
throw new Error(`Refusing to apply plan ${planId}: it was approved without integrity signatures ` +
|
|
2311
|
+
"(approved before 0.26.0, or its signatures were removed). Re-approve it with " +
|
|
2312
|
+
`\`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`);
|
|
2313
|
+
}
|
|
2314
|
+
// Integrity gate: the plan file is re-read from disk, so verify each approved
|
|
2315
|
+
// operation still matches what was signed at approval. Verify against the
|
|
2316
|
+
// EFFECTIVE overrides (stored ∪ apply-time --value): the invariant is "what
|
|
2317
|
+
// gets written must equal what was signed", so an apply-time --value that
|
|
2318
|
+
// changes a value the human did not approve is treated as tamper, not a live
|
|
2319
|
+
// override. A mismatch means the plan/overrides were edited after approval —
|
|
2320
|
+
// refuse the whole apply rather than write an unapproved value.
|
|
2265
2321
|
valueOverrides = { ...stored.valueOverrides, ...parseValueOverrides(args) };
|
|
2322
|
+
const verification = verifyApprovalDigests(stored.plan.operations, stored.approvedOperationIds, valueOverrides, stored.approvalDigests);
|
|
2323
|
+
if (!verification.ok) {
|
|
2324
|
+
const detail = verification.reason === "no_key"
|
|
2325
|
+
? "the plan-signing key is missing (was this plan approved on another machine?). Re-approve it here with `fullstackgtm plans approve`."
|
|
2326
|
+
: `these operations differ from what was approved: ${verification.tampered.join(", ")}. ` +
|
|
2327
|
+
"If you changed a value at apply time, set it at approval instead (`plans approve --value <op>=<v>`) and re-approve; " +
|
|
2328
|
+
"otherwise the plan was edited after approval — review and re-approve.";
|
|
2329
|
+
throw new Error(`Refusing to apply plan ${planId}: ${detail}`);
|
|
2330
|
+
}
|
|
2266
2331
|
}
|
|
2267
2332
|
else {
|
|
2268
2333
|
const approve = option(args, "--approve");
|
package/dist/connector.js
CHANGED
|
@@ -1,4 +1,69 @@
|
|
|
1
|
+
import { dedupeKey } from "./dedupe.js";
|
|
1
2
|
import { requiresHumanInput } from "./rules.js";
|
|
3
|
+
const IRREVERSIBLE_OPERATIONS = new Set(["merge_records", "archive_record"]);
|
|
4
|
+
const IDENTITY_KEY_BY_TYPE = {
|
|
5
|
+
account: "domain",
|
|
6
|
+
contact: "email",
|
|
7
|
+
};
|
|
8
|
+
/** snapshot collection for an object type */
|
|
9
|
+
function collectionFor(objectType) {
|
|
10
|
+
if (objectType === "account")
|
|
11
|
+
return "accounts";
|
|
12
|
+
if (objectType === "contact")
|
|
13
|
+
return "contacts";
|
|
14
|
+
if (objectType === "deal")
|
|
15
|
+
return "deals";
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Drift/safety check for the two IRREVERSIBLE operations against a fresh
|
|
20
|
+
* snapshot. Returns a conflict detail string, or null if the op is safe to
|
|
21
|
+
* apply. These operations get NO field compare-and-set (there is no single
|
|
22
|
+
* field to compare), so this snapshot check is their only guard.
|
|
23
|
+
*/
|
|
24
|
+
function checkIrreversibleOp(operation, snapshot) {
|
|
25
|
+
const collection = collectionFor(operation.objectType);
|
|
26
|
+
if (!collection)
|
|
27
|
+
return null;
|
|
28
|
+
const records = snapshot[collection];
|
|
29
|
+
const byId = (id) => records.find((record) => String(record.id) === id);
|
|
30
|
+
if (operation.operation === "archive_record") {
|
|
31
|
+
if (!byId(operation.objectId)) {
|
|
32
|
+
return `Record ${operation.objectType}/${operation.objectId} no longer exists (already archived or merged). Re-plan against current data.`;
|
|
33
|
+
}
|
|
34
|
+
// Archiving a duplicate discards data a merge would keep — refuse unless the
|
|
35
|
+
// human explicitly forced it. This catches every archive_record path (agent,
|
|
36
|
+
// hand-edited plan, audit), not just `bulk-update --archive`.
|
|
37
|
+
if (!operation.forceArchiveDuplicate) {
|
|
38
|
+
const keyName = IDENTITY_KEY_BY_TYPE[operation.objectType];
|
|
39
|
+
if (keyName) {
|
|
40
|
+
const target = byId(operation.objectId);
|
|
41
|
+
const key = dedupeKey(target, keyName);
|
|
42
|
+
if (key) {
|
|
43
|
+
const sharers = records.filter((record) => String(record.id) !== operation.objectId && dedupeKey(record, keyName) === key);
|
|
44
|
+
if (sharers.length > 0) {
|
|
45
|
+
return (`Refusing to archive ${operation.objectType}/${operation.objectId}: it shares ${keyName} "${key}" with ` +
|
|
46
|
+
`${sharers.length} other record(s) — that's a duplicate, and archiving discards its data where merging keeps it. ` +
|
|
47
|
+
`Merge with \`fullstackgtm dedupe ${operation.objectType} --key ${keyName}\` instead, or rebuild the op with --force-archive-duplicates.`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
if (operation.operation === "merge_records") {
|
|
55
|
+
if (!byId(operation.objectId)) {
|
|
56
|
+
return `Merge survivor ${operation.objectType}/${operation.objectId} no longer exists (archived or merged away since the plan was built). Re-plan — merges are irreversible.`;
|
|
57
|
+
}
|
|
58
|
+
const groupIds = Array.isArray(operation.beforeValue) ? operation.beforeValue.map(String) : [];
|
|
59
|
+
const losersStillPresent = groupIds.filter((id) => id !== operation.objectId && byId(id));
|
|
60
|
+
if (groupIds.length > 0 && losersStillPresent.length === 0) {
|
|
61
|
+
return `Every record to merge into ${operation.objectType}/${operation.objectId} is already gone (merge already applied?). Nothing to do — re-plan if duplicates remain.`;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
2
67
|
const FIELD_WRITE_OPERATIONS = new Set(["set_field", "clear_field", "link_record"]);
|
|
3
68
|
function normalizeForComparison(value) {
|
|
4
69
|
if (value === undefined || value === null || value === "")
|
|
@@ -35,9 +100,16 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
35
100
|
// closed — but it can be shrunk: re-run the snapshot checks after the
|
|
36
101
|
// first write and every `recheckEvery` writes, conflicting out any
|
|
37
102
|
// operation whose record went stale mid-run.
|
|
38
|
-
|
|
103
|
+
// Irreversible ops (merge/archive) need a fresh snapshot too — it is their
|
|
104
|
+
// only drift/safety guard (no field to compare-and-set). Respect a caller's
|
|
105
|
+
// explicit checkConflicts:false opt-out (a stub/known-stale snapshot).
|
|
106
|
+
const hasIrreversibleApproved = checkConflicts &&
|
|
107
|
+
plan.operations.some((operation) => approved.has(operation.id) && IRREVERSIBLE_OPERATIONS.has(operation.operation));
|
|
108
|
+
const needsSnapshot = ((plan.guards && plan.guards.length > 0) || plan.filter || hasIrreversibleApproved) &&
|
|
109
|
+
connector.fetchSnapshot;
|
|
39
110
|
const recheckEvery = Math.max(1, options.recheckEvery ?? 25);
|
|
40
111
|
const staleIds = new Set();
|
|
112
|
+
const irreversibleStale = new Map();
|
|
41
113
|
let guardFailure = null;
|
|
42
114
|
const refreshSnapshotChecks = async () => {
|
|
43
115
|
if (!needsSnapshot)
|
|
@@ -52,6 +124,16 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
52
124
|
staleIds.add(operation.objectId);
|
|
53
125
|
}
|
|
54
126
|
}
|
|
127
|
+
irreversibleStale.clear();
|
|
128
|
+
if (checkConflicts) {
|
|
129
|
+
for (const operation of plan.operations) {
|
|
130
|
+
if (!approved.has(operation.id) || !IRREVERSIBLE_OPERATIONS.has(operation.operation))
|
|
131
|
+
continue;
|
|
132
|
+
const detail = checkIrreversibleOp(operation, liveSnapshot);
|
|
133
|
+
if (detail)
|
|
134
|
+
irreversibleStale.set(operation.id, detail);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
55
137
|
for (const guard of plan.guards ?? []) {
|
|
56
138
|
const failure = evaluateGuard(liveSnapshot, guard);
|
|
57
139
|
if (failure) {
|
|
@@ -182,6 +264,13 @@ export async function applyPatchPlan(connector, plan, options) {
|
|
|
182
264
|
poisonedGroups.add(operation.groupId);
|
|
183
265
|
continue;
|
|
184
266
|
}
|
|
267
|
+
const irreversibleConflict = irreversibleStale.get(operation.id);
|
|
268
|
+
if (irreversibleConflict) {
|
|
269
|
+
results.push({ operationId: operation.id, status: "conflict", detail: irreversibleConflict });
|
|
270
|
+
if (operation.groupId)
|
|
271
|
+
poisonedGroups.add(operation.groupId);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
185
274
|
if (operation.groupId && poisonedGroups.has(operation.groupId)) {
|
|
186
275
|
results.push({
|
|
187
276
|
operationId: operation.id,
|
|
@@ -44,8 +44,11 @@ export function createHubspotConnector(options) {
|
|
|
44
44
|
throw new Error(`Cannot reach HubSpot at ${baseUrl}${cause}. Check network access.`);
|
|
45
45
|
}
|
|
46
46
|
if (!response.ok) {
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
// Status line only — HubSpot 4xx bodies echo submitted property values
|
|
48
|
+
// (contact emails, company domains) and the request payload, and these
|
|
49
|
+
// errors are persisted into scheduled-run records. Never interpolate it.
|
|
50
|
+
await response.text().catch(() => undefined);
|
|
51
|
+
throw new Error(`HubSpot API error ${response.status}. Check the token scopes and request.`);
|
|
49
52
|
}
|
|
50
53
|
// DELETE and some association writes return 204 with an empty body.
|
|
51
54
|
const text = await response.text();
|
|
@@ -46,8 +46,10 @@ export function createSalesforceConnector(options) {
|
|
|
46
46
|
throw new Error(`Cannot reach Salesforce at ${connection.instanceUrl}${cause}. Check SALESFORCE_INSTANCE_URL (your My Domain URL, e.g. https://yourco.my.salesforce.com) and network access.`);
|
|
47
47
|
}
|
|
48
48
|
if (!response.ok) {
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
// Status line only — the body echoes submitted field values and the
|
|
50
|
+
// request, and these errors are persisted into scheduled-run records.
|
|
51
|
+
await response.text().catch(() => undefined);
|
|
52
|
+
throw new Error(`Salesforce API error ${response.status}. Check the token and request.`);
|
|
51
53
|
}
|
|
52
54
|
// Salesforce PATCH returns 204 No Content on success.
|
|
53
55
|
const text = await response.text();
|
|
@@ -26,8 +26,10 @@ export function createStripeConnector(options) {
|
|
|
26
26
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
27
27
|
});
|
|
28
28
|
if (!response.ok) {
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// Status line only — the body can echo request details bound to a live
|
|
30
|
+
// billing key, and these errors land in scheduled-run records.
|
|
31
|
+
await response.text().catch(() => undefined);
|
|
32
|
+
throw new Error(`Stripe API error ${response.status}. Check the restricted key and request.`);
|
|
31
33
|
}
|
|
32
34
|
return response.json();
|
|
33
35
|
}
|
package/dist/credentials.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { refreshHubspotToken } from "./connectors/hubspotAuth.js";
|
|
@@ -98,8 +98,29 @@ export function writeSecureFile(path, contents) {
|
|
|
98
98
|
// Non-POSIX filesystems ignore chmod.
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* The 0600/0700 guarantee was write-only: a credentials.json inherited at
|
|
103
|
+
* looser permissions (a restored backup, a file created by another tool, a
|
|
104
|
+
* cloned home) was read and trusted regardless of its actual mode. Enforce the
|
|
105
|
+
* mode on read too — re-tighten to 0600 and warn once — so a world-readable
|
|
106
|
+
* credential store can't sit there silently leaking the token to other users.
|
|
107
|
+
*/
|
|
108
|
+
function enforceCredentialFileMode(path) {
|
|
109
|
+
try {
|
|
110
|
+
const mode = statSync(path).mode & 0o777;
|
|
111
|
+
if ((mode & 0o077) !== 0) {
|
|
112
|
+
chmodSync(path, 0o600);
|
|
113
|
+
console.error(`fullstackgtm: tightened ${path} from ${mode.toString(8).padStart(3, "0")} to 600 ` +
|
|
114
|
+
"(it was readable or writable by other users).");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Missing file or non-POSIX filesystem: nothing to enforce.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
101
121
|
function readFile() {
|
|
102
122
|
try {
|
|
123
|
+
enforceCredentialFileMode(credentialsPath());
|
|
103
124
|
const parsed = JSON.parse(readFileSync(credentialsPath(), "utf8"));
|
|
104
125
|
if (parsed && typeof parsed === "object" && parsed.version === 1 && parsed.providers) {
|
|
105
126
|
return parsed;
|
package/dist/dedupe.d.ts
CHANGED
|
@@ -9,6 +9,12 @@ export type DedupeOptions = {
|
|
|
9
9
|
/** refuse to build plans larger than this (default 500 operations) */
|
|
10
10
|
maxOperations?: number;
|
|
11
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* The subset of a record worth keeping as a merge-recovery artifact: its id (to
|
|
14
|
+
* reference) plus every populated data field, dropping bulky/plumbing fields
|
|
15
|
+
* (raw, identities, provenance) that aren't needed to recreate it by hand.
|
|
16
|
+
*/
|
|
17
|
+
export declare function recoverableFields(record: Record<string, unknown>): Record<string, unknown>;
|
|
12
18
|
/** Normalize a record's identity key; undefined when the field is empty. */
|
|
13
19
|
export declare function dedupeKey(record: Record<string, unknown>, key: DedupeOptions["key"]): string | undefined;
|
|
14
20
|
export declare function buildDedupePlan(snapshot: CanonicalGtmSnapshot, options: DedupeOptions): PatchPlan;
|
package/dist/dedupe.js
CHANGED
|
@@ -40,6 +40,22 @@ const NON_DATA_FIELDS = new Set(["id", "provider", "crmId", "identities", "raw",
|
|
|
40
40
|
function populatedDataFields(record) {
|
|
41
41
|
return Object.entries(record).filter(([field, value]) => !NON_DATA_FIELDS.has(field) && value !== undefined && value !== null && value !== "").length;
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* The subset of a record worth keeping as a merge-recovery artifact: its id (to
|
|
45
|
+
* reference) plus every populated data field, dropping bulky/plumbing fields
|
|
46
|
+
* (raw, identities, provenance) that aren't needed to recreate it by hand.
|
|
47
|
+
*/
|
|
48
|
+
export function recoverableFields(record) {
|
|
49
|
+
const out = { id: String(record.id) };
|
|
50
|
+
for (const [field, value] of Object.entries(record)) {
|
|
51
|
+
if (NON_DATA_FIELDS.has(field))
|
|
52
|
+
continue;
|
|
53
|
+
if (value === undefined || value === null || value === "")
|
|
54
|
+
continue;
|
|
55
|
+
out[field] = value;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
43
59
|
/** True when id `a` sorts before id `b` — numeric when both ids are numeric. */
|
|
44
60
|
function idBefore(a, b) {
|
|
45
61
|
const numericA = Number(a);
|
|
@@ -102,6 +118,12 @@ export function buildDedupePlan(snapshot, options) {
|
|
|
102
118
|
const groupIds = members
|
|
103
119
|
.map((member) => String(member.id))
|
|
104
120
|
.sort((a, b) => (idBefore(a, b) ? -1 : 1));
|
|
121
|
+
// Recovery artifact: the records that will be merged away (everyone but the
|
|
122
|
+
// survivor), captured with their field values so a human can recreate one by
|
|
123
|
+
// hand if the merge was wrong. Merges are irreversible — the plan is the backup.
|
|
124
|
+
const recoverySnapshot = members
|
|
125
|
+
.filter((member) => String(member.id) !== String(survivor.id))
|
|
126
|
+
.map((member) => recoverableFields(member));
|
|
105
127
|
const survivorName = typeof survivor.name === "string" && survivor.name
|
|
106
128
|
? survivor.name
|
|
107
129
|
: typeof survivor.email === "string" && survivor.email
|
|
@@ -124,7 +146,8 @@ export function buildDedupePlan(snapshot, options) {
|
|
|
124
146
|
approvalRequired: true,
|
|
125
147
|
sourceRuleOrPolicy: "dedupe",
|
|
126
148
|
groupId: `grp_${options.objectType}_${String(survivor.id)}`,
|
|
127
|
-
|
|
149
|
+
recoverySnapshot,
|
|
150
|
+
rollback: "IRREVERSIBLE: provider merges cannot be unmerged. recoverySnapshot on this operation retains every merged-away record's field values; recreate a record manually from it if a merge was wrong.",
|
|
128
151
|
});
|
|
129
152
|
}
|
|
130
153
|
return {
|
package/dist/enrich.js
CHANGED
|
@@ -291,6 +291,28 @@ function valueToString(value) {
|
|
|
291
291
|
return String(value);
|
|
292
292
|
return "";
|
|
293
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* CSV/formula-injection neutralization for string values destined for a CRM
|
|
296
|
+
* write. Third-party export rows (Clay CSV, webhook JSON) can contain cells
|
|
297
|
+
* like `=cmd|'/c calc'!A1` or `@SUM(...)`; written verbatim to a CRM field they
|
|
298
|
+
* lie dormant until someone exports the CRM to CSV and opens it in a spreadsheet,
|
|
299
|
+
* where the leading `= + - @` (or a leading tab/CR) makes the client execute it.
|
|
300
|
+
* We prefix a single apostrophe — the spreadsheet-standard escape that renders
|
|
301
|
+
* the cell as literal text. Numeric values bypass this (they're written as
|
|
302
|
+
* numbers, not strings), so signed numbers keep full fidelity; a phone number
|
|
303
|
+
* supplied as a string and starting with `+` gains a leading `'`, which the
|
|
304
|
+
* human sees in the approved diff. Applied only at the write path, never to
|
|
305
|
+
* match keys.
|
|
306
|
+
*/
|
|
307
|
+
function neutralizeFormulaInjection(value) {
|
|
308
|
+
if (value && /^[=+\-@\t\r]/.test(value))
|
|
309
|
+
return `'${value}`;
|
|
310
|
+
return value;
|
|
311
|
+
}
|
|
312
|
+
/** valueToString for a value that will be written to a CRM field. */
|
|
313
|
+
function writeSafeString(value) {
|
|
314
|
+
return neutralizeFormulaInjection(valueToString(value));
|
|
315
|
+
}
|
|
294
316
|
function normalizeKeyValue(key, value) {
|
|
295
317
|
const text = valueToString(value).toLowerCase();
|
|
296
318
|
if (!text)
|
|
@@ -498,7 +520,7 @@ export function buildEnrichPlan(options) {
|
|
|
498
520
|
operation: "set_field",
|
|
499
521
|
field: canonicalField,
|
|
500
522
|
beforeValue: currentValue ?? null,
|
|
501
|
-
afterValue: typeof sourceValue === "number" ? sourceValue :
|
|
523
|
+
afterValue: typeof sourceValue === "number" ? sourceValue : writeSafeString(sourceValue),
|
|
502
524
|
reason: `${source} ${record.objectType} "${describeSourceRecord(record)}" (matched by ` +
|
|
503
525
|
`${outcome.matchedKey}) reports a changed value for ${canonicalField}.`,
|
|
504
526
|
sourceRuleOrPolicy: `enrich:${source}:${canonicalField}`,
|
|
@@ -516,7 +538,7 @@ export function buildEnrichPlan(options) {
|
|
|
516
538
|
if (!isEmptyValue(currentValue))
|
|
517
539
|
continue;
|
|
518
540
|
emittedForRecord = true;
|
|
519
|
-
const afterValue = typeof sourceValue === "number" ? sourceValue :
|
|
541
|
+
const afterValue = typeof sourceValue === "number" ? sourceValue : writeSafeString(sourceValue);
|
|
520
542
|
operations.push({
|
|
521
543
|
id: `op_enr_${fnv1a(`${source}:${record.objectType}:${outcome.recordId}:${canonicalField}`)}`,
|
|
522
544
|
objectType: canonicalObjectType(record.objectType),
|
package/dist/enrichApollo.js
CHANGED
|
@@ -56,9 +56,12 @@ export function createApolloClient(options) {
|
|
|
56
56
|
if (response.status === 404)
|
|
57
57
|
return null;
|
|
58
58
|
if (!response.ok) {
|
|
59
|
-
|
|
59
|
+
// Status line only — never interpolate the response body. It can echo
|
|
60
|
+
// the submitted query (contact emails / company domains) or the API key,
|
|
61
|
+
// and these errors are persisted verbatim into scheduled-run records.
|
|
62
|
+
await response.text().catch(() => undefined);
|
|
60
63
|
const exhausted = response.status === 429 ? ` (rate limited; ${maxRetries} retries exhausted)` : "";
|
|
61
|
-
throw new Error(`Apollo API error ${response.status}${exhausted}
|
|
64
|
+
throw new Error(`Apollo API error ${response.status}${exhausted}. Check the API key and request.`);
|
|
62
65
|
}
|
|
63
66
|
const text = await response.text();
|
|
64
67
|
return text ? JSON.parse(text) : null;
|
package/dist/index.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient,
|
|
|
16
16
|
export { diffFindings, diffSnapshots, diffToMarkdown, type CollectionDiff, type FieldChange, type FindingsDrift, type RecordChange, type SnapshotDiff, } from "./diff.ts";
|
|
17
17
|
export { mergeSnapshots, type MergeConflict, type MergeMatch, type MergeReport, type MergeSuggestion, } from "./merge.ts";
|
|
18
18
|
export { createFilePlanStore, type PlanStore, type StoredPlan } from "./planStore.ts";
|
|
19
|
+
export { computeApprovalDigests, loadOrCreateSigningKey, loadSigningKey, signApproval, verifyApprovalDigests, type ApprovalVerification, } from "./integrity.ts";
|
|
19
20
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
20
21
|
export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
21
22
|
export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, type CrmObjectType, type FieldMappings, } from "./mappings.ts";
|
package/dist/index.js
CHANGED
|
@@ -16,6 +16,7 @@ export { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient,
|
|
|
16
16
|
export { diffFindings, diffSnapshots, diffToMarkdown, } from "./diff.js";
|
|
17
17
|
export { mergeSnapshots, } from "./merge.js";
|
|
18
18
|
export { createFilePlanStore } from "./planStore.js";
|
|
19
|
+
export { computeApprovalDigests, loadOrCreateSigningKey, loadSigningKey, signApproval, verifyApprovalDigests, } from "./integrity.js";
|
|
19
20
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
|
|
20
21
|
export { auditReportToHtml, auditReportToMarkdown } from "./report.js";
|
|
21
22
|
export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, } from "./mappings.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PatchOperation } from "./types.ts";
|
|
2
|
+
/** Read the signing key, or null if it has not been created yet. */
|
|
3
|
+
export declare function loadSigningKey(): Buffer | null;
|
|
4
|
+
/** Read the signing key, creating a fresh 32-byte one (0600) on first use. */
|
|
5
|
+
export declare function loadOrCreateSigningKey(): Buffer;
|
|
6
|
+
/** HMAC-SHA256 signature of one operation's approved content. */
|
|
7
|
+
export declare function signApproval(operation: PatchOperation, override: unknown, key: Buffer): string;
|
|
8
|
+
/**
|
|
9
|
+
* Compute the approval signature map for a set of approved operation ids,
|
|
10
|
+
* resolving each op from the plan and its (approved) value override.
|
|
11
|
+
*/
|
|
12
|
+
export declare function computeApprovalDigests(operations: PatchOperation[], approvedOperationIds: string[], valueOverrides: Record<string, unknown>, key: Buffer): Record<string, string>;
|
|
13
|
+
export type ApprovalVerification = {
|
|
14
|
+
ok: true;
|
|
15
|
+
} | {
|
|
16
|
+
ok: false;
|
|
17
|
+
reason: "no_key";
|
|
18
|
+
tampered: string[];
|
|
19
|
+
} | {
|
|
20
|
+
ok: false;
|
|
21
|
+
reason: "mismatch";
|
|
22
|
+
tampered: string[];
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Verify that every approved operation still matches what was signed. Returns
|
|
26
|
+
* ok:true when there are no stored digests (a pre-integrity plan — nothing to
|
|
27
|
+
* verify), when all match, or fails with the list of operation ids whose
|
|
28
|
+
* content changed since approval.
|
|
29
|
+
*/
|
|
30
|
+
export declare function verifyApprovalDigests(operations: PatchOperation[], approvedOperationIds: string[], valueOverrides: Record<string, unknown>, storedDigests: Record<string, string> | undefined): ApprovalVerification;
|