fullstackgtm 0.26.0 → 0.28.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.
@@ -18,8 +18,21 @@
18
18
  * field=value case-insensitive equality
19
19
  * field!=value case-insensitive inequality
20
20
  * field~value case-insensitive substring
21
+ * field!~value case-insensitive not-substring
21
22
  * field:empty unset or empty string
22
23
  * field:notempty set and non-empty
24
+ * field<value type-aware less-than (date or numeric)
25
+ * field>value type-aware greater-than
26
+ * field<=value type-aware less-than-or-equal
27
+ * field>=value type-aware greater-than-or-equal
28
+ *
29
+ * Comparison operators (`<` `>` `<=` `>=`) are type-aware, unlike the string
30
+ * operators above: if both the field value and the RHS parse as finite dates
31
+ * they compare as dates; else if both parse as finite numbers they compare
32
+ * numerically; otherwise the clause does not match (never throws at match
33
+ * time). The bare literal `today` on the RHS resolves to the policy/today date
34
+ * (`--today`, defaulting to the system date) and forces a date comparison —
35
+ * so `closeDate<today` is the canonical "past close date" filter.
23
36
  *
24
37
  * Fields are canonical (ownerId, stage, closeDate, amount, domain, name,
25
38
  * email, isClosed, accountId, …). Relational pseudo-fields are available in
@@ -31,7 +44,49 @@ import { recoverableFields } from "./dedupe.js";
31
44
  import { normalizeDomain } from "./merge.js";
32
45
  import { stableHash } from "./rules.js";
33
46
  const FIELD_PATTERN = "[a-zA-Z][a-zA-Z0-9_]*(?:\\.[a-zA-Z][a-zA-Z0-9_]*)?";
47
+ /**
48
+ * Is a comparison RHS (a single `|`-alternative) authorable for comparison?
49
+ * Accept `today`, OR a plain number, OR a date-SHAPED string — using the exact
50
+ * same `isPlainNumber`/`isDateLike` predicates the match-time branch uses, so
51
+ * the parse-time authoring check and the runtime branch cannot diverge. The
52
+ * intentional asymmetry: parse-time validates one literal in isolation (EITHER
53
+ * number OR date is fine), whereas match-time's `compareValues` additionally
54
+ * requires the FIELD and the RHS to agree on type (BOTH number, or BOTH date).
55
+ * Requiring a date *shape* here (not bare `Date.parse`-finite) means a prose
56
+ * typo like `July 4` or a stray `<5` fails loudly at parse instead of silently
57
+ * matching nothing. `isDateLike`/`isPlainNumber` are hoisted function decls.
58
+ */
59
+ function isComparableValue(rhs) {
60
+ const trimmed = rhs.trim();
61
+ if (trimmed === "today")
62
+ return true;
63
+ if (isPlainNumber(trimmed))
64
+ return true;
65
+ if (isDateLike(trimmed))
66
+ return true;
67
+ return false;
68
+ }
69
+ /**
70
+ * Catch the "multiple conditions crammed into one --where" footgun: a weak
71
+ * agent writes `--where 'stage!=closedwon AND stage!=closedlost'`, which the
72
+ * grammar would otherwise parse as a single neq clause whose VALUE is the
73
+ * literal string `closedwon AND stage!=closedlost` — matching almost nothing
74
+ * (or, with `!=`, almost EVERYTHING), so the gate faithfully applies a
75
+ * wildly-wrong-but-authorized plan.
76
+ *
77
+ * Fire only when a connector ` AND `/` OR ` is followed by a clause-LIKE
78
+ * structure (a field name + an operator), so incidental prose in a value —
79
+ * `name~Procter AND Gamble`, `name~research and development` — is NOT
80
+ * rejected (the word after the connector has no operator). Case-insensitive.
81
+ */
82
+ const INLINE_CONJUNCTION = new RegExp(`\\s(and|or)\\s+${FIELD_PATTERN}\\s*(!=|<=|>=|!~|=|<|>|~|:(empty|notempty)\\b)`, "i");
34
83
  export function parseWhere(expr) {
84
+ if (INLINE_CONJUNCTION.test(expr)) {
85
+ throw new Error(`Cannot parse "${expr}": looks like multiple conditions in one clause. ` +
86
+ `AND is implicit — use a SEPARATE --where for each condition ` +
87
+ `(e.g. --where stage!=closedwon --where stage!=closedlost). ` +
88
+ `For OR within one field, use value1|value2 (e.g. --where stage=closedwon|closedlost).`);
89
+ }
35
90
  const empty = expr.match(new RegExp(`^(${FIELD_PATTERN}):(empty|notempty)$`));
36
91
  if (empty)
37
92
  return { field: empty[1], op: empty[2], raw: expr };
@@ -41,13 +96,44 @@ export function parseWhere(expr) {
41
96
  const notContains = expr.match(new RegExp(`^(${FIELD_PATTERN})!~(.*)$`));
42
97
  if (notContains)
43
98
  return { field: notContains[1], op: "notcontains", value: notContains[2], raw: expr };
99
+ // Comparison operators: two-char tokens (`<=`/`>=`) MUST be tried before the
100
+ // one-char ones (`<`/`>`) so a prefix isn't stolen (`field<=5` → lte, not
101
+ // lt + stray `=`). They slot in after `!=`/`!~` and before `~`/`=` (different
102
+ // lead chars, no collision). The RHS is validated at parse time: an
103
+ // un-comparable literal (neither today, number, nor date) is a static
104
+ // authoring error and throws here, so a typo never silently matches nothing.
105
+ const lte = expr.match(new RegExp(`^(${FIELD_PATTERN})<=(.*)$`));
106
+ if (lte)
107
+ return assertComparable({ field: lte[1], op: "lte", value: lte[2], raw: expr });
108
+ const gte = expr.match(new RegExp(`^(${FIELD_PATTERN})>=(.*)$`));
109
+ if (gte)
110
+ return assertComparable({ field: gte[1], op: "gte", value: gte[2], raw: expr });
111
+ const lt = expr.match(new RegExp(`^(${FIELD_PATTERN})<(.*)$`));
112
+ if (lt)
113
+ return assertComparable({ field: lt[1], op: "lt", value: lt[2], raw: expr });
114
+ const gt = expr.match(new RegExp(`^(${FIELD_PATTERN})>(.*)$`));
115
+ if (gt)
116
+ return assertComparable({ field: gt[1], op: "gt", value: gt[2], raw: expr });
44
117
  const contains = expr.match(new RegExp(`^(${FIELD_PATTERN})~(.*)$`));
45
118
  if (contains)
46
119
  return { field: contains[1], op: "contains", value: contains[2], raw: expr };
47
120
  const eq = expr.match(new RegExp(`^(${FIELD_PATTERN})=(.*)$`));
48
121
  if (eq)
49
122
  return { field: eq[1], op: "eq", value: eq[2], raw: expr };
50
- throw new Error(`Cannot parse --where "${expr}". Use field=value, field!=value, field~substring, field!~substring, field:empty, or field:notempty.`);
123
+ throw new Error(`Cannot parse --where "${expr}". Use field=value, field!=value, field~substring, field!~substring, field:empty, field:notempty, field<value, field>value, field<=value, or field>=value.`);
124
+ }
125
+ /**
126
+ * Parse-time validity for a comparison clause: every `|`-alternative of the
127
+ * RHS must be a comparable literal (today / number / date). An un-comparable
128
+ * value (e.g. `amount>`, `closeDate<garbage`) throws here — a comparison that
129
+ * can never coerce is an authoring error, not a silent empty match.
130
+ */
131
+ function assertComparable(clause) {
132
+ const alternatives = (clause.value ?? "").split("|");
133
+ if (alternatives.some((a) => !isComparableValue(a))) {
134
+ throw new Error(`Cannot parse --where "${clause.raw}": the comparison value must be a number, an ISO date, or "today" (got "${clause.value ?? ""}").`);
135
+ }
136
+ return clause;
51
137
  }
52
138
  function fieldValue(view, field) {
53
139
  const value = view[field];
@@ -55,7 +141,57 @@ function fieldValue(view, field) {
55
141
  return "";
56
142
  return String(value);
57
143
  }
58
- function matches(view, clause) {
144
+ /** System date as ISO yyyy-mm-dd — the default `today` when none is threaded. */
145
+ function systemToday() {
146
+ return new Date().toISOString().slice(0, 10);
147
+ }
148
+ /** A non-empty trimmed string that parses as a finite JS number. */
149
+ function isPlainNumber(value) {
150
+ const trimmed = value.trim();
151
+ return trimmed !== "" && Number.isFinite(Number(trimmed));
152
+ }
153
+ /**
154
+ * "Looks like a date": Date.parse-finite AND not a plain number. The guard
155
+ * against plain numbers is essential — `Date.parse("5000")` is finite (it reads
156
+ * as the YEAR 5000), so without it `closeDate<5000` and `amount>2026-04-30`
157
+ * would silently take the date branch and produce wrong, broad matches. Pure
158
+ * numbers are numbers; only date-SHAPED strings (with `-`/`/`/`T`, like the
159
+ * canonical ISO `2026-04-30`) are dates. Mirrors the ISO-date assumption the
160
+ * past-close-date rule already makes (rules.ts compareDate).
161
+ */
162
+ function isDateLike(value) {
163
+ // Require an ISO-ish date separator (`-`/`/`/`T`/`:`) in addition to a finite
164
+ // Date.parse: excludes plain numbers (`Date.parse("5000")` = year 5000) AND
165
+ // locale-fragile prose (`Date.parse("July 4")` is finite but is not a date we
166
+ // want to accept). Canonical dates are ISO (`2026-04-30`), so this never
167
+ // rejects real data; it only rejects authoring typos.
168
+ return !isPlainNumber(value) && /[-/T:]/.test(value) && Number.isFinite(Date.parse(value));
169
+ }
170
+ /**
171
+ * Type-aware comparison for a single (un-lowercased) field value against one
172
+ * resolved RHS alternative. Date-first, then numeric; any non-coercible side
173
+ * (including empty/unset, type mismatch) yields null (caller → no match). Never
174
+ * throws. `today` is already resolved to a date string by the caller.
175
+ * Returns a<0 / 0 / a>0 in the chosen type's space, or null when not comparable.
176
+ */
177
+ function compareValues(rawField, rhs) {
178
+ if (rawField.trim() === "")
179
+ return null; // empty/unset is "not comparable", never epoch-0
180
+ // Date comparison when BOTH sides are date-shaped (mirrors compareDate in rules.ts).
181
+ if (isDateLike(rawField) && isDateLike(rhs)) {
182
+ return Date.parse(rawField) - Date.parse(rhs);
183
+ }
184
+ // Numeric comparison when BOTH sides are plain finite numbers.
185
+ if (isPlainNumber(rawField) && isPlainNumber(rhs)) {
186
+ return Number(rawField) - Number(rhs);
187
+ }
188
+ return null; // type mismatch / non-parseable → no match (caller returns false)
189
+ }
190
+ /**
191
+ * `today` is threaded from the policy/--today date (default: system date), so
192
+ * plan-time and apply-time re-verification resolve the literal identically.
193
+ */
194
+ function matches(view, clause, today) {
59
195
  const actual = fieldValue(view, clause.field).toLowerCase();
60
196
  // `|` alternation: eq/contains match ANY alternative; neq/notcontains
61
197
  // must hold against ALL alternatives.
@@ -73,6 +209,35 @@ function matches(view, clause) {
73
209
  return actual === "";
74
210
  case "notempty":
75
211
  return actual !== "";
212
+ // Comparison ops are type-aware and use the RAW (un-lowercased) field
213
+ // value, not `actual` — lowercasing an ISO date still parses, but the
214
+ // helper must read from fieldValue() directly (see §6.3 risk 2). `today`
215
+ // resolves to the threaded policy date. Alternation is OR for all four
216
+ // (`some`): each alternative is coerced independently; one that fails
217
+ // coercion contributes false.
218
+ case "lt":
219
+ case "gt":
220
+ case "lte":
221
+ case "gte": {
222
+ const rawField = fieldValue(view, clause.field);
223
+ const rawAlternatives = (clause.value ?? "").split("|");
224
+ return rawAlternatives.some((a) => {
225
+ const rhs = a.trim() === "today" ? today : a;
226
+ const cmp = compareValues(rawField, rhs);
227
+ if (cmp === null)
228
+ return false;
229
+ switch (clause.op) {
230
+ case "lt":
231
+ return cmp < 0;
232
+ case "gt":
233
+ return cmp > 0;
234
+ case "lte":
235
+ return cmp <= 0;
236
+ case "gte":
237
+ return cmp >= 0;
238
+ }
239
+ });
240
+ }
76
241
  }
77
242
  }
78
243
  const COLLECTIONS = {
@@ -169,17 +334,22 @@ export function parseGuard(raw) {
169
334
  assertValidFields(objectType, where.map(parseWhere), "--guard");
170
335
  return { objectType: objectType, where, expect: expect, description: raw };
171
336
  }
172
- /** Ids of records matching a filter — used for apply-time filter re-verification. */
173
- export function eligibleIds(snapshot, objectType, where) {
337
+ /**
338
+ * Ids of records matching a filter — used for apply-time filter
339
+ * re-verification. `today` resolves the comparison `today` literal; apply-time
340
+ * callers pass the value the plan was built with (stored on plan.filter.today)
341
+ * so re-verification uses the SAME today, defaulting to the system date.
342
+ */
343
+ export function eligibleIds(snapshot, objectType, where, today = systemToday()) {
174
344
  const clauses = where.map(parseWhere);
175
345
  const views = buildViews(snapshot, objectType);
176
- return new Set(views.filter(({ view }) => clauses.every((c) => matches(view, c))).map(({ record }) => String(record.id)));
346
+ return new Set(views.filter(({ view }) => clauses.every((c) => matches(view, c, today))).map(({ record }) => String(record.id)));
177
347
  }
178
348
  /** Evaluate a plan guard against a snapshot. Returns null when satisfied, else a failure detail. */
179
- export function evaluateGuard(snapshot, guard) {
349
+ export function evaluateGuard(snapshot, guard, today = systemToday()) {
180
350
  const clauses = guard.where.map(parseWhere);
181
351
  const views = buildViews(snapshot, guard.objectType);
182
- const matchCount = views.filter(({ view }) => clauses.every((c) => matches(view, c))).length;
352
+ const matchCount = views.filter(({ view }) => clauses.every((c) => matches(view, c, today))).length;
183
353
  const ok = guard.expect === "none" ? matchCount === 0 : matchCount > 0;
184
354
  if (ok)
185
355
  return null;
@@ -187,6 +357,10 @@ export function evaluateGuard(snapshot, guard) {
187
357
  }
188
358
  export function buildBulkUpdatePlan(snapshot, options) {
189
359
  const maxOperations = options.maxOperations ?? 500;
360
+ // Resolve `today` once: the comparison `today` literal in both --where and
361
+ // --guard binds to this value at plan time, and it is stored on plan.filter
362
+ // so apply-time re-verification (eligibleIds) resolves it identically.
363
+ const today = options.today ?? systemToday();
190
364
  if (options.where.length === 0) {
191
365
  throw new Error("bulk-update requires at least one --where filter — refusing to build an unscoped mass write.");
192
366
  }
@@ -197,6 +371,10 @@ export function buildBulkUpdatePlan(snapshot, options) {
197
371
  }
198
372
  const clauses = options.where.map(parseWhere);
199
373
  assertValidFields(options.objectType, clauses, "--where");
374
+ // Whether any comparison clause references the `today` literal — drives
375
+ // whether the resolved `today` is persisted on plan.filter (see below).
376
+ const isComparisonOp = (op) => op === "lt" || op === "gt" || op === "lte" || op === "gte";
377
+ const referencesToday = clauses.some((c) => isComparisonOp(c.op) && (c.value ?? "").split("|").some((a) => a.trim() === "today"));
200
378
  const WRITABLE_BLOCKLIST = new Set(["id", "crmId", "contactCount", "openDealCount", "openDealStages"]);
201
379
  // `from:<sourceField>` values resolve per record from the filter view —
202
380
  // the source is validated with the same strictness as filters (relational
@@ -218,7 +396,7 @@ export function buildBulkUpdatePlan(snapshot, options) {
218
396
  }
219
397
  }
220
398
  const views = buildViews(snapshot, options.objectType);
221
- const matched = views.filter(({ view }) => clauses.every((c) => matches(view, c)));
399
+ const matched = views.filter(({ view }) => clauses.every((c) => matches(view, c, today)));
222
400
  if (matched.length > maxOperations) {
223
401
  throw new Error(`Filter matched ${matched.length} ${COLLECTIONS[options.objectType]} — above the ${maxOperations}-record safety cap. Narrow the --where filter or raise --max-operations explicitly.`);
224
402
  }
@@ -381,10 +559,20 @@ export function buildBulkUpdatePlan(snapshot, options) {
381
559
  // guards must hold at plan time too — building a plan whose guard already
382
560
  // fails is a footgun, surface it immediately
383
561
  for (const guard of guards) {
384
- const failure = evaluateGuard(snapshot, guard);
562
+ const failure = evaluateGuard(snapshot, guard, today);
385
563
  if (failure)
386
564
  throw new Error(`${failure} The guard already fails against the current snapshot — the plan would never apply.`);
387
565
  }
566
+ // A `today` literal inside a --guard (not just --where) must also pin
567
+ // plan.filter.today: connector.ts re-evaluates guards at apply time and,
568
+ // without the persisted date, would resolve `today` to the apply-day system
569
+ // date — silently drifting a fail-closed guard. Persist `today` if EITHER a
570
+ // --where or a --guard clause references the literal.
571
+ const guardReferencesToday = guards.some((g) => g.where.some((raw) => {
572
+ const c = parseWhere(raw);
573
+ return isComparisonOp(c.op) && (c.value ?? "").split("|").some((a) => a.trim() === "today");
574
+ }));
575
+ const persistToday = referencesToday || guardReferencesToday;
388
576
  const skippedText = [...skippedBySource.entries()]
389
577
  .map(([sourceField, count]) => ` ${count} skipped: empty ${sourceField}.`)
390
578
  .join("");
@@ -397,7 +585,18 @@ export function buildBulkUpdatePlan(snapshot, options) {
397
585
  summary: `${matched.length} ${COLLECTIONS[options.objectType]} matched (${whereText}); ${operations.length} proposed dry-run operations (${action}).${skippedText}${guards.length > 0 ? ` ${guards.length} apply-time guard(s).` : ""}`,
398
586
  findings: [],
399
587
  operations,
400
- filter: { objectType: options.objectType, where: options.where },
588
+ // Persist the resolved `today` ONLY when a filter clause actually
589
+ // references the `today` literal, so apply-time re-verification (eligibleIds
590
+ // in connector.ts) resolves it to the same date the plan was built with —
591
+ // the apply command carries no --today of its own. Plans without `today`
592
+ // (the vast majority — equality filters, reassign, etc.) keep the original
593
+ // `{ objectType, where }` shape unchanged; eligibleIds defaults to the
594
+ // system date for them, which never affects a non-`today` filter.
595
+ filter: {
596
+ objectType: options.objectType,
597
+ where: options.where,
598
+ ...(persistToday ? { today } : {}),
599
+ },
401
600
  ...(guards.length > 0 ? { guards } : {}),
402
601
  };
403
602
  }
package/dist/cli.d.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  import { type LlmProvider } from "./llm.ts";
2
+ /**
3
+ * The broker channel carries a long-lived pairing bearer and receives freshly
4
+ * minted live-CRM tokens, so it must be TLS unless it's an explicit localhost
5
+ * dev target. Refuse http:// (and non-http schemes) otherwise — single-quote
6
+ * shell escaping does nothing for a token sent in cleartext.
7
+ */
8
+ export declare function assertSecureBrokerUrl(raw: string): URL;
2
9
  type ProviderDoctorStatus = {
3
10
  source: "env" | "stored" | "broker" | "none";
4
11
  detail: string;
@@ -33,7 +40,7 @@ export declare function doctorReport(env?: Record<string, string | undefined>):
33
40
  llm: {
34
41
  configured: boolean;
35
42
  provider: LlmProvider;
36
- source: "env" | "stored";
43
+ source: "stored" | "env";
37
44
  detail?: undefined;
38
45
  } | {
39
46
  configured: boolean;
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ import { generateDemoSnapshot } from "./demo.js";
14
14
  import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
15
15
  import { mergeSnapshots } from "./merge.js";
16
16
  import { verifyApprovalDigests } from "./integrity.js";
17
+ import { buildAuditLog, verifyAuditLog } from "./auditLog.js";
17
18
  import { createFilePlanStore } from "./planStore.js";
18
19
  import { auditReportToHtml, auditReportToMarkdown } from "./report.js";
19
20
  import { builtinAuditRules } from "./rules.js";
@@ -155,6 +156,7 @@ Usage:
155
156
  fullstackgtm plans approve <id> --values-from <suggestions.json> [--min-confidence high|low] [--include-creates]
156
157
  fullstackgtm apply --plan-id <id> --provider <name>
157
158
  fullstackgtm apply --plan <path> --provider <name> --approve <ids|all> [options]
159
+ fullstackgtm audit-log export [--out <path>] | verify --in <path> tamper-evident apply-run record
158
160
  fullstackgtm rules [--json]
159
161
  fullstackgtm profiles [--json] list credential profiles
160
162
  fullstackgtm doctor [--json] check install, credentials, and next step
@@ -2027,6 +2029,9 @@ async function bulkUpdateCommand(args) {
2027
2029
  guard: repeatedOption(rest, "--guard"),
2028
2030
  reason: option(rest, "--reason") ?? undefined,
2029
2031
  maxOperations: numericOption(rest, "--max-operations"),
2032
+ // --today resolves the comparison `today` literal (e.g. closeDate<today);
2033
+ // defaults to the system date inside buildBulkUpdatePlan when omitted.
2034
+ today: option(rest, "--today") ?? undefined,
2030
2035
  });
2031
2036
  await emitPlan(plan, rest);
2032
2037
  }
@@ -2281,6 +2286,52 @@ function readSuggestionValues(path, minConfidence, includeCreates) {
2281
2286
  }
2282
2287
  return { overrides, skipped };
2283
2288
  }
2289
+ async function auditLogCommand(args) {
2290
+ const [sub, ...rest] = args;
2291
+ if (!sub || sub === "--help" || sub === "-h" || (sub !== "export" && sub !== "verify")) {
2292
+ console.log(`Usage:
2293
+ audit-log export [--out <path>] [--json] hash-chained, signed record of every apply run
2294
+ audit-log verify [--in <path>] re-check an exported log's chain and signature
2295
+
2296
+ export flattens every apply run across all stored plans (this profile) into a
2297
+ tamper-evident chain — each entry carries the prior entry's hash, and the chain
2298
+ head is HMAC-signed with this install's key — so a change-management process can
2299
+ archive one file and later prove it was not edited. verify recomputes the chain
2300
+ and (if the signing key is present) the signature.`);
2301
+ return;
2302
+ }
2303
+ if (sub === "export") {
2304
+ const plans = await createFilePlanStore().list();
2305
+ const log = buildAuditLog(plans, new Date().toISOString());
2306
+ const payload = `${JSON.stringify(log, null, 2)}\n`;
2307
+ const outPath = option(rest, "--out");
2308
+ if (outPath) {
2309
+ writeFileSync(resolve(process.cwd(), outPath), payload);
2310
+ console.log(`Wrote ${outPath}: ${log.entryCount} run(s), chain head ${log.chainHead.slice(0, 12)}${log.signature ? " (signed)" : " (unsigned — no signing key on this install)"}.`);
2311
+ }
2312
+ else if (rest.includes("--json")) {
2313
+ console.log(payload);
2314
+ }
2315
+ else {
2316
+ console.log(`${log.entryCount} apply run(s); chain head ${log.chainHead.slice(0, 12)}${log.signature ? ", signed" : ", unsigned"}. Pass --out <path> to archive, or --json to print.`);
2317
+ }
2318
+ return;
2319
+ }
2320
+ // verify
2321
+ const inPath = option(rest, "--in");
2322
+ if (!inPath)
2323
+ throw new Error("audit-log verify requires --in <exported-log.json>");
2324
+ const log = JSON.parse(readFileSync(resolve(process.cwd(), inPath), "utf8"));
2325
+ const result = verifyAuditLog(log);
2326
+ if (rest.includes("--json")) {
2327
+ console.log(JSON.stringify(result, null, 2));
2328
+ }
2329
+ else {
2330
+ console.log(result.ok ? `OK — ${result.detail}` : `TAMPERED — ${result.detail}`);
2331
+ }
2332
+ if (!result.ok)
2333
+ process.exitCode = 2;
2334
+ }
2284
2335
  async function apply(args) {
2285
2336
  const provider = option(args, "--provider");
2286
2337
  if (!provider)
@@ -2570,7 +2621,30 @@ function rejectArgvSecret(args, ...flags) {
2570
2621
  }
2571
2622
  }
2572
2623
  }
2624
+ /**
2625
+ * The broker channel carries a long-lived pairing bearer and receives freshly
2626
+ * minted live-CRM tokens, so it must be TLS unless it's an explicit localhost
2627
+ * dev target. Refuse http:// (and non-http schemes) otherwise — single-quote
2628
+ * shell escaping does nothing for a token sent in cleartext.
2629
+ */
2630
+ export function assertSecureBrokerUrl(raw) {
2631
+ let url;
2632
+ try {
2633
+ url = new URL(raw);
2634
+ }
2635
+ catch {
2636
+ throw new Error(`--via must be a full URL (e.g. https://gtm.yourco.com), got "${raw}".`);
2637
+ }
2638
+ const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]";
2639
+ if (url.protocol === "https:")
2640
+ return url;
2641
+ if (url.protocol === "http:" && isLocalhost)
2642
+ return url; // local dev only
2643
+ throw new Error(`Refusing to pair over ${url.protocol}//${url.host}: the broker exchanges a long-lived token and mints live CRM ` +
2644
+ "credentials, so it must use https (http is allowed only for localhost dev).");
2645
+ }
2573
2646
  async function brokerLogin(baseUrl) {
2647
+ const viaUrl = assertSecureBrokerUrl(baseUrl);
2574
2648
  const base = baseUrl.replace(/\/$/, "");
2575
2649
  const os = await import("node:os");
2576
2650
  // Self-reported, shown to the approver so they can recognize this request
@@ -2592,8 +2666,22 @@ async function brokerLogin(baseUrl) {
2592
2666
  throw new Error(`Could not start pairing with ${base} (${startResponse.status}). Is this a FullStackGTM deployment?`);
2593
2667
  }
2594
2668
  const start = await startResponse.json();
2669
+ // Only auto-open a verification URL that belongs to the --via origin the user
2670
+ // typed — a malicious/typo'd deployment cannot redirect the browser elsewhere.
2671
+ let sameOrigin = false;
2672
+ try {
2673
+ sameOrigin = new URL(start.verificationUrl).origin === viaUrl.origin;
2674
+ }
2675
+ catch {
2676
+ sameOrigin = false;
2677
+ }
2595
2678
  console.error(`\nPairing code: ${start.userCode}\n\nApprove this CLI ("${requesterLabel}") in your dashboard:\n\n ${start.verificationUrl}\n`);
2596
- void openInBrowser(start.verificationUrl);
2679
+ if (sameOrigin) {
2680
+ void openInBrowser(start.verificationUrl);
2681
+ }
2682
+ else {
2683
+ console.error(`(Not auto-opening: the verification URL is not on ${viaUrl.origin}. Open it manually only if you trust it.)`);
2684
+ }
2597
2685
  const deadline = Date.now() + (start.expiresInSeconds ?? 600) * 1000;
2598
2686
  const intervalMs = Math.max(0, (start.intervalSeconds ?? 3) * 1000);
2599
2687
  while (Date.now() < deadline) {
@@ -3053,6 +3141,10 @@ export async function runCli(argv) {
3053
3141
  await plansCommand(args);
3054
3142
  return;
3055
3143
  }
3144
+ if (command === "audit-log") {
3145
+ await auditLogCommand(args);
3146
+ return;
3147
+ }
3056
3148
  if (command === "apply") {
3057
3149
  await apply(args);
3058
3150
  return;
package/dist/connector.js CHANGED
@@ -117,7 +117,11 @@ export async function applyPatchPlan(connector, plan, options) {
117
117
  const { evaluateGuard, eligibleIds } = await import("./bulkUpdate.js");
118
118
  const liveSnapshot = await connector.fetchSnapshot();
119
119
  if (plan.filter) {
120
- const stillEligible = eligibleIds(liveSnapshot, plan.filter.objectType, plan.filter.where);
120
+ // Resolve the comparison `today` literal to the date the plan was built
121
+ // with (stored on plan.filter), so apply-time re-verification of a
122
+ // `closeDate<today`-style filter agrees with plan time. eligibleIds
123
+ // defaults to the system date when the plan predates comparison ops.
124
+ const stillEligible = eligibleIds(liveSnapshot, plan.filter.objectType, plan.filter.where, plan.filter.today);
121
125
  staleIds.clear();
122
126
  for (const operation of plan.operations) {
123
127
  if (!stillEligible.has(operation.objectId))
@@ -135,7 +139,7 @@ export async function applyPatchPlan(connector, plan, options) {
135
139
  }
136
140
  }
137
141
  for (const guard of plan.guards ?? []) {
138
- const failure = evaluateGuard(liveSnapshot, guard);
142
+ const failure = evaluateGuard(liveSnapshot, guard, plan.filter?.today);
139
143
  if (failure) {
140
144
  guardFailure = failure;
141
145
  return;
@@ -1,8 +1,10 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { createHash } from "node:crypto";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { refreshHubspotToken } from "./connectors/hubspotAuth.js";
5
6
  import { refreshSalesforceToken } from "./connectors/salesforceAuth.js";
7
+ import { detectKeychainBackend } from "./keychain.js";
6
8
  /**
7
9
  * Local CLI credential store: ~/.fullstackgtm/credentials.json (0600), or
8
10
  * $FSGTM_HOME/credentials.json when set. Environment tokens always win over
@@ -118,38 +120,103 @@ function enforceCredentialFileMode(path) {
118
120
  // Missing file or non-POSIX filesystem: nothing to enforce.
119
121
  }
120
122
  }
123
+ /**
124
+ * Persistence backend for the credential blob: the OS keychain when
125
+ * FSGTM_KEYCHAIN=1 and one is available, otherwise the 0600 file. The keychain
126
+ * account is derived from the credential file path so distinct homes/profiles
127
+ * never collide in the machine-wide store.
128
+ */
129
+ function activeKeychain() {
130
+ if (process.env.FSGTM_KEYCHAIN !== "1")
131
+ return null;
132
+ const backend = detectKeychainBackend();
133
+ if (!backend)
134
+ return null;
135
+ return { account: createHash("sha256").update(credentialsPath()).digest("hex").slice(0, 24), backend };
136
+ }
137
+ /**
138
+ * When keychain is enabled on an install that previously wrote a plaintext
139
+ * credentials.json, that file would otherwise sit on disk forever (readFile only
140
+ * looks at the keychain). Import it into the keychain and remove it — the whole
141
+ * point of keychain mode is that no plaintext credential remains at rest.
142
+ */
143
+ function migratePlaintextToKeychain(keychain) {
144
+ if (!existsSync(credentialsPath()))
145
+ return;
146
+ try {
147
+ const fileParsed = JSON.parse(readFileSync(credentialsPath(), "utf8"));
148
+ if (fileParsed && fileParsed.version === 1 && fileParsed.providers) {
149
+ const current = keychain.backend.get(keychain.account);
150
+ const existing = current ? (JSON.parse(current).providers ?? {}) : {};
151
+ // Keychain entries win over the file on conflict (the file is the older copy).
152
+ const merged = { version: 1, providers: { ...fileParsed.providers, ...existing } };
153
+ keychain.backend.set(keychain.account, `${JSON.stringify(merged, null, 2)}\n`);
154
+ }
155
+ unlinkSync(credentialsPath());
156
+ console.error("fullstackgtm: migrated credentials.json into the OS keychain and removed the plaintext file.");
157
+ }
158
+ catch {
159
+ // Best effort: a malformed/locked file is left in place rather than lost.
160
+ }
161
+ }
121
162
  function readFile() {
163
+ const keychain = activeKeychain();
164
+ if (keychain)
165
+ migratePlaintextToKeychain(keychain);
122
166
  try {
123
- enforceCredentialFileMode(credentialsPath());
124
- const parsed = JSON.parse(readFileSync(credentialsPath(), "utf8"));
125
- if (parsed && typeof parsed === "object" && parsed.version === 1 && parsed.providers) {
126
- return parsed;
167
+ const raw = keychain ? keychain.backend.get(keychain.account) : (enforceCredentialFileMode(credentialsPath()), readFileSync(credentialsPath(), "utf8"));
168
+ if (raw) {
169
+ const parsed = JSON.parse(raw);
170
+ if (parsed && typeof parsed === "object" && parsed.version === 1 && parsed.providers) {
171
+ return parsed;
172
+ }
127
173
  }
128
174
  }
129
175
  catch {
130
- // Missing or unreadable file falls through to an empty store.
176
+ // Missing or unreadable store falls through to an empty one.
131
177
  }
132
178
  return { version: 1, providers: {} };
133
179
  }
180
+ function persist(file) {
181
+ const keychain = activeKeychain();
182
+ const blob = `${JSON.stringify(file, null, 2)}\n`;
183
+ if (keychain) {
184
+ keychain.backend.set(keychain.account, blob);
185
+ }
186
+ else {
187
+ ensureSecureHomeDir();
188
+ writeSecureFile(credentialsPath(), blob);
189
+ }
190
+ }
134
191
  export function getCredential(provider) {
135
192
  return readFile().providers[provider] ?? null;
136
193
  }
137
194
  export function storeCredential(provider, credential) {
138
195
  const file = readFile();
139
196
  file.providers[provider] = credential;
140
- ensureSecureHomeDir();
141
- writeSecureFile(credentialsPath(), `${JSON.stringify(file, null, 2)}\n`);
197
+ persist(file);
142
198
  }
143
199
  export function deleteCredential(provider) {
144
200
  const file = readFile();
145
201
  if (!file.providers[provider])
146
202
  return false;
147
203
  delete file.providers[provider];
148
- if (Object.keys(file.providers).length === 0 && existsSync(credentialsPath())) {
149
- unlinkSync(credentialsPath());
150
- return true;
204
+ const keychain = activeKeychain();
205
+ if (Object.keys(file.providers).length === 0) {
206
+ if (keychain) {
207
+ keychain.backend.delete(keychain.account);
208
+ // Defensive: remove any leftover plaintext file too (migration normally
209
+ // already did, but never leave a credential blob on disk after logout).
210
+ if (existsSync(credentialsPath()))
211
+ unlinkSync(credentialsPath());
212
+ return true;
213
+ }
214
+ if (existsSync(credentialsPath())) {
215
+ unlinkSync(credentialsPath());
216
+ return true;
217
+ }
151
218
  }
152
- writeSecureFile(credentialsPath(), `${JSON.stringify(file, null, 2)}\n`);
219
+ persist(file);
153
220
  return true;
154
221
  }
155
222
  const REFRESH_SKEW_MS = 2 * 60 * 1000;
@@ -222,6 +289,13 @@ async function brokerMint(provider, options) {
222
289
  const broker = getCredential("broker");
223
290
  if (!broker?.baseUrl)
224
291
  return null;
292
+ // The mint replays the long-lived bearer and receives a live CRM token —
293
+ // refuse to do so over cleartext even if a tampered store points us at http.
294
+ const brokerUrl = new URL(broker.baseUrl);
295
+ const localhost = brokerUrl.hostname === "localhost" || brokerUrl.hostname === "127.0.0.1" || brokerUrl.hostname === "::1" || brokerUrl.hostname === "[::1]";
296
+ if (brokerUrl.protocol !== "https:" && !(brokerUrl.protocol === "http:" && localhost)) {
297
+ throw new Error(`Refusing to mint a CRM token over ${brokerUrl.protocol}//${brokerUrl.host} — the broker must use https. Re-pair with an https deployment.`);
298
+ }
225
299
  const fetchImpl = options.fetchImpl ?? fetch;
226
300
  const response = await fetchImpl(`${broker.baseUrl.replace(/\/$/, "")}/api/cli/token`, {
227
301
  method: "POST",
package/dist/index.d.ts CHANGED
@@ -17,6 +17,7 @@ export { diffFindings, diffSnapshots, diffToMarkdown, type CollectionDiff, type
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
19
  export { computeApprovalDigests, loadOrCreateSigningKey, loadSigningKey, signApproval, verifyApprovalDigests, type ApprovalVerification, } from "./integrity.ts";
20
+ export { buildAuditLog, verifyAuditLog, type AuditLogEntry, type AuditLogExport, type AuditLogVerification, } from "./auditLog.ts";
20
21
  export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
21
22
  export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
22
23
  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
@@ -17,6 +17,7 @@ export { diffFindings, diffSnapshots, diffToMarkdown, } from "./diff.js";
17
17
  export { mergeSnapshots, } from "./merge.js";
18
18
  export { createFilePlanStore } from "./planStore.js";
19
19
  export { computeApprovalDigests, loadOrCreateSigningKey, loadSigningKey, signApproval, verifyApprovalDigests, } from "./integrity.js";
20
+ export { buildAuditLog, verifyAuditLog, } from "./auditLog.js";
20
21
  export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.js";
21
22
  export { auditReportToHtml, auditReportToMarkdown } from "./report.js";
22
23
  export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mappedField, mappedFields, normalizeFieldMappings, readMappedValue, } from "./mappings.js";