fullstackgtm 0.27.0 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,50 @@ 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.28.1] — 2026-06-16
9
+
10
+ Company-of-record correction: the legal entity is **Full Stack GTM LLC** and the
11
+ contact/disclosure address is **ryan@fullstackgtm.com** (package.json author,
12
+ NOTICE copyright, SECURITY.md, DATA-FLOWS.md). No code changes.
13
+
14
+ ## [0.28.0] — 2026-06-16
15
+
16
+ Connectors, credentials & supply chain — the last of the hardening train.
17
+ Each security change was re-attacked; the keychain and supply-chain gates each
18
+ took two rounds (the re-attack found the stale-plaintext-on-migration gap and
19
+ the orphan-dist gap that round 1 left open).
20
+
21
+ ### Added
22
+
23
+ - **Opt-in OS-keychain credential storage** (`FSGTM_KEYCHAIN=1`). The credential
24
+ blob is stored in the macOS Keychain (`security`) or Linux libsecret
25
+ (`secret-tool`) instead of a 0600 file — no native dependency. Enabling it on
26
+ an existing install migrates `credentials.json` into the keychain and removes
27
+ the plaintext file. Default (unset) is unchanged: the 0600 file. macOS caveat
28
+ (transient argv exposure during the keychain write) documented in SECURITY.md.
29
+
30
+ ### Security
31
+
32
+ - **Broker URL must be https.** `login --via` and the token-mint path refuse a
33
+ cleartext/non-https broker (localhost dev excepted), and the device
34
+ verification URL is only auto-opened when it shares the `--via` origin — so a
35
+ long-lived pairing bearer and minted live-CRM tokens can't go over cleartext
36
+ or to an attacker-redirected URL.
37
+ - **Supply-chain: published dist is provably from source.** `npm run build` now
38
+ cleans first; release rebuilds from source and refuses to publish if the
39
+ committed `dist/` doesn't match (catching a poisoned or *orphaned* dist file),
40
+ and a CI `dist-integrity` job enforces the same on every push — protecting
41
+ `npm install github:` consumers who run the committed dist unbuilt.
42
+ - npx peer resolution from the current directory is now logged (running the MCP
43
+ server in an untrusted directory could otherwise silently load a peer from it).
44
+
45
+ ### Changed
46
+
47
+ - **Salesforce merge is documented as unsupported**, with a connector
48
+ capability matrix in the README — no REST merge exists (SOAP/Apex only), so
49
+ `merge_records` is refused honestly; deduplicate in the UI or archive the
50
+ non-survivors. No silent half-merge, no demo surprise.
51
+
8
52
  ## [0.27.0] — 2026-06-16
9
53
 
10
54
  Trust, compliance & transparency — the artifacts a skeptical buyer's security
package/DATA-FLOWS.md CHANGED
@@ -38,8 +38,8 @@ Commands not listed (`plans`, `rules`, `doctor`, `schedule`, `audit-log`,
38
38
  CRM-only.
39
39
  - **No data is sent for training.** Anthropic, OpenAI, and Apollo are reached
40
40
  with your own API keys under your own agreements; their data-handling terms
41
- (and any DPA you have with them) govern that traffic. Full Stack GTM is not
42
- in that path and is not a sub-processor for the open-source CLI.
41
+ (and any DPA you have with them) govern that traffic. Full Stack GTM LLC is
42
+ not in that path and is not a sub-processor for the open-source CLI.
43
43
 
44
44
  ## Sub-processors
45
45
 
@@ -49,4 +49,4 @@ controllers are you and the providers whose keys you supply.
49
49
  For the **hosted application** (a separate, proprietary product — not this
50
50
  package): a sub-processor list and DPA are provided through that product's
51
51
  agreement. If you are evaluating the hosted product, request them from
52
- security@fullstackgtm.com.
52
+ ryan@fullstackgtm.com.
package/NOTICE CHANGED
@@ -1,5 +1,5 @@
1
1
  fullstackgtm
2
- Copyright 2026 Full Stack GTM
2
+ Copyright 2026 Full Stack GTM LLC
3
3
 
4
- This product is developed and maintained by Full Stack GTM (https://fullstackgtm.com).
4
+ This product is developed and maintained by Full Stack GTM LLC (https://fullstackgtm.com).
5
5
  Licensed under the Apache License, Version 2.0 (see LICENSE).
package/README.md CHANGED
@@ -127,7 +127,7 @@ fullstackgtm reassign --from 411 --to 902 --except-deal-stage closing --save #
127
127
  fullstackgtm fix --rule missing-deal-owner --provider hubspot --yes # audit one rule → suggest → approve → apply, one command
128
128
  ```
129
129
 
130
- `bulk-update` filters the snapshot (`=`, `!=`, `~` substring, `!~` not-substring, `:empty`/`:notempty`, `|` any-of, relational pseudo-fields like `account.domain` or `openDealStages`) into a dry-run patch plan — and **the full filter is re-verified per record at apply time**, with mid-apply rechecks, so a record that stopped matching between audit and apply is skipped, not clobbered. Equality filters double as preconditions; `--require` adds explicit ones; `--guard` asserts cross-record conditions; `--max-operations` caps blast radius. `--set field=from:<sourceField>` derives values per record; `--create-task <text>` is the third change mode, emitting approval-gated `create_task` operations instead of field writes; `--archive` refuses records whose identity key (account domain, contact email) is shared with another record — that's a duplicate, and duplicates are merged with `dedupe`, not archived around (`--force-archive-duplicates` overrides that refusal explicitly).
130
+ `bulk-update` filters the snapshot (`=`, `!=`, `~` substring, `!~` not-substring, `:empty`/`:notempty`, type-aware comparisons `<` `>` `<=` `>=` where `today` resolves to the policy date — e.g. `closeDate<today` — and date/numeric fields coerce by value form, `|` any-of, relational pseudo-fields like `account.domain` or `openDealStages`) into a dry-run patch plan — and **the full filter is re-verified per record at apply time**, with mid-apply rechecks, so a record that stopped matching between audit and apply is skipped, not clobbered. For date/count hygiene (past close dates, stale deals, missing accounts, duplicates), prefer the rule-backed `fix --rule <id>` — the rule encodes the open-deal + date logic deterministically; use `bulk-update` only when no rule covers the task. Equality filters double as preconditions; `--require` adds explicit ones; `--guard` asserts cross-record conditions; `--max-operations` caps blast radius. `--set field=from:<sourceField>` derives values per record; `--create-task <text>` is the third change mode, emitting approval-gated `create_task` operations instead of field writes; `--archive` refuses records whose identity key (account domain, contact email) is shared with another record — that's a duplicate, and duplicates are merged with `dedupe`, not archived around (`--force-archive-duplicates` overrides that refusal explicitly).
131
131
 
132
132
  `dedupe` finds duplicate groups by normalized identity key and emits one `merge_records` operation per group with a deterministic survivor (`richest` = most populated fields, ties to lowest id; `oldest`). Merges stay irreversible-and-therefore-low-confidence-capped on approval, exactly like merge suggestions from the audit. `reassign` is the ownership-handoff playbook: one plan per object type, extra scoping account-lifted to deals and contacts, and `--except-deal-stage` excludes both deals in that stage and every record whose account has an open deal in it. `fix` is the one-shot composite for a single rule: audit → save → suggest → approve suggestion-backed operations at the confidence bar → with `--yes`, apply and print the stage-by-stage summary; without it, stop after approval and print the apply command.
133
133
 
@@ -271,6 +271,20 @@ A direct `login hubspot` always wins over a broker pairing, so an operator can o
271
271
 
272
272
  What each provider actually requires before `audit --provider <name>` works on your data.
273
273
 
274
+ ### Connector capabilities
275
+
276
+ Connectors differ in what the provider's API allows — stated up front so nothing surprises you mid-evaluation:
277
+
278
+ | Operation | HubSpot | Salesforce | Stripe |
279
+ | --- | --- | --- | --- |
280
+ | Read / snapshot / audit | ✅ | ✅ | ✅ (read-only) |
281
+ | Field writes (`set_field`, `clear_field`, `link_record`) | ✅ | ✅ | — |
282
+ | `create_task` | ✅ | ✅ | — |
283
+ | `archive_record` | ✅ | ✅ | — |
284
+ | `merge_records` (`dedupe`) | ✅ | ❌ **not supported** | — |
285
+
286
+ **Salesforce merge** has no REST resource — it exists only in the SOAP API / Apex (Lead, Contact, Account, Case; max 3 records). This connector refuses a Salesforce `merge_records` operation honestly rather than half-merging; on Salesforce, deduplicate in the UI (or via SOAP/Apex), or use `bulk-update --archive` for the non-survivors. Native Salesforce merge is tracked future work, not a silent gap.
287
+
274
288
  ### HubSpot: create a private app (~2 minutes, needs super-admin)
275
289
 
276
290
  1. In HubSpot: **Settings → Integrations → Private Apps → Create a private app.**
package/SECURITY.md CHANGED
@@ -7,7 +7,7 @@ security reviewer needs.
7
7
 
8
8
  ## Reporting a vulnerability
9
9
 
10
- Email **security@fullstackgtm.com** with a description and, ideally, a
10
+ Email **ryan@fullstackgtm.com** with a description and, ideally, a
11
11
  reproduction. Please do not open a public issue for a security report. We aim
12
12
  to acknowledge within 3 business days and to ship a fix or mitigation before
13
13
  any public disclosure. There is no bounty program yet; credit is given in the
@@ -24,7 +24,20 @@ environment variable or stdin only, and are stored `0600` under a `0700` home
24
24
  (`$FSGTM_HOME`, default `~/.fullstackgtm`), re-tightened on read. This is the
25
25
  same custody model as the `gcloud`/`aws` CLIs. The hosted broker
26
26
  (`login --via`) exists so a team can connect a CRM once, server-side, and hand
27
- laptops only a revocable pairing token instead of a long-lived super-admin key.
27
+ laptops only a revocable pairing token instead of a long-lived super-admin key;
28
+ the broker URL must be https (cleartext is refused except for localhost dev).
29
+
30
+ **OS keychain (opt-in).** Set `FSGTM_KEYCHAIN=1` to store the credential blob
31
+ in the OS secret store instead of a plaintext file — macOS Keychain (via
32
+ `security`) or Linux libsecret (via `secret-tool`); no native dependency. When
33
+ enabled, a pre-existing `credentials.json` is migrated into the keychain and the
34
+ plaintext file is removed, so a cloned home or restored backup finds no token at
35
+ rest. Caveat: on macOS, `security add-generic-password` only accepts the secret
36
+ via an argv flag, so it is briefly visible to a same-user `ps` during the write
37
+ — a transient exposure strictly smaller than a persistent plaintext file, but a
38
+ real one (Linux `secret-tool` reads the secret from stdin, with no such window).
39
+ Backups or clones taken *before* enabling keychain already captured the file;
40
+ rotate those credentials if that is a concern.
28
41
 
29
42
  **Writes are approval-gated.** Reads are safe by default. Every change is a
30
43
  typed patch operation in a dry-run plan that a human must approve before
@@ -33,10 +33,17 @@ export type BulkUpdateOptions = {
33
33
  reason?: string;
34
34
  /** refuse to build plans larger than this (default 500 operations) */
35
35
  maxOperations?: number;
36
+ /**
37
+ * Date the comparison `today` literal resolves to (ISO yyyy-mm-dd). Set from
38
+ * the policy/--today date at the CLI; defaults to the system date. Stored on
39
+ * plan.filter so apply-time filter re-verification resolves `today`
40
+ * identically to plan time.
41
+ */
42
+ today?: string;
36
43
  };
37
44
  type WhereClause = {
38
45
  field: string;
39
- op: "eq" | "neq" | "contains" | "notcontains" | "empty" | "notempty";
46
+ op: "eq" | "neq" | "contains" | "notcontains" | "empty" | "notempty" | "lt" | "gt" | "lte" | "gte";
40
47
  value?: string;
41
48
  raw: string;
42
49
  };
@@ -44,9 +51,14 @@ export declare function parseWhere(expr: string): WhereClause;
44
51
  /** True when `field` is filterable for this object type (relational pseudo-fields included). */
45
52
  export declare function isFilterableField(objectType: BulkUpdateOptions["objectType"], field: string): boolean;
46
53
  export declare function parseGuard(raw: string): PlanGuard;
47
- /** Ids of records matching a filter — used for apply-time filter re-verification. */
48
- export declare function eligibleIds(snapshot: CanonicalGtmSnapshot, objectType: BulkUpdateOptions["objectType"], where: string[]): Set<string>;
54
+ /**
55
+ * Ids of records matching a filter used for apply-time filter
56
+ * re-verification. `today` resolves the comparison `today` literal; apply-time
57
+ * callers pass the value the plan was built with (stored on plan.filter.today)
58
+ * so re-verification uses the SAME today, defaulting to the system date.
59
+ */
60
+ export declare function eligibleIds(snapshot: CanonicalGtmSnapshot, objectType: BulkUpdateOptions["objectType"], where: string[], today?: string): Set<string>;
49
61
  /** Evaluate a plan guard against a snapshot. Returns null when satisfied, else a failure detail. */
50
- export declare function evaluateGuard(snapshot: CanonicalGtmSnapshot, guard: PlanGuard): string | null;
62
+ export declare function evaluateGuard(snapshot: CanonicalGtmSnapshot, guard: PlanGuard, today?: string): string | null;
51
63
  export declare function buildBulkUpdatePlan(snapshot: CanonicalGtmSnapshot, options: BulkUpdateOptions): PatchPlan;
52
64
  export {};
@@ -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;
package/dist/cli.js CHANGED
@@ -2029,6 +2029,9 @@ async function bulkUpdateCommand(args) {
2029
2029
  guard: repeatedOption(rest, "--guard"),
2030
2030
  reason: option(rest, "--reason") ?? undefined,
2031
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,
2032
2035
  });
2033
2036
  await emitPlan(plan, rest);
2034
2037
  }
@@ -2618,7 +2621,30 @@ function rejectArgvSecret(args, ...flags) {
2618
2621
  }
2619
2622
  }
2620
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
+ }
2621
2646
  async function brokerLogin(baseUrl) {
2647
+ const viaUrl = assertSecureBrokerUrl(baseUrl);
2622
2648
  const base = baseUrl.replace(/\/$/, "");
2623
2649
  const os = await import("node:os");
2624
2650
  // Self-reported, shown to the approver so they can recognize this request
@@ -2640,8 +2666,22 @@ async function brokerLogin(baseUrl) {
2640
2666
  throw new Error(`Could not start pairing with ${base} (${startResponse.status}). Is this a FullStackGTM deployment?`);
2641
2667
  }
2642
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
+ }
2643
2678
  console.error(`\nPairing code: ${start.userCode}\n\nApprove this CLI ("${requesterLabel}") in your dashboard:\n\n ${start.verificationUrl}\n`);
2644
- 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
+ }
2645
2685
  const deadline = Date.now() + (start.expiresInSeconds ?? 600) * 1000;
2646
2686
  const intervalMs = Math.max(0, (start.intervalSeconds ?? 3) * 1000);
2647
2687
  while (Date.now() < deadline) {
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;