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/dist/schedule.d.ts
CHANGED
|
@@ -53,6 +53,23 @@ export declare function scheduleId(label: string, cron: string, argv: string[],
|
|
|
53
53
|
* in-process into the CLI router, never through a shell).
|
|
54
54
|
*/
|
|
55
55
|
export declare function validateSchedulableArgv(argv: string[]): void;
|
|
56
|
+
/**
|
|
57
|
+
* A schedule label is free text the operator chooses, but it is later
|
|
58
|
+
* interpolated into a crontab comment line by `renderManagedBlock`. A newline
|
|
59
|
+
* (or carriage return) would break out of the comment and inject an arbitrary
|
|
60
|
+
* crontab entry on `schedule install`. Reject control characters at the entry
|
|
61
|
+
* point so a label can never carry a second line; `renderManagedBlock` also
|
|
62
|
+
* strips them defensively in case a hand-edited schedules.json slips one past.
|
|
63
|
+
*/
|
|
64
|
+
export declare function assertSingleLineLabel(label: string): void;
|
|
65
|
+
/**
|
|
66
|
+
* True if the string contains any line-breaking or control character. Covers
|
|
67
|
+
* C0 controls + DEL, plus the Unicode separators a non-cron parser might honor
|
|
68
|
+
* (NEL U+0085, LS U+2028, PS U+2029, VT U+000B, FF U+000C) — defense-in-depth
|
|
69
|
+
* for the future modal/aws scaffold renderers whose target formats may treat
|
|
70
|
+
* those as line breaks.
|
|
71
|
+
*/
|
|
72
|
+
export declare function hasControlChar(value: string): boolean;
|
|
56
73
|
/**
|
|
57
74
|
* Split a `schedule add "<command>"` string into argv, honoring single and
|
|
58
75
|
* double quotes (no escapes, no expansion — this is tokenization, not shell).
|
package/dist/schedule.js
CHANGED
|
@@ -61,6 +61,10 @@ export function validateSchedulableArgv(argv) {
|
|
|
61
61
|
throw new Error("A scheduled apply cannot take --plan/--approve — file-based approval would bypass the " +
|
|
62
62
|
"plan store's approval state. Use `apply --plan-id <id>` and approve via `plans approve`.");
|
|
63
63
|
}
|
|
64
|
+
if (argv.includes("--value")) {
|
|
65
|
+
throw new Error("A scheduled apply cannot take --value — an unattended run must write exactly the values " +
|
|
66
|
+
"signed at approval. Set the value with `plans approve --value <op>=<v>` and re-approve.");
|
|
67
|
+
}
|
|
64
68
|
return;
|
|
65
69
|
}
|
|
66
70
|
if (!Object.hasOwn(SCHEDULABLE, head)) {
|
|
@@ -77,6 +81,68 @@ export function validateSchedulableArgv(argv) {
|
|
|
77
81
|
}
|
|
78
82
|
}
|
|
79
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* A schedule label is free text the operator chooses, but it is later
|
|
86
|
+
* interpolated into a crontab comment line by `renderManagedBlock`. A newline
|
|
87
|
+
* (or carriage return) would break out of the comment and inject an arbitrary
|
|
88
|
+
* crontab entry on `schedule install`. Reject control characters at the entry
|
|
89
|
+
* point so a label can never carry a second line; `renderManagedBlock` also
|
|
90
|
+
* strips them defensively in case a hand-edited schedules.json slips one past.
|
|
91
|
+
*/
|
|
92
|
+
export function assertSingleLineLabel(label) {
|
|
93
|
+
if (hasControlChar(label)) {
|
|
94
|
+
throw new Error("A schedule --label cannot contain newlines or control characters " +
|
|
95
|
+
"(they would inject lines into the managed crontab block). Use a plain single-line name.");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* True if the string contains any line-breaking or control character. Covers
|
|
100
|
+
* C0 controls + DEL, plus the Unicode separators a non-cron parser might honor
|
|
101
|
+
* (NEL U+0085, LS U+2028, PS U+2029, VT U+000B, FF U+000C) — defense-in-depth
|
|
102
|
+
* for the future modal/aws scaffold renderers whose target formats may treat
|
|
103
|
+
* those as line breaks.
|
|
104
|
+
*/
|
|
105
|
+
export function hasControlChar(value) {
|
|
106
|
+
for (let i = 0; i < value.length; i++) {
|
|
107
|
+
const code = value.charCodeAt(i);
|
|
108
|
+
if (code < 0x20 || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029)
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
/** Collapse any control/separator character to a space — last-resort guard at render time. */
|
|
114
|
+
function sanitizeCrontabComment(value) {
|
|
115
|
+
let out = "";
|
|
116
|
+
for (const ch of value) {
|
|
117
|
+
const code = ch.charCodeAt(0);
|
|
118
|
+
out += code < 0x20 || code === 0x7f || code === 0x85 || code === 0x2028 || code === 0x2029 ? " " : ch;
|
|
119
|
+
}
|
|
120
|
+
return out.replace(/ {2,}/g, " ").trim();
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Validate every field of an entry that `renderManagedBlock` interpolates into
|
|
124
|
+
* the crontab — not just the label. The EXECUTABLE line embeds `cron` and `id`
|
|
125
|
+
* raw, and `schedule install` renders entries straight from schedules.json, so
|
|
126
|
+
* a hand-edited (or otherwise tampered) entry with a newline in cron/id/profile
|
|
127
|
+
* would inject a live crontab line. Refuse to render a tampered entry rather
|
|
128
|
+
* than emit it. (Well-formed entries never trip this: cron is parser-validated,
|
|
129
|
+
* id is an fnv1a hex hash, label is guarded at add-time.)
|
|
130
|
+
*/
|
|
131
|
+
function assertRenderableEntry(profile, entry) {
|
|
132
|
+
const fields = [
|
|
133
|
+
["profile", profile],
|
|
134
|
+
["cron", entry.cron],
|
|
135
|
+
["id", entry.id],
|
|
136
|
+
["label", entry.label],
|
|
137
|
+
...entry.argv.map((token, i) => [`argv[${i}]`, token]),
|
|
138
|
+
];
|
|
139
|
+
for (const [name, value] of fields) {
|
|
140
|
+
if (hasControlChar(value)) {
|
|
141
|
+
throw new Error(`Refusing to render schedule entry ${entry.id}: its ${name} contains a newline or control character. ` +
|
|
142
|
+
"The schedules.json store has been tampered with or corrupted — repair it before installing.");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
80
146
|
/**
|
|
81
147
|
* Split a `schedule add "<command>"` string into argv, honoring single and
|
|
82
148
|
* double quotes (no escapes, no expansion — this is tokenization, not shell).
|
|
@@ -124,7 +190,13 @@ const CRON_FIELD_SPECS = [
|
|
|
124
190
|
{ name: "day-of-week", min: 0, max: 7 },
|
|
125
191
|
];
|
|
126
192
|
export function parseCron(expression) {
|
|
127
|
-
|
|
193
|
+
// Reject non-ASCII whitespace and control chars: JS \s splits on U+00A0,
|
|
194
|
+
// U+3000, etc., but Vixie cron's field separator is only space/tab. A source
|
|
195
|
+
// carrying them would parse here yet be misparsed or rejected by `crontab -`.
|
|
196
|
+
if (hasControlChar(expression) || /[^\x20-\x7e]/.test(expression)) {
|
|
197
|
+
throw new Error(`Invalid cron expression "${expression}": only ASCII characters, space, and tab are allowed.`);
|
|
198
|
+
}
|
|
199
|
+
const fields = expression.trim().split(/[ \t]+/);
|
|
128
200
|
if (fields.length !== 5) {
|
|
129
201
|
throw new Error(`Invalid cron expression "${expression}": expected 5 fields ` +
|
|
130
202
|
`(minute hour day-of-month month day-of-week), got ${fields.length}.`);
|
|
@@ -432,13 +504,26 @@ export function crontabSentinels(profile) {
|
|
|
432
504
|
* but fullstackgtm dispatch (no arbitrary shell, ever).
|
|
433
505
|
*/
|
|
434
506
|
export function renderManagedBlock(profile, entries, cliInvocation) {
|
|
507
|
+
// cliInvocation is spliced raw into the executable line; it is built from
|
|
508
|
+
// process.execPath, the script path, and FSGTM_HOME (cli.ts), so a newline in
|
|
509
|
+
// FSGTM_HOME would inject a crontab line. Validate it like the entry fields —
|
|
510
|
+
// single-quote shell-escaping does NOT defend cron's line parser.
|
|
511
|
+
if (hasControlChar(cliInvocation)) {
|
|
512
|
+
throw new Error("Refusing to render the managed crontab: the resolved CLI invocation (node path, script path, " +
|
|
513
|
+
"or FSGTM_HOME) contains a newline or control character. Check $FSGTM_HOME.");
|
|
514
|
+
}
|
|
435
515
|
const { open, close } = crontabSentinels(profile);
|
|
436
516
|
const lines = [
|
|
437
517
|
open,
|
|
438
518
|
"# Managed by `fullstackgtm schedule install` — replaced wholesale on re-install; do not edit.",
|
|
439
519
|
];
|
|
440
520
|
for (const entry of entries) {
|
|
441
|
-
|
|
521
|
+
// Refuse to render any entry whose interpolated fields carry a control char
|
|
522
|
+
// — the executable line below embeds cron/id raw, so a tampered store could
|
|
523
|
+
// otherwise inject a live crontab line. The comment line is additionally
|
|
524
|
+
// sanitized so a benign-but-messy label can't break it.
|
|
525
|
+
assertRenderableEntry(profile, entry);
|
|
526
|
+
lines.push(sanitizeCrontabComment(`# ${entry.label} (${entry.id}): ${entry.argv.join(" ")}`));
|
|
442
527
|
lines.push(`${entry.cron} ${cliInvocation} schedule run ${entry.id} --profile ${profile} --trigger cron`);
|
|
443
528
|
}
|
|
444
529
|
lines.push(close);
|
package/dist/types.d.ts
CHANGED
|
@@ -239,6 +239,22 @@ export type PatchOperation = {
|
|
|
239
239
|
* member of the group.
|
|
240
240
|
*/
|
|
241
241
|
groupId?: string;
|
|
242
|
+
/**
|
|
243
|
+
* Set only when a human explicitly chose to archive a record that shares an
|
|
244
|
+
* identity key with another (`bulk-update --archive --force-archive-duplicates`).
|
|
245
|
+
* Without it, apply refuses to archive_record a record the live snapshot still
|
|
246
|
+
* sees as a duplicate — archiving a duplicate discards data that merging keeps,
|
|
247
|
+
* and an agent on a dedupe task must not silently substitute archive for merge.
|
|
248
|
+
*/
|
|
249
|
+
forceArchiveDuplicate?: boolean;
|
|
250
|
+
/**
|
|
251
|
+
* For irreversible operations (merge_records, archive_record): the field
|
|
252
|
+
* values of the records that will be destroyed, captured at plan-build time.
|
|
253
|
+
* Merges and archives cannot be undone on any provider, so this is the
|
|
254
|
+
* recovery artifact a human uses to recreate a record by hand if a merge or
|
|
255
|
+
* archive was wrong — the plan file IS the backup.
|
|
256
|
+
*/
|
|
257
|
+
recoverySnapshot?: Record<string, unknown>[];
|
|
242
258
|
};
|
|
243
259
|
/**
|
|
244
260
|
* A patch plan is always a dry-run proposal. Applying a plan never mutates
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
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",
|
package/src/bulkUpdate.ts
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.ts";
|
|
30
31
|
import { normalizeDomain } from "./merge.ts";
|
|
31
32
|
import { stableHash } from "./rules.ts";
|
|
32
33
|
import type {
|
|
@@ -399,7 +400,11 @@ export function buildBulkUpdatePlan(
|
|
|
399
400
|
beforeValue: null,
|
|
400
401
|
afterValue: null,
|
|
401
402
|
riskLevel: "high",
|
|
402
|
-
|
|
403
|
+
// Carry the human's explicit force decision to the apply-time guard, and
|
|
404
|
+
// snapshot the record so it can be recreated if the archive was wrong.
|
|
405
|
+
...(options.forceArchiveDuplicates ? { forceArchiveDuplicate: true } : {}),
|
|
406
|
+
recoverySnapshot: [recoverableFields(record)],
|
|
407
|
+
rollback: "Archived records can be restored from the provider's recycle bin within its retention window; recoverySnapshot also retains the field values.",
|
|
403
408
|
});
|
|
404
409
|
continue;
|
|
405
410
|
}
|
package/src/cli.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
import { generateDemoSnapshot } from "./demo.ts";
|
|
35
35
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
36
36
|
import { mergeSnapshots } from "./merge.ts";
|
|
37
|
+
import { verifyApprovalDigests } from "./integrity.ts";
|
|
37
38
|
import { createFilePlanStore } from "./planStore.ts";
|
|
38
39
|
import { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
39
40
|
import { builtinAuditRules } from "./rules.ts";
|
|
@@ -60,6 +61,7 @@ import {
|
|
|
60
61
|
type CallDocument,
|
|
61
62
|
} from "./marketOverlay.ts";
|
|
62
63
|
import { computeScaleIndex, scaleReportToText } from "./marketScale.ts";
|
|
64
|
+
import { suggestMarketConfig } from "./marketTaxonomy.ts";
|
|
63
65
|
import { buildWorksheet, classifyMarket } from "./marketClassify.ts";
|
|
64
66
|
import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
|
|
65
67
|
import {
|
|
@@ -109,6 +111,8 @@ import {
|
|
|
109
111
|
parseCron,
|
|
110
112
|
renderManagedBlock,
|
|
111
113
|
replaceManagedBlock,
|
|
114
|
+
assertSingleLineLabel,
|
|
115
|
+
hasControlChar,
|
|
112
116
|
scheduleId,
|
|
113
117
|
systemCrontabIo,
|
|
114
118
|
tokenizeCommand,
|
|
@@ -974,6 +978,8 @@ async function marketCommand(args: string[]) {
|
|
|
974
978
|
if (!subcommand || subcommand === "--help" || subcommand === "-h" || rest.includes("--help") || rest.includes("-h")) {
|
|
975
979
|
console.log(`Usage:
|
|
976
980
|
market init --category <name> [--out <path>] write a starter market.config.json
|
|
981
|
+
market init --category <name> --auto --vendor <url> [--vendor <url>...] [--anchor <url>] [--max-claims n]
|
|
982
|
+
LLM-propose vendors + claim taxonomy from seed pages (needs an API key)
|
|
977
983
|
market capture [--config <path>] [--run <label>]
|
|
978
984
|
market classify [--run <label>] [--capture-run <label>] [--vendor <id>] [--model m] [--out <path>]
|
|
979
985
|
market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
|
|
@@ -1023,6 +1029,31 @@ recomputed deterministically on every invocation — never stored.`);
|
|
|
1023
1029
|
if (!category) throw new Error("market init requires --category <name>");
|
|
1024
1030
|
const outPath = resolve(process.cwd(), option(rest, "--out") ?? "market.config.json");
|
|
1025
1031
|
if (existsSync(outPath)) throw new Error(`${outPath} already exists — refusing to overwrite`);
|
|
1032
|
+
|
|
1033
|
+
if (rest.includes("--auto")) {
|
|
1034
|
+
const vendorUrls = repeatedOption(rest, "--vendor");
|
|
1035
|
+
if (vendorUrls.length === 0) {
|
|
1036
|
+
throw new Error("market init --auto requires at least one --vendor <url> (the competitor homepages to seed from)");
|
|
1037
|
+
}
|
|
1038
|
+
const anchorUrl = option(rest, "--anchor");
|
|
1039
|
+
const credential = await requireLlmCredential("market classify");
|
|
1040
|
+
console.error(`Capturing ${vendorUrls.length} seed page(s) and proposing a claim taxonomy with ${credential.provider}…`);
|
|
1041
|
+
const { config, unreadableVendorIds, model } = await suggestMarketConfig({
|
|
1042
|
+
category,
|
|
1043
|
+
vendors: vendorUrls.map((url) => ({ url, anchor: anchorUrl ? url === anchorUrl : false })),
|
|
1044
|
+
llm: { ...credential, model: option(rest, "--model") ?? undefined },
|
|
1045
|
+
maxClaims: numericOption(rest, "--max-claims"),
|
|
1046
|
+
});
|
|
1047
|
+
writeFileSync(outPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
1048
|
+
if (unreadableVendorIds.length > 0) {
|
|
1049
|
+
console.error(`Note: no readable text for ${unreadableVendorIds.join(", ")} — excluded from taxonomy grounding.`);
|
|
1050
|
+
}
|
|
1051
|
+
console.log(
|
|
1052
|
+
`Wrote ${outPath}: ${config.vendors.length} vendors, ${config.claims.length} proposed claims (${model}). Review it, then: fullstackgtm market refresh`,
|
|
1053
|
+
);
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1026
1057
|
writeFileSync(outPath, `${JSON.stringify(starterMarketConfig(category), null, 2)}\n`);
|
|
1027
1058
|
console.log(`Wrote ${outPath}. Fill in vendors and claims, then: fullstackgtm market capture`);
|
|
1028
1059
|
return;
|
|
@@ -1831,6 +1862,7 @@ trigger: manual. status shows next firing and surfaces missed firings
|
|
|
1831
1862
|
const label =
|
|
1832
1863
|
option(rest, "--label") ??
|
|
1833
1864
|
argv.filter((arg) => !arg.startsWith("--")).slice(0, 2).join("-").replace(/[^\w.-]+/g, "-");
|
|
1865
|
+
assertSingleLineLabel(label);
|
|
1834
1866
|
const entry: ScheduleEntry = {
|
|
1835
1867
|
id: scheduleId(label, cron.source, argv, createdAt),
|
|
1836
1868
|
label,
|
|
@@ -2052,12 +2084,26 @@ function scheduleCliInvocation(): string {
|
|
|
2052
2084
|
if (!script || !existsSync(script)) {
|
|
2053
2085
|
throw new Error("Cannot resolve the fullstackgtm entry point for crontab lines (process.argv[1] is missing).");
|
|
2054
2086
|
}
|
|
2087
|
+
// A newline/control char in any of these flows verbatim into the crontab
|
|
2088
|
+
// executable line; single-quote escaping defends the shell, not cron's line
|
|
2089
|
+
// parser. Refuse early with a clear message (renderManagedBlock re-checks).
|
|
2090
|
+
for (const [name, value] of [
|
|
2091
|
+
["FSGTM_HOME", process.env.FSGTM_HOME],
|
|
2092
|
+
["the node executable path", process.execPath],
|
|
2093
|
+
["the CLI script path", script],
|
|
2094
|
+
] as const) {
|
|
2095
|
+
if (value && hasControlChar(value)) {
|
|
2096
|
+
throw new Error(`Cannot install schedules: ${name} contains a newline or control character.`);
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2055
2099
|
const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
2056
2100
|
const parts = [quote(process.execPath)];
|
|
2057
2101
|
if (script.endsWith(".ts")) parts.push("--experimental-strip-types");
|
|
2058
2102
|
parts.push(quote(script));
|
|
2059
2103
|
const home = process.env.FSGTM_HOME ? `FSGTM_HOME=${quote(process.env.FSGTM_HOME)} ` : "";
|
|
2060
|
-
|
|
2104
|
+
// cron treats an unescaped `%` in the command field as a newline/stdin split.
|
|
2105
|
+
// Escape it as `\%` so a stray `%` in a path can't truncate the managed line.
|
|
2106
|
+
return (home + parts.join(" ")).replace(/%/g, "\\%");
|
|
2061
2107
|
}
|
|
2062
2108
|
|
|
2063
2109
|
/**
|
|
@@ -2535,7 +2581,40 @@ async function apply(args: string[]) {
|
|
|
2535
2581
|
}
|
|
2536
2582
|
plan = stored.plan;
|
|
2537
2583
|
approvedOperationIds = stored.approvedOperationIds;
|
|
2584
|
+
// Downgrade guard: an approved plan with no signatures is either pre-0.26
|
|
2585
|
+
// (re-approve to gain them) or had its approvalDigests stripped to skip the
|
|
2586
|
+
// integrity check. Either way, refuse rather than fall back to trusting the
|
|
2587
|
+
// file. (A plan with zero approved operations has nothing to apply anyway.)
|
|
2588
|
+
if (stored.approvedOperationIds.length > 0 && !stored.approvalDigests) {
|
|
2589
|
+
throw new Error(
|
|
2590
|
+
`Refusing to apply plan ${planId}: it was approved without integrity signatures ` +
|
|
2591
|
+
"(approved before 0.26.0, or its signatures were removed). Re-approve it with " +
|
|
2592
|
+
`\`fullstackgtm plans approve ${planId} --operations <ids|all>\`.`,
|
|
2593
|
+
);
|
|
2594
|
+
}
|
|
2595
|
+
// Integrity gate: the plan file is re-read from disk, so verify each approved
|
|
2596
|
+
// operation still matches what was signed at approval. Verify against the
|
|
2597
|
+
// EFFECTIVE overrides (stored ∪ apply-time --value): the invariant is "what
|
|
2598
|
+
// gets written must equal what was signed", so an apply-time --value that
|
|
2599
|
+
// changes a value the human did not approve is treated as tamper, not a live
|
|
2600
|
+
// override. A mismatch means the plan/overrides were edited after approval —
|
|
2601
|
+
// refuse the whole apply rather than write an unapproved value.
|
|
2538
2602
|
valueOverrides = { ...stored.valueOverrides, ...parseValueOverrides(args) };
|
|
2603
|
+
const verification = verifyApprovalDigests(
|
|
2604
|
+
stored.plan.operations,
|
|
2605
|
+
stored.approvedOperationIds,
|
|
2606
|
+
valueOverrides,
|
|
2607
|
+
stored.approvalDigests,
|
|
2608
|
+
);
|
|
2609
|
+
if (!verification.ok) {
|
|
2610
|
+
const detail =
|
|
2611
|
+
verification.reason === "no_key"
|
|
2612
|
+
? "the plan-signing key is missing (was this plan approved on another machine?). Re-approve it here with `fullstackgtm plans approve`."
|
|
2613
|
+
: `these operations differ from what was approved: ${verification.tampered.join(", ")}. ` +
|
|
2614
|
+
"If you changed a value at apply time, set it at approval instead (`plans approve --value <op>=<v>`) and re-approve; " +
|
|
2615
|
+
"otherwise the plan was edited after approval — review and re-approve.";
|
|
2616
|
+
throw new Error(`Refusing to apply plan ${planId}: ${detail}`);
|
|
2617
|
+
}
|
|
2539
2618
|
} else {
|
|
2540
2619
|
const approve = option(args, "--approve");
|
|
2541
2620
|
if (!approve) {
|
package/src/connector.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { dedupeKey } from "./dedupe.ts";
|
|
1
2
|
import { requiresHumanInput } from "./rules.ts";
|
|
2
3
|
import type {
|
|
4
|
+
CanonicalGtmSnapshot,
|
|
3
5
|
GtmConnector,
|
|
4
6
|
PatchOperation,
|
|
5
7
|
PatchOperationResult,
|
|
@@ -8,6 +10,75 @@ import type {
|
|
|
8
10
|
PatchPlanRunStatus,
|
|
9
11
|
} from "./types.ts";
|
|
10
12
|
|
|
13
|
+
const IRREVERSIBLE_OPERATIONS = new Set(["merge_records", "archive_record"]);
|
|
14
|
+
const IDENTITY_KEY_BY_TYPE: Partial<Record<string, "domain" | "email">> = {
|
|
15
|
+
account: "domain",
|
|
16
|
+
contact: "email",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** snapshot collection for an object type */
|
|
20
|
+
function collectionFor(objectType: string): "accounts" | "contacts" | "deals" | null {
|
|
21
|
+
if (objectType === "account") return "accounts";
|
|
22
|
+
if (objectType === "contact") return "contacts";
|
|
23
|
+
if (objectType === "deal") return "deals";
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Drift/safety check for the two IRREVERSIBLE operations against a fresh
|
|
29
|
+
* snapshot. Returns a conflict detail string, or null if the op is safe to
|
|
30
|
+
* apply. These operations get NO field compare-and-set (there is no single
|
|
31
|
+
* field to compare), so this snapshot check is their only guard.
|
|
32
|
+
*/
|
|
33
|
+
function checkIrreversibleOp(operation: PatchOperation, snapshot: CanonicalGtmSnapshot): string | null {
|
|
34
|
+
const collection = collectionFor(operation.objectType);
|
|
35
|
+
if (!collection) return null;
|
|
36
|
+
const records = snapshot[collection] as Array<Record<string, unknown>>;
|
|
37
|
+
const byId = (id: string) => records.find((record) => String(record.id) === id);
|
|
38
|
+
|
|
39
|
+
if (operation.operation === "archive_record") {
|
|
40
|
+
if (!byId(operation.objectId)) {
|
|
41
|
+
return `Record ${operation.objectType}/${operation.objectId} no longer exists (already archived or merged). Re-plan against current data.`;
|
|
42
|
+
}
|
|
43
|
+
// Archiving a duplicate discards data a merge would keep — refuse unless the
|
|
44
|
+
// human explicitly forced it. This catches every archive_record path (agent,
|
|
45
|
+
// hand-edited plan, audit), not just `bulk-update --archive`.
|
|
46
|
+
if (!operation.forceArchiveDuplicate) {
|
|
47
|
+
const keyName = IDENTITY_KEY_BY_TYPE[operation.objectType];
|
|
48
|
+
if (keyName) {
|
|
49
|
+
const target = byId(operation.objectId)!;
|
|
50
|
+
const key = dedupeKey(target, keyName);
|
|
51
|
+
if (key) {
|
|
52
|
+
const sharers = records.filter(
|
|
53
|
+
(record) => String(record.id) !== operation.objectId && dedupeKey(record, keyName) === key,
|
|
54
|
+
);
|
|
55
|
+
if (sharers.length > 0) {
|
|
56
|
+
return (
|
|
57
|
+
`Refusing to archive ${operation.objectType}/${operation.objectId}: it shares ${keyName} "${key}" with ` +
|
|
58
|
+
`${sharers.length} other record(s) — that's a duplicate, and archiving discards its data where merging keeps it. ` +
|
|
59
|
+
`Merge with \`fullstackgtm dedupe ${operation.objectType} --key ${keyName}\` instead, or rebuild the op with --force-archive-duplicates.`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (operation.operation === "merge_records") {
|
|
69
|
+
if (!byId(operation.objectId)) {
|
|
70
|
+
return `Merge survivor ${operation.objectType}/${operation.objectId} no longer exists (archived or merged away since the plan was built). Re-plan — merges are irreversible.`;
|
|
71
|
+
}
|
|
72
|
+
const groupIds = Array.isArray(operation.beforeValue) ? (operation.beforeValue as unknown[]).map(String) : [];
|
|
73
|
+
const losersStillPresent = groupIds.filter((id) => id !== operation.objectId && byId(id));
|
|
74
|
+
if (groupIds.length > 0 && losersStillPresent.length === 0) {
|
|
75
|
+
return `Every record to merge into ${operation.objectType}/${operation.objectId} is already gone (merge already applied?). Nothing to do — re-plan if duplicates remain.`;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
11
82
|
export type ApplyPatchPlanOptions = {
|
|
12
83
|
/**
|
|
13
84
|
* Explicit allow-list of operation ids the human approved. Operations not
|
|
@@ -79,10 +150,20 @@ export async function applyPatchPlan(
|
|
|
79
150
|
// closed — but it can be shrunk: re-run the snapshot checks after the
|
|
80
151
|
// first write and every `recheckEvery` writes, conflicting out any
|
|
81
152
|
// operation whose record went stale mid-run.
|
|
153
|
+
// Irreversible ops (merge/archive) need a fresh snapshot too — it is their
|
|
154
|
+
// only drift/safety guard (no field to compare-and-set). Respect a caller's
|
|
155
|
+
// explicit checkConflicts:false opt-out (a stub/known-stale snapshot).
|
|
156
|
+
const hasIrreversibleApproved =
|
|
157
|
+
checkConflicts &&
|
|
158
|
+
plan.operations.some(
|
|
159
|
+
(operation) => approved.has(operation.id) && IRREVERSIBLE_OPERATIONS.has(operation.operation),
|
|
160
|
+
);
|
|
82
161
|
const needsSnapshot =
|
|
83
|
-
((plan.guards && plan.guards.length > 0) || plan.filter) &&
|
|
162
|
+
((plan.guards && plan.guards.length > 0) || plan.filter || hasIrreversibleApproved) &&
|
|
163
|
+
connector.fetchSnapshot;
|
|
84
164
|
const recheckEvery = Math.max(1, options.recheckEvery ?? 25);
|
|
85
165
|
const staleIds = new Set<string>();
|
|
166
|
+
const irreversibleStale = new Map<string, string>();
|
|
86
167
|
let guardFailure: string | null = null;
|
|
87
168
|
const refreshSnapshotChecks = async (): Promise<void> => {
|
|
88
169
|
if (!needsSnapshot) return;
|
|
@@ -95,6 +176,14 @@ export async function applyPatchPlan(
|
|
|
95
176
|
if (!stillEligible.has(operation.objectId)) staleIds.add(operation.objectId);
|
|
96
177
|
}
|
|
97
178
|
}
|
|
179
|
+
irreversibleStale.clear();
|
|
180
|
+
if (checkConflicts) {
|
|
181
|
+
for (const operation of plan.operations) {
|
|
182
|
+
if (!approved.has(operation.id) || !IRREVERSIBLE_OPERATIONS.has(operation.operation)) continue;
|
|
183
|
+
const detail = checkIrreversibleOp(operation, liveSnapshot);
|
|
184
|
+
if (detail) irreversibleStale.set(operation.id, detail);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
98
187
|
for (const guard of plan.guards ?? []) {
|
|
99
188
|
const failure = evaluateGuard(liveSnapshot, guard);
|
|
100
189
|
if (failure) {
|
|
@@ -232,6 +321,12 @@ export async function applyPatchPlan(
|
|
|
232
321
|
if (operation.groupId) poisonedGroups.add(operation.groupId);
|
|
233
322
|
continue;
|
|
234
323
|
}
|
|
324
|
+
const irreversibleConflict = irreversibleStale.get(operation.id);
|
|
325
|
+
if (irreversibleConflict) {
|
|
326
|
+
results.push({ operationId: operation.id, status: "conflict", detail: irreversibleConflict });
|
|
327
|
+
if (operation.groupId) poisonedGroups.add(operation.groupId);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
235
330
|
if (operation.groupId && poisonedGroups.has(operation.groupId)) {
|
|
236
331
|
results.push({
|
|
237
332
|
operationId: operation.id,
|
|
@@ -77,8 +77,11 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Requir
|
|
|
77
77
|
throw new Error(`Cannot reach HubSpot at ${baseUrl}${cause}. Check network access.`);
|
|
78
78
|
}
|
|
79
79
|
if (!response.ok) {
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
// Status line only — HubSpot 4xx bodies echo submitted property values
|
|
81
|
+
// (contact emails, company domains) and the request payload, and these
|
|
82
|
+
// errors are persisted into scheduled-run records. Never interpolate it.
|
|
83
|
+
await response.text().catch(() => undefined);
|
|
84
|
+
throw new Error(`HubSpot API error ${response.status}. Check the token scopes and request.`);
|
|
82
85
|
}
|
|
83
86
|
// DELETE and some association writes return 204 with an empty body.
|
|
84
87
|
const text = await response.text();
|
|
@@ -88,8 +88,10 @@ export function createSalesforceConnector(
|
|
|
88
88
|
);
|
|
89
89
|
}
|
|
90
90
|
if (!response.ok) {
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
// Status line only — the body echoes submitted field values and the
|
|
92
|
+
// request, and these errors are persisted into scheduled-run records.
|
|
93
|
+
await response.text().catch(() => undefined);
|
|
94
|
+
throw new Error(`Salesforce API error ${response.status}. Check the token and request.`);
|
|
93
95
|
}
|
|
94
96
|
// Salesforce PATCH returns 204 No Content on success.
|
|
95
97
|
const text = await response.text();
|
package/src/connectors/stripe.ts
CHANGED
|
@@ -46,8 +46,10 @@ export function createStripeConnector(options: StripeConnectorOptions): GtmConne
|
|
|
46
46
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
47
47
|
});
|
|
48
48
|
if (!response.ok) {
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
// Status line only — the body can echo request details bound to a live
|
|
50
|
+
// billing key, and these errors land in scheduled-run records.
|
|
51
|
+
await response.text().catch(() => undefined);
|
|
52
|
+
throw new Error(`Stripe API error ${response.status}. Check the restricted key and request.`);
|
|
51
53
|
}
|
|
52
54
|
return response.json();
|
|
53
55
|
}
|
package/src/credentials.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
mkdirSync,
|
|
5
5
|
readdirSync,
|
|
6
6
|
readFileSync,
|
|
7
|
+
statSync,
|
|
7
8
|
unlinkSync,
|
|
8
9
|
writeFileSync,
|
|
9
10
|
} from "node:fs";
|
|
@@ -143,8 +144,31 @@ export function writeSecureFile(path: string, contents: string) {
|
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The 0600/0700 guarantee was write-only: a credentials.json inherited at
|
|
149
|
+
* looser permissions (a restored backup, a file created by another tool, a
|
|
150
|
+
* cloned home) was read and trusted regardless of its actual mode. Enforce the
|
|
151
|
+
* mode on read too — re-tighten to 0600 and warn once — so a world-readable
|
|
152
|
+
* credential store can't sit there silently leaking the token to other users.
|
|
153
|
+
*/
|
|
154
|
+
function enforceCredentialFileMode(path: string): void {
|
|
155
|
+
try {
|
|
156
|
+
const mode = statSync(path).mode & 0o777;
|
|
157
|
+
if ((mode & 0o077) !== 0) {
|
|
158
|
+
chmodSync(path, 0o600);
|
|
159
|
+
console.error(
|
|
160
|
+
`fullstackgtm: tightened ${path} from ${mode.toString(8).padStart(3, "0")} to 600 ` +
|
|
161
|
+
"(it was readable or writable by other users).",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// Missing file or non-POSIX filesystem: nothing to enforce.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
146
169
|
function readFile(): CredentialsFile {
|
|
147
170
|
try {
|
|
171
|
+
enforceCredentialFileMode(credentialsPath());
|
|
148
172
|
const parsed = JSON.parse(readFileSync(credentialsPath(), "utf8"));
|
|
149
173
|
if (parsed && typeof parsed === "object" && parsed.version === 1 && parsed.providers) {
|
|
150
174
|
return parsed as CredentialsFile;
|
package/src/dedupe.ts
CHANGED
|
@@ -65,6 +65,21 @@ function populatedDataFields(record: Record<string, unknown>): number {
|
|
|
65
65
|
).length;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The subset of a record worth keeping as a merge-recovery artifact: its id (to
|
|
70
|
+
* reference) plus every populated data field, dropping bulky/plumbing fields
|
|
71
|
+
* (raw, identities, provenance) that aren't needed to recreate it by hand.
|
|
72
|
+
*/
|
|
73
|
+
export function recoverableFields(record: Record<string, unknown>): Record<string, unknown> {
|
|
74
|
+
const out: Record<string, unknown> = { id: String(record.id) };
|
|
75
|
+
for (const [field, value] of Object.entries(record)) {
|
|
76
|
+
if (NON_DATA_FIELDS.has(field)) continue;
|
|
77
|
+
if (value === undefined || value === null || value === "") continue;
|
|
78
|
+
out[field] = value;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
68
83
|
/** True when id `a` sorts before id `b` — numeric when both ids are numeric. */
|
|
69
84
|
function idBefore(a: string, b: string): boolean {
|
|
70
85
|
const numericA = Number(a);
|
|
@@ -137,6 +152,12 @@ export function buildDedupePlan(
|
|
|
137
152
|
const groupIds = members
|
|
138
153
|
.map((member) => String(member.id))
|
|
139
154
|
.sort((a, b) => (idBefore(a, b) ? -1 : 1));
|
|
155
|
+
// Recovery artifact: the records that will be merged away (everyone but the
|
|
156
|
+
// survivor), captured with their field values so a human can recreate one by
|
|
157
|
+
// hand if the merge was wrong. Merges are irreversible — the plan is the backup.
|
|
158
|
+
const recoverySnapshot = members
|
|
159
|
+
.filter((member) => String(member.id) !== String(survivor.id))
|
|
160
|
+
.map((member) => recoverableFields(member));
|
|
140
161
|
const survivorName =
|
|
141
162
|
typeof survivor.name === "string" && survivor.name
|
|
142
163
|
? survivor.name
|
|
@@ -162,8 +183,9 @@ export function buildDedupePlan(
|
|
|
162
183
|
approvalRequired: true,
|
|
163
184
|
sourceRuleOrPolicy: "dedupe",
|
|
164
185
|
groupId: `grp_${options.objectType}_${String(survivor.id)}`,
|
|
186
|
+
recoverySnapshot,
|
|
165
187
|
rollback:
|
|
166
|
-
"IRREVERSIBLE: provider merges cannot be unmerged.
|
|
188
|
+
"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.",
|
|
167
189
|
});
|
|
168
190
|
}
|
|
169
191
|
|
package/src/enrich.ts
CHANGED
|
@@ -394,6 +394,29 @@ function valueToString(value: unknown): string {
|
|
|
394
394
|
return "";
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
+
/**
|
|
398
|
+
* CSV/formula-injection neutralization for string values destined for a CRM
|
|
399
|
+
* write. Third-party export rows (Clay CSV, webhook JSON) can contain cells
|
|
400
|
+
* like `=cmd|'/c calc'!A1` or `@SUM(...)`; written verbatim to a CRM field they
|
|
401
|
+
* lie dormant until someone exports the CRM to CSV and opens it in a spreadsheet,
|
|
402
|
+
* where the leading `= + - @` (or a leading tab/CR) makes the client execute it.
|
|
403
|
+
* We prefix a single apostrophe — the spreadsheet-standard escape that renders
|
|
404
|
+
* the cell as literal text. Numeric values bypass this (they're written as
|
|
405
|
+
* numbers, not strings), so signed numbers keep full fidelity; a phone number
|
|
406
|
+
* supplied as a string and starting with `+` gains a leading `'`, which the
|
|
407
|
+
* human sees in the approved diff. Applied only at the write path, never to
|
|
408
|
+
* match keys.
|
|
409
|
+
*/
|
|
410
|
+
function neutralizeFormulaInjection(value: string): string {
|
|
411
|
+
if (value && /^[=+\-@\t\r]/.test(value)) return `'${value}`;
|
|
412
|
+
return value;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** valueToString for a value that will be written to a CRM field. */
|
|
416
|
+
function writeSafeString(value: unknown): string {
|
|
417
|
+
return neutralizeFormulaInjection(valueToString(value));
|
|
418
|
+
}
|
|
419
|
+
|
|
397
420
|
// ---------------------------------------------------------------------------
|
|
398
421
|
// Matching: ordered keys, unique-hit-wins, zero-hits-next-key,
|
|
399
422
|
// multi-hit → onAmbiguous. Ambiguity is surfaced, never resolved by coin flip.
|
|
@@ -708,7 +731,7 @@ export function buildEnrichPlan(options: BuildEnrichPlanOptions): EnrichPlanResu
|
|
|
708
731
|
operation: "set_field",
|
|
709
732
|
field: canonicalField,
|
|
710
733
|
beforeValue: currentValue ?? null,
|
|
711
|
-
afterValue: typeof sourceValue === "number" ? sourceValue :
|
|
734
|
+
afterValue: typeof sourceValue === "number" ? sourceValue : writeSafeString(sourceValue),
|
|
712
735
|
reason:
|
|
713
736
|
`${source} ${record.objectType} "${describeSourceRecord(record)}" (matched by ` +
|
|
714
737
|
`${outcome.matchedKey}) reports a changed value for ${canonicalField}.`,
|
|
@@ -726,7 +749,7 @@ export function buildEnrichPlan(options: BuildEnrichPlanOptions): EnrichPlanResu
|
|
|
726
749
|
if (isEmptyValue(sourceValue)) continue;
|
|
727
750
|
if (!isEmptyValue(currentValue)) continue;
|
|
728
751
|
emittedForRecord = true;
|
|
729
|
-
const afterValue = typeof sourceValue === "number" ? sourceValue :
|
|
752
|
+
const afterValue = typeof sourceValue === "number" ? sourceValue : writeSafeString(sourceValue);
|
|
730
753
|
operations.push({
|
|
731
754
|
id: `op_enr_${fnv1a(`${source}:${record.objectType}:${outcome.recordId}:${canonicalField}`)}`,
|
|
732
755
|
objectType: canonicalObjectType(record.objectType),
|