thumbgate 1.28.0 → 1.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.
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const crypto = require('crypto');
3
+ const crypto = require('node:crypto');
4
4
 
5
5
  const TRANSPORT_KEYS = new Set([
6
6
  'hookeventname',
@@ -52,10 +52,136 @@ const TRANSPORT_WORDS = new Set([
52
52
  'json',
53
53
  ]);
54
54
 
55
+ // Transport keys that distinguish hook/session envelopes from ordinary JSON
56
+ // tool inputs. A JSON object is rejected only when it carries one of these
57
+ // keys; valid inputs such as {"filePath":"AGENTS.md"} must remain searchable.
58
+ const REJECT_SUBSTRINGS = [
59
+ 'session_id',
60
+ 'transcript_path',
61
+ 'prompt_id',
62
+ 'hook_event_name',
63
+ ];
64
+ const REJECT_JSON_KEYS = new Set([
65
+ ...REJECT_SUBSTRINGS,
66
+ 'sessionid',
67
+ 'transcriptpath',
68
+ 'promptid',
69
+ 'hookeventname',
70
+ ]);
71
+
72
+ // A token is treated as a filesystem-path fragment when it contains a path
73
+ // separator, ends in a machine-file extension, or is a bare UUID.
74
+ function isPathToken(token) {
75
+ if (!token) return false;
76
+ return (
77
+ token.includes('/') ||
78
+ token.includes('\\') ||
79
+ /\.(?:jsonl|json|log|sqlite|db)$/i.test(token) ||
80
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(token)
81
+ );
82
+ }
83
+
84
+ function parseJsonValue(text) {
85
+ try {
86
+ return { ok: true, value: JSON.parse(text) };
87
+ } catch (error) {
88
+ if (!(error instanceof SyntaxError)) throw error;
89
+ return { ok: false, value: null };
90
+ }
91
+ }
92
+
93
+ function hasTransportJsonKey(text) {
94
+ const trimmed = String(text || '').trim();
95
+ if (!/^[[{]/.test(trimmed)) return false;
96
+ const parsedResult = parseJsonValue(trimmed);
97
+ if (!parsedResult.ok) return false;
98
+ const parsed = parsedResult.value;
99
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
100
+ const keys = new Set(Object.keys(parsed).map((key) => key.toLowerCase()));
101
+ return [...keys].some((key) => REJECT_JSON_KEYS.has(key));
102
+ }
103
+
104
+ // Positive rejection guard. Returns true when `text` is a transport/metadata
105
+ // blob that must never be stored as a lesson, because it:
106
+ // (a) is a JSON object carrying a hook/session transport key, OR
107
+ // (b) contains multiple known transport marker substrings, OR
108
+ // (c) is dominated by filesystem-path tokens.
109
+ function looksLikeTransportBlob(text) {
110
+ const trimmed = String(text || '').trim();
111
+ if (!trimmed) return false;
112
+
113
+ if (hasTransportJsonKey(trimmed)) return true;
114
+
115
+ // Multiple marker names identify non-JSON transport residue. A human
116
+ // sentence that merely discusses `session_id` is not itself an envelope.
117
+ const lower = trimmed.toLowerCase();
118
+ const markerCount = REJECT_SUBSTRINGS.filter((marker) => lower.includes(marker)).length;
119
+ if (markerCount >= 2) return true;
120
+
121
+ // (b) Dominated by filesystem-path tokens.
122
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
123
+ if (tokens.length > 0) {
124
+ const pathTokens = tokens.filter(isPathToken);
125
+ if (pathTokens.length > 0 && pathTokens.length / tokens.length > 0.5) {
126
+ return true;
127
+ }
128
+ }
129
+
130
+ return false;
131
+ }
132
+
133
+ function firstStringField(record, names) {
134
+ for (const name of names) {
135
+ if (typeof record[name] === 'string' && record[name].trim()) return record[name].trim();
136
+ if (typeof record[name] === 'number' && Number.isFinite(record[name])) return String(record[name]);
137
+ }
138
+ return null;
139
+ }
140
+
141
+ /**
142
+ * Extract the human prompt plus the minimum source metadata needed to identify
143
+ * one hook event. Transcript paths and raw identifiers never enter lesson text;
144
+ * callers hash the source fields before persistence.
145
+ */
146
+ function extractPromptEnvelope(rawStdin) {
147
+ const raw = typeof rawStdin === 'string' ? rawStdin : '';
148
+ const trimmed = raw.trim();
149
+ if (!trimmed) return { prompt: '' };
150
+
151
+ if (/^[[{]/.test(trimmed)) {
152
+ const parsedResult = parseJsonValue(trimmed);
153
+ if (!parsedResult.ok) return { prompt: trimmed };
154
+ const parsed = parsedResult.value;
155
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
156
+ return {
157
+ prompt: typeof parsed.prompt === 'string' ? parsed.prompt.trim() : '',
158
+ sessionId: firstStringField(parsed, ['session_id', 'sessionId']),
159
+ promptId: firstStringField(parsed, ['prompt_id', 'promptId']),
160
+ projectDir: firstStringField(parsed, ['cwd', 'project', 'project_dir', 'projectDir']),
161
+ timestamp: firstStringField(parsed, ['timestamp', 'created_at', 'createdAt']),
162
+ hookEventName: firstStringField(parsed, ['hook_event_name', 'hookEventName']),
163
+ };
164
+ }
165
+ return { prompt: '' };
166
+ }
167
+
168
+ return { prompt: trimmed };
169
+ }
170
+
171
+ // Persist ONLY the human `.prompt` field from a hook transport envelope.
172
+ function extractPromptText(rawStdin) {
173
+ return extractPromptEnvelope(rawStdin).prompt;
174
+ }
175
+
55
176
  function stripEphemeralText(text) {
56
177
  if (!text || typeof text !== 'string') return '';
57
- return String(text)
58
- .replace(/["']?(?:hook_?event_?name|session_?id|transcript_?path|timestamp|created_?at|updated_?at|cwd|pid|process_?id|prompt_?id|trace_?id|request_?id|install_?id|visitor_?session_?id)["']?\s*[:=]\s*["']?[^"',}\]\s]+["']?/gi, ' ')
178
+ let stripped = String(text);
179
+ for (const key of TRANSPORT_KEYS) {
180
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
181
+ const assignment = new RegExp(String.raw`["']?${escapedKey}["']?\s*[:=]\s*["']?[^"',}\]\s]+["']?`, 'gi');
182
+ stripped = stripped.replace(assignment, ' ');
183
+ }
184
+ return stripped
59
185
  .replace(/\/(?:private\/)?tmp\/[^\s"',}\]]+/gi, ' ')
60
186
  .replace(/\/var\/folders\/[^\s"',}\]]+/gi, ' ')
61
187
  .replace(/\/Users\/[^/\s]+\/\.(?:claude|codex|thumbgate)\/[^\s"',}\]]+/gi, ' ')
@@ -68,6 +194,10 @@ function stripEphemeralText(text) {
68
194
  }
69
195
 
70
196
  function transportWordsOnly(text) {
197
+ // Positive rejection first: JSON payloads, transport markers, and
198
+ // path-dominated blobs are always "transport only" regardless of the
199
+ // incidental non-transport words they contain.
200
+ if (looksLikeTransportBlob(text)) return true;
71
201
  const tokens = String(text || '')
72
202
  .toLowerCase()
73
203
  .replace(/[^a-z0-9_ -]/g, ' ')
@@ -78,6 +208,20 @@ function transportWordsOnly(text) {
78
208
  }
79
209
 
80
210
  function sanitizeFeedbackText(text) {
211
+ const raw = String(text || '').trim();
212
+ // A raw hook-stdin PAYLOAD — a pure JSON object carrying transport markers
213
+ // (session_id / transcript_path / prompt_id / hook_event_name) — is never a
214
+ // human lesson; reject it whole, even when it also carries a `prompt` field
215
+ // (that field is pulled out separately via extractPromptText). But do NOT
216
+ // reject content JSON that carries no transport markers (e.g. a tool input
217
+ // {"filePath":"…","reason":"…"}): it must survive so it can be matched against
218
+ // patterns. The discriminator is the marker, not JSON-ness.
219
+ if (hasTransportJsonKey(raw)) return '';
220
+ // Otherwise scrub ephemeral marker key:value pairs and volatile paths, then
221
+ // reject only if nothing substantive remains — so real feedback that arrives
222
+ // next to hook metadata keeps the sentence while the metadata is stripped, and
223
+ // space-separated marker residue / path-dominated blobs collapse to
224
+ // transport-only text and are dropped by transportWordsOnly().
81
225
  const stripped = stripEphemeralText(text)
82
226
  .replace(/\/Users\/[^\s/]+/g, '/Users/redacted')
83
227
  .replace(/\s+/g, ' ')
@@ -99,7 +243,11 @@ function actionFingerprint(parts) {
99
243
 
100
244
  module.exports = {
101
245
  TRANSPORT_WORDS,
246
+ REJECT_SUBSTRINGS,
102
247
  sanitizeFeedbackText,
103
248
  actionFingerprint,
104
249
  transportWordsOnly,
250
+ looksLikeTransportBlob,
251
+ extractPromptEnvelope,
252
+ extractPromptText,
105
253
  };
@@ -131,42 +131,40 @@ const DOWNLOAD_EXEC_CHAIN_PATTERN = /\b(?:curl|wget)\b[\s\S]{0,400}(?:\|\s*(?:ba
131
131
  const DESTRUCTIVE_OR_PRIVILEGE_BOUNDARY_PATTERN = /\b(?:rm\s+-rf|chmod\s+(?:\+x|777)|chown\b|sudo\b|dd\s+if=|mkfs|git\s+reset\s+--hard|git\s+clean\s+-f[a-z]*|kubectl\s+(?:apply|delete)|terraform\s+(?:apply|destroy)|railway\s+(?:deploy|up)|gcloud\s+(?:run\s+deploy|app\s+deploy)|vercel\s+--prod|firebase\s+deploy)\b/i;
132
132
 
133
133
  // ---------------------------------------------------------------------------
134
- // Enforcement posture (CEO decision 2026-06-04): warn-by-default.
135
- // The firewall ALWAYS fires and logs every decision, but most gates WARN rather
136
- // than hard-block — only TRULY CATASTROPHIC, irreversible actions hard-block:
137
- // - secret exfiltration (handled on its own deny path; never downgraded)
138
- // - security-vulnerability / supply-chain denies (own deny path; not downgraded)
139
- // - irreversibly destructive filesystem commands (rm -rf class, mkfs, dd to disk,
140
- // fork bomb) — kept as hard deny via DESTRUCTIVE_FS_PATTERN below.
141
- // Everything else (memory-high-risk, workflow-sequence, off-scope, git push, deploy,
142
- // approval gates) downgrades deny/approve -> warn so legitimate work is never blocked.
143
- // Opt back into full hard enforcement with THUMBGATE_STRICT_ENFORCEMENT=1.
144
134
  // Enforcement posture (CEO decision 2026-06-04): WARN + AUDIT by default.
145
- // The firewall fires and LOGS every decision, but downgrades deny/approve -> warn so
146
- // legitimate work is never hard-blocked. We deliberately do NOT try to hard-block
147
- // arbitrary destructive commands here: a regex "catastrophic floor" is unwinnable
148
- // (sudo / bash -c / find -exec / eval / base64|sh all evade it) and gives false confidence.
149
- // HARD enforcement is an explicit opt-in via THUMBGATE_STRICT_ENFORCEMENT=1, which keeps
150
- // the engine's FULL gate set (its high-risk-command gates catch prefixed/obfuscated forms
151
- // far better than any single regex). Secret exfiltration and the security-vulnerability
152
- // scan hard-deny on their OWN paths before this runs, so irreversible data-leak / supply
153
- // chain risks stay blocked regardless of posture.
154
- // Self-protective gates guard ThumbGate's own guardrails (kill/env/config/hooks). See the
155
- // self-protect-binds-regardless-of-posture changeset for the full rationale.
135
+ // Unconditional floors run before posture conversion: secret exfiltration, deny results
136
+ // from the security scanner, and the four canonical self-protection gates. Arbitrary
137
+ // destructive commands do not get a regex-based "catastrophic" floor; wrappers and
138
+ // obfuscation make that boundary misleading. Every other deny/approve becomes a warning
139
+ // unless the operator explicitly enables THUMBGATE_STRICT_ENFORCEMENT=1.
140
+ const SELF_PROTECT_HARD_FLOOR_GATE_IDS = new Set([
141
+ 'self-protect-config',
142
+ 'self-protect-kill',
143
+ 'self-protect-env-override',
144
+ 'self-protect-hooks-disable',
145
+ ]);
146
+ const UNCONDITIONAL_HARD_FLOOR_GATE_IDS = new Set([
147
+ 'secret-exfiltration',
148
+ 'security-vuln-scan',
149
+ 'slopsquat-guard',
150
+ ...SELF_PROTECT_HARD_FLOOR_GATE_IDS,
151
+ ]);
152
+ const SELF_PROTECT_CONFIG_TARGET_PATTERN = /(?:^|\/)(?:config\/gates\/|config\/(?:budget|enforcement|mcp-allowlists)\.json$|\.thumbgate\/config\.json$|thumbgate\.json$)/i;
153
+ const SELF_PROTECT_HOOK_TARGET_PATTERN = /(?:^|\/)(?:\.claude\/settings(?:\.local)?\.json|\.codex\/config\.toml|scripts\/hook-[^/]+\.(?:js|sh))$/i;
154
+ const SELF_PROTECT_CONFIG_COMMAND_PATTERN = /(?:config\/gates\/|config\/(?:budget|enforcement|mcp-allowlists)\.json\b|\.thumbgate\/config\.json\b|thumbgate\.json\b)/i;
155
+ const SELF_PROTECT_HOOK_COMMAND_PATTERN = /(?:\.claude\/settings(?:\.local)?\.json|\.codex\/config\.toml|scripts\/hook-[^\s'";|]+\.(?:js|sh))\b/i;
156
+ const SHELL_FILE_MUTATION_PATTERN = /\b(?:sed\s+-i|perl\s+-pi|python\d*\s+-c|node\s+-e|ruby\s+-e|tee|truncate|rm|mv|cp|install|patch|jq)\b|(?:^|[\s;&|])>{1,2}\s*\S/i;
157
+
156
158
  function isSelfProtectGate(gateId) {
157
- return typeof gateId === 'string' && gateId.startsWith('self-protect');
159
+ return SELF_PROTECT_HARD_FLOOR_GATE_IDS.has(gateId);
158
160
  }
159
161
 
160
162
  function applyEnforcementPosture(result) {
161
163
  if (!result || (result.decision !== 'deny' && result.decision !== 'approve')) return result;
164
+ // Defensive backstop: hard-floor results must never be posture-downgraded.
165
+ if (UNCONDITIONAL_HARD_FLOOR_GATE_IDS.has(result.gate)) return result;
162
166
  // Full hard enforcement opt-in: keep every deny.
163
167
  if (process.env.THUMBGATE_STRICT_ENFORCEMENT === '1') return result;
164
- // Self-protection binds regardless of posture: a warning is worthless once the guardrail is
165
- // gone. Owner escape: THUMBGATE_SELF_PROTECT_OVERRIDE=1 (break-glass covers .claude/settings*
166
- // but not this surface, so it needs its own escape — self-lockout, 2026-07-07).
167
- if (isSelfProtectGate(result.gate) && process.env.THUMBGATE_SELF_PROTECT_OVERRIDE !== '1') {
168
- return result;
169
- }
170
168
  // Honor the explicit strict-knowledge-conflict opt-in for that gate.
171
169
  if (process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1' && result.gate === 'knowledge-conflict-gate') return result;
172
170
  // Warn-by-default: the gate still fired and is recorded; the action is allowed through
@@ -175,7 +173,7 @@ function applyEnforcementPosture(result) {
175
173
  ...result,
176
174
  decision: 'warn',
177
175
  warnByDefault: true,
178
- message: `${result.message}\n\n⚠️ ThumbGate is in warn-by-default mode — this was flagged and logged, not blocked. Set THUMBGATE_STRICT_ENFORCEMENT=1 to hard-block, or THUMBGATE_HOTFIX_BYPASS=1 to disable checks entirely.`,
176
+ message: `${result.message}\n\n⚠️ ThumbGate is in warn-by-default mode — this was flagged and logged, not blocked. Set THUMBGATE_STRICT_ENFORCEMENT=1 to hard-block other flagged actions.`,
179
177
  };
180
178
  }
181
179
  const BREAK_GLASS_CONDITION = 'thumbgate_break_glass';
@@ -1710,6 +1708,10 @@ function matchGate(gate, toolName, toolInput = {}) {
1710
1708
  const governanceState = loadGovernanceState();
1711
1709
  const constraints = loadConstraints();
1712
1710
 
1711
+ if (isSelfProtectGate(gate.id) && hasActiveProtectedApproval(governanceState, affectedFiles)) {
1712
+ return { matched: false, matchText, affectedFiles };
1713
+ }
1714
+
1713
1715
  if (gate.id === 'on-demand-freeze-mode' || (gate.when && gate.when.constraints && gate.when.constraints.freeze_mode)) {
1714
1716
  let freezePaths = [];
1715
1717
  if (process.env.THUMBGATE_FREEZE_PATHS) {
@@ -1817,6 +1819,78 @@ function matchesGate(gate, toolName, toolInput) {
1817
1819
  return matchGate(gate, toolName, toolInput).matched;
1818
1820
  }
1819
1821
 
1822
+ function hasActiveProtectedApproval(governanceState, affectedFiles) {
1823
+ if (!Array.isArray(affectedFiles) || affectedFiles.length === 0) return false;
1824
+ const approvals = Array.isArray(governanceState && governanceState.protectedApprovals)
1825
+ ? governanceState.protectedApprovals
1826
+ : [];
1827
+ return affectedFiles.every((filePath) => approvals.some((entry) => {
1828
+ return matchesAnyGlob(filePath, sanitizeGlobList(entry && entry.pathGlobs));
1829
+ }));
1830
+ }
1831
+
1832
+ function matchSelfProtectHardFloor(gate, toolName, toolInput = {}) {
1833
+ const affected = extractAffectedFiles(toolName, toolInput);
1834
+ const affectedFiles = affected.files;
1835
+ if (hasActiveProtectedApproval(loadGovernanceState(), affectedFiles)) return null;
1836
+
1837
+ const command = String(toolInput.command || '');
1838
+ let matchText = command;
1839
+ if (gate.id === 'self-protect-config' || gate.id === 'self-protect-hooks-disable') {
1840
+ const targetPattern = gate.id === 'self-protect-config'
1841
+ ? SELF_PROTECT_CONFIG_TARGET_PATTERN
1842
+ : SELF_PROTECT_HOOK_TARGET_PATTERN;
1843
+ if (EDIT_LIKE_TOOLS.has(toolName)) {
1844
+ matchText = affectedFiles.join(' ');
1845
+ if (!targetPattern.test(matchText)) return null;
1846
+ } else if (toolName === 'Bash') {
1847
+ const commandTargetPattern = gate.id === 'self-protect-config'
1848
+ ? SELF_PROTECT_CONFIG_COMMAND_PATTERN
1849
+ : SELF_PROTECT_HOOK_COMMAND_PATTERN;
1850
+ if (!SHELL_FILE_MUTATION_PATTERN.test(command) || !commandTargetPattern.test(command)) return null;
1851
+ } else {
1852
+ return null;
1853
+ }
1854
+ } else {
1855
+ if (!Array.isArray(gate.toolNames) || !gate.toolNames.includes(toolName)) return null;
1856
+ if (!matchText || !gate.pattern) return null;
1857
+ try {
1858
+ if (!new RegExp(gate.pattern).test(matchText)) return null;
1859
+ } catch {
1860
+ return null;
1861
+ }
1862
+ }
1863
+
1864
+ return {
1865
+ matched: true,
1866
+ matchText,
1867
+ affectedFiles,
1868
+ };
1869
+ }
1870
+
1871
+ function evaluateSelfProtectHardFloor(input = {}) {
1872
+ const toolName = input.tool_name || input.toolName || '';
1873
+ const toolInput = input.tool_input || input.toolInput || {};
1874
+ const config = loadGatesConfig(DEFAULT_CONFIG_PATH);
1875
+
1876
+ for (const gate of config.gates) {
1877
+ if (!isSelfProtectGate(gate.id)) continue;
1878
+ const matchDetails = matchSelfProtectHardFloor(gate, toolName, toolInput);
1879
+ if (!matchDetails) continue;
1880
+
1881
+ const result = {
1882
+ decision: 'deny',
1883
+ gate: gate.id,
1884
+ message: buildGateMessage(gate, matchDetails),
1885
+ severity: gate.severity,
1886
+ reasoning: buildReasoning(gate, toolName, toolInput, matchDetails),
1887
+ };
1888
+ return recordStructuralGateBlock(toolName, toolInput, result);
1889
+ }
1890
+
1891
+ return null;
1892
+ }
1893
+
1820
1894
  function isSafeLocalCredentialHardeningCommand(toolName, toolInput = {}) {
1821
1895
  if (toolName !== 'Bash') return false;
1822
1896
  const command = String(toolInput.command || '').trim();
@@ -2643,6 +2717,26 @@ function evaluateSecretGuard(input = {}) {
2643
2717
  return result;
2644
2718
  }
2645
2719
 
2720
+ function evaluateUnconditionalHardFloor(input = {}) {
2721
+ const secretGuard = evaluateSecretGuard(input);
2722
+ if (secretGuard) return { hardFloor: secretGuard, securityScan: null };
2723
+
2724
+ const securityScan = evaluateSecurityScan(input);
2725
+ if (securityScan && securityScan.decision === 'deny') {
2726
+ return { hardFloor: securityScan, securityScan };
2727
+ }
2728
+
2729
+ return {
2730
+ hardFloor: evaluateSelfProtectHardFloor(input),
2731
+ securityScan,
2732
+ };
2733
+ }
2734
+
2735
+ function runHardFloor(input) {
2736
+ const { hardFloor } = evaluateUnconditionalHardFloor(input);
2737
+ return hardFloor ? formatOutput(hardFloor) : null;
2738
+ }
2739
+
2646
2740
  // ---------------------------------------------------------------------------
2647
2741
  function isApprovalGatesEnabled() {
2648
2742
  return process.env.THUMBGATE_APPROVAL_GATES !== '0';
@@ -2996,16 +3090,8 @@ function mergeContextStrings(...ctxs) {
2996
3090
  }
2997
3091
 
2998
3092
  async function runAsync(input) {
2999
- const secretGuard = evaluateSecretGuard(input);
3000
- if (secretGuard) {
3001
- return formatOutput(secretGuard);
3002
- }
3003
-
3004
- // Security vulnerability scan (Tier 1: pattern match, Tier 2: supply chain)
3005
- const securityScan = evaluateSecurityScan(input);
3006
- if (securityScan && securityScan.decision === 'deny') {
3007
- return formatOutput(securityScan);
3008
- }
3093
+ const { hardFloor, securityScan } = evaluateUnconditionalHardFloor(input);
3094
+ if (hardFloor) return formatOutput(hardFloor);
3009
3095
 
3010
3096
  const toolName = input.tool_name || '';
3011
3097
  const toolInput = input.tool_input || {};
@@ -3043,16 +3129,8 @@ async function runAsync(input) {
3043
3129
  }
3044
3130
 
3045
3131
  function run(input) {
3046
- const secretGuard = evaluateSecretGuard(input);
3047
- if (secretGuard) {
3048
- return formatOutput(secretGuard);
3049
- }
3050
-
3051
- // Security vulnerability scan (Tier 1: pattern match, Tier 2: supply chain)
3052
- const securityScan = evaluateSecurityScan(input);
3053
- if (securityScan && securityScan.decision === 'deny') {
3054
- return formatOutput(securityScan);
3055
- }
3132
+ const { hardFloor, securityScan } = evaluateUnconditionalHardFloor(input);
3133
+ if (hardFloor) return formatOutput(hardFloor);
3056
3134
 
3057
3135
  const toolName = input.tool_name || '';
3058
3136
  const toolInput = input.tool_input || {};
@@ -3379,6 +3457,7 @@ module.exports = {
3379
3457
  computeExecutableHash,
3380
3458
  formatOutput,
3381
3459
  isApprovalGatesEnabled,
3460
+ runHardFloor,
3382
3461
  run,
3383
3462
  runAsync,
3384
3463
  trackAction,
@@ -13,6 +13,9 @@ const DEFAULT_PRO_PRICE_DOLLARS = PRO_MONTHLY_PRICE_DOLLARS;
13
13
  const DEFAULT_PRO_PRICE_LABEL = PRO_PRICE_LABEL;
14
14
  const DEFAULT_SPRINT_DIAGNOSTIC_PRICE_DOLLARS = 499;
15
15
  const DEFAULT_WORKFLOW_SPRINT_PRICE_DOLLARS = 1500;
16
+ // Keep fallbacks price-aligned. Sprint must NOT share the diagnostic Stripe slug.
17
+ const DEFAULT_SPRINT_DIAGNOSTIC_CHECKOUT_URL = 'https://buy.stripe.com/9B69ATbmI4r4aK5eOD3sI3k';
18
+ const DEFAULT_WORKFLOW_SPRINT_CHECKOUT_URL = 'https://www.paypal.com/ncp/payment/LTQFR7P9AR3QG';
16
19
  const GA_MEASUREMENT_ID_PATTERN = /^G-[A-Z0-9]+$/i;
17
20
 
18
21
  function normalizeOrigin(value) {
@@ -127,8 +130,10 @@ function resolveHostedBillingConfig({ requestOrigin } = {}, env = process.env) {
127
130
  const gaMeasurementId = normalizeTrackingId(env.THUMBGATE_GA_MEASUREMENT_ID, GA_MEASUREMENT_ID_PATTERN);
128
131
  const posthogApiKey = env.POSTHOG_API_KEY || '';
129
132
  const googleSiteVerification = normalizeTrackingId(env.THUMBGATE_GOOGLE_SITE_VERIFICATION);
130
- const sprintDiagnosticCheckoutUrl = normalizeAbsoluteUrl(env.THUMBGATE_SPRINT_DIAGNOSTIC_CHECKOUT_URL);
131
- const workflowSprintCheckoutUrl = normalizeAbsoluteUrl(env.THUMBGATE_WORKFLOW_SPRINT_CHECKOUT_URL);
133
+ const sprintDiagnosticCheckoutUrl = normalizeAbsoluteUrl(env.THUMBGATE_SPRINT_DIAGNOSTIC_CHECKOUT_URL)
134
+ || DEFAULT_SPRINT_DIAGNOSTIC_CHECKOUT_URL;
135
+ const workflowSprintCheckoutUrl = normalizeAbsoluteUrl(env.THUMBGATE_WORKFLOW_SPRINT_CHECKOUT_URL)
136
+ || DEFAULT_WORKFLOW_SPRINT_CHECKOUT_URL;
132
137
 
133
138
  return {
134
139
  appOrigin,
@@ -159,6 +164,8 @@ module.exports = {
159
164
  DEFAULT_PRO_PRICE_LABEL,
160
165
  DEFAULT_SPRINT_DIAGNOSTIC_PRICE_DOLLARS,
161
166
  DEFAULT_WORKFLOW_SPRINT_PRICE_DOLLARS,
167
+ DEFAULT_SPRINT_DIAGNOSTIC_CHECKOUT_URL,
168
+ DEFAULT_WORKFLOW_SPRINT_CHECKOUT_URL,
162
169
  GA_MEASUREMENT_ID_PATTERN,
163
170
  normalizeAbsoluteUrl,
164
171
  normalizeOrigin,
@@ -226,12 +226,13 @@ function parseJsonOrNull(bodyText) {
226
226
  }
227
227
  }
228
228
 
229
- async function postResendEmail({ fetcher, apiKey, payload }) {
229
+ async function postResendEmail({ fetcher, apiKey, payload, idempotencyKey }) {
230
230
  const res = await fetcher(RESEND_ENDPOINT, {
231
231
  method: 'POST',
232
232
  headers: {
233
233
  'Content-Type': 'application/json',
234
234
  Authorization: `Bearer ${apiKey}`,
235
+ ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
235
236
  },
236
237
  body: JSON.stringify(payload),
237
238
  });
@@ -256,7 +257,7 @@ function buildSendSuccess({ bodyJson, status, senderFallback }) {
256
257
  * Low-level send. Posts to the Resend API or no-ops when RESEND_API_KEY is
257
258
  * missing. Never throws on network errors; returns a structured result instead.
258
259
  */
259
- async function sendEmail({ to, subject, html, text, from, replyTo, fetchImpl, dnsResolver } = {}) {
260
+ async function sendEmail({ to, subject, html, text, from, replyTo, idempotencyKey, fetchImpl, dnsResolver } = {}) {
260
261
  validateSendEmailInput({ to, subject, html, text });
261
262
 
262
263
  const apiKey = getApiKey();
@@ -278,7 +279,12 @@ async function sendEmail({ to, subject, html, text, from, replyTo, fetchImpl, dn
278
279
  }
279
280
 
280
281
  try {
281
- const { res, bodyText, bodyJson } = await postResendEmail({ fetcher, apiKey, payload });
282
+ const { res, bodyText, bodyJson } = await postResendEmail({
283
+ fetcher,
284
+ apiKey,
285
+ payload,
286
+ idempotencyKey: isNonEmptyString(idempotencyKey) ? idempotencyKey.slice(0, 256) : null,
287
+ });
282
288
  if (!res.ok) {
283
289
  // eslint-disable-next-line no-console
284
290
  console.warn(`[mailer] Resend returned ${res.status}:`, bodyText);
@@ -490,7 +496,7 @@ function renderTrialWelcomeBodies({ licenseKey, customerId, customerName, trialE
490
496
  * Never throws on send failures (beyond input validation); the Stripe webhook
491
497
  * must keep working even if email breaks.
492
498
  */
493
- async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName, trialEndAt, fetchImpl, dnsResolver } = {}) {
499
+ async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName, trialEndAt, idempotencyKey, fetchImpl, dnsResolver } = {}) {
494
500
  if (!isNonEmptyString(to)) throw new Error('sendTrialWelcomeEmail: `to` is required');
495
501
  if (!isNonEmptyString(licenseKey)) throw new Error('sendTrialWelcomeEmail: `licenseKey` is required');
496
502
 
@@ -500,7 +506,7 @@ async function sendTrialWelcomeEmail({ to, licenseKey, customerId, customerName,
500
506
  ? `${name}, your ThumbGate Pro key is inside`
501
507
  : 'Your ThumbGate Pro key is inside';
502
508
 
503
- return sendEmail({ to, subject, html, text, replyTo: getReplyTo(), fetchImpl, dnsResolver });
509
+ return sendEmail({ to, subject, html, text, replyTo: getReplyTo(), idempotencyKey, fetchImpl, dnsResolver });
504
510
  }
505
511
 
506
512
  function renderNewsletterWelcomeBodies() {