thumbgate 1.26.2 → 1.26.3

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.
@@ -109,9 +109,21 @@ const DEFAULT_PROTECTED_FILE_GLOBS = [
109
109
  const EDIT_LIKE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
110
110
  const HIGH_RISK_BASH_PATTERN = /\b(?:git\s+(?:add|commit|push)|gh\s+pr\s+(?:create|merge)|npm\s+publish|yarn\s+publish|pnpm\s+publish|rm\s+-rf)\b/i;
111
111
  const REMOTE_SIDE_EFFECT_BASH_PATTERN = /\b(?:git\s+push\b|gh\s+pr\s+(?:create|merge|close|reopen|ready|edit)\b|gh\s+release\s+(?:create|delete|edit|upload)\b|npm\s+publish\b|yarn\s+publish\b|pnpm\s+publish\b)\b/i;
112
+ const GH_API_PR_CREATE_PATTERN = /\bgh\s+api\b(?=.*(?:\/pulls\b|repos\/[^\s]+\/[^\s]+\/pulls\b))(?=.*(?:-f\b|--field\b|-F\b|--raw-field\b|--method\s+POST\b|-X\s+POST\b))/i;
112
113
  const BOOSTED_RISK_BLOCK_SCORE = 0.8;
113
114
  const BOOSTED_RISK_MIN_EXAMPLES = 3;
114
115
  const PR_THREAD_RESOLUTION_ACTION = 'pr_thread_resolution_verified_after_commit';
116
+ const KNOWLEDGE_ENTROPY_THRESHOLD = 0.7;
117
+ const KNOWLEDGE_CONFLICT_STRICT_BASH_PATTERN = /\b(?:git\s+push\b|gh\s+pr\s+merge\b|gh\s+release\s+(?:create|delete|edit|upload)\b|(?:npm|yarn|pnpm)\s+publish\b|rm\s+-rf\b|git\s+reset\s+--hard\b|git\s+clean\s+-f|railway\s+(?:deploy|up)\b|gcloud\s+(?:run\s+deploy|app\s+deploy)\b|firebase\s+deploy\b|vercel\s+--prod\b|kubectl\s+(?:apply|delete)\b|terraform\s+(?:apply|destroy)\b)\b/i;
118
+ const BREAK_GLASS_CONDITION = 'thumbgate_break_glass';
119
+ const BREAK_GLASS_SETTINGS_GLOBS = [
120
+ '.claude/settings.local.json',
121
+ '.claude/settings.json',
122
+ '**/.claude/settings.local.json',
123
+ '**/.claude/settings.json',
124
+ '.codex/config.toml',
125
+ '**/.codex/config.toml',
126
+ ];
115
127
 
116
128
  function isRuntimePlanGateEnabled() {
117
129
  return process.env.THUMBGATE_PLAN_GATE === '1' || process.env.THUMBGATE_PLAN_GATE === 'true';
@@ -360,6 +372,38 @@ function approveProtectedAction(input = {}) {
360
372
  return entry;
361
373
  }
362
374
 
375
+ function breakGlassEmergency(input = {}) {
376
+ const reason = String(input.reason || '').trim();
377
+ if (!reason) {
378
+ throw new Error('reason is required');
379
+ }
380
+
381
+ const ttlMs = Math.min(clampTtlMs(input.ttlMs, TTL_MS), TTL_MS);
382
+ const evidence = `BREAK GLASS: ${reason}`;
383
+ const gates = ['pr_create_allowed', 'pr_threads_checked', BREAK_GLASS_CONDITION];
384
+ const satisfied = {};
385
+ for (const gateId of gates) {
386
+ satisfied[gateId] = satisfyCondition(gateId, evidence);
387
+ }
388
+
389
+ const approval = approveProtectedAction({
390
+ pathGlobs: BREAK_GLASS_SETTINGS_GLOBS,
391
+ reason: evidence,
392
+ evidence,
393
+ ttlMs,
394
+ });
395
+
396
+ return {
397
+ ok: true,
398
+ reason,
399
+ ttlMs,
400
+ expiresAt: new Date(Date.now() + ttlMs).toISOString(),
401
+ satisfied,
402
+ approval,
403
+ settingsGlobs: BREAK_GLASS_SETTINGS_GLOBS.slice(),
404
+ };
405
+ }
406
+
363
407
  function setBranchGovernance(input = {}) {
364
408
  if (input && input.clear === true) {
365
409
  const state = loadGovernanceState();
@@ -693,7 +737,7 @@ function extractAffectedFiles(toolName, toolInput = {}) {
693
737
  }
694
738
  }
695
739
 
696
- if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command)) {
740
+ if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command) || GH_API_PR_CREATE_PATTERN.test(command)) {
697
741
  for (const filePath of getBranchDiffFiles(repoRoot)) {
698
742
  files.add(normalizePosix(filePath));
699
743
  }
@@ -712,6 +756,7 @@ function isHighRiskAction(toolName, toolInput = {}, affectedFiles = []) {
712
756
  const command = String(toolInput.command || '');
713
757
  // Original high-risk pattern (git writes, publishes, destructive ops)
714
758
  if (HIGH_RISK_BASH_PATTERN.test(command)) return true;
759
+ if (GH_API_PR_CREATE_PATTERN.test(command)) return true;
715
760
  // Broadened: any Bash command that modifies files or has side effects.
716
761
  // Excludes pure read/analysis commands (node --test, cat, ls, echo, etc.)
717
762
  // to avoid false positives on benign operations.
@@ -949,7 +994,8 @@ function getLocalOnlyScopeSources(governanceState = {}, constraints = {}) {
949
994
 
950
995
  function isRemoteSideEffectCommand(toolName, toolInput = {}) {
951
996
  if (toolName !== 'Bash') return false;
952
- return REMOTE_SIDE_EFFECT_BASH_PATTERN.test(String(toolInput.command || ''));
997
+ const command = String(toolInput.command || '');
998
+ return REMOTE_SIDE_EFFECT_BASH_PATTERN.test(command) || GH_API_PR_CREATE_PATTERN.test(command);
953
999
  }
954
1000
 
955
1001
  function evaluateLocalOnlyRemoteSideEffectGate(toolName, toolInput = {}, governanceState = {}, constraints = {}) {
@@ -1003,6 +1049,18 @@ function shouldEnforceTaskScope(gate, governanceState, toolName, toolInput = {},
1003
1049
  return isScopeEnforcedAction(toolName, toolInput, affectedFiles);
1004
1050
  }
1005
1051
 
1052
+ function isAgentHookSettingsFile(filePath) {
1053
+ return matchesAnyGlob(filePath, BREAK_GLASS_SETTINGS_GLOBS);
1054
+ }
1055
+
1056
+ function isBreakGlassSettingsBypass(gate, affectedFiles) {
1057
+ if (!gate || !['task-scope-edit-boundary', 'protected-file-approval-required'].includes(gate.id)) {
1058
+ return false;
1059
+ }
1060
+ if (!isConditionSatisfied(BREAK_GLASS_CONDITION)) return false;
1061
+ return Array.isArray(affectedFiles) && affectedFiles.length > 0 && affectedFiles.every(isAgentHookSettingsFile);
1062
+ }
1063
+
1006
1064
  function formatFileList(files, limit = 5) {
1007
1065
  const items = Array.isArray(files) ? files.filter(Boolean) : [];
1008
1066
  if (items.length === 0) return 'none';
@@ -1234,6 +1292,12 @@ function matchGate(gate, toolName, toolInput = {}) {
1234
1292
  try {
1235
1293
  const regex = new RegExp(gate.pattern);
1236
1294
  if (!regex.test(matchText)) return { matched: false, matchText, affectedFiles };
1295
+ if (gate.id === 'permission-change-approval' && isSafeLocalCredentialHardeningCommand(toolName, toolInput)) {
1296
+ return { matched: false, matchText, affectedFiles };
1297
+ }
1298
+ if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
1299
+ return { matched: false, matchText, affectedFiles };
1300
+ }
1237
1301
  } catch {
1238
1302
  return { matched: false, matchText, affectedFiles };
1239
1303
  }
@@ -1251,6 +1315,9 @@ function matchGate(gate, toolName, toolInput = {}) {
1251
1315
 
1252
1316
  let taskScopeViolation = null;
1253
1317
  if (gate.requireTaskScope) {
1318
+ if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
1319
+ return { matched: false, matchText, affectedFiles };
1320
+ }
1254
1321
  if (!shouldEnforceTaskScope(gate, governanceState, toolName, toolInput, affectedFiles)) {
1255
1322
  return { matched: false, matchText, affectedFiles };
1256
1323
  }
@@ -1260,6 +1327,9 @@ function matchGate(gate, toolName, toolInput = {}) {
1260
1327
 
1261
1328
  let protectedApprovalViolation = null;
1262
1329
  if (gate.requireProtectedApproval) {
1330
+ if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
1331
+ return { matched: false, matchText, affectedFiles };
1332
+ }
1263
1333
  const protectedGlobs = sanitizeGlobList(
1264
1334
  Array.isArray(gate.protectedGlobs) && gate.protectedGlobs.length > 0
1265
1335
  ? gate.protectedGlobs
@@ -1299,6 +1369,27 @@ function matchesGate(gate, toolName, toolInput) {
1299
1369
  return matchGate(gate, toolName, toolInput).matched;
1300
1370
  }
1301
1371
 
1372
+ function isSafeLocalCredentialHardeningCommand(toolName, toolInput = {}) {
1373
+ if (toolName !== 'Bash') return false;
1374
+ const command = String(toolInput.command || '').trim();
1375
+ if (!command || /(?:^|\s)chmod\s+[^&;|]*\s+-R\b/i.test(command)) return false;
1376
+ if (/[;&|`$()<>*?[\]{}]/.test(command)) return false;
1377
+
1378
+ const match = command.match(/(?:^|\s)chmod\s+(?:-[fv]\s+)?0?([46]00)\s+(['"]?)(\S+)\2\s*$/i);
1379
+ if (!match) return false;
1380
+
1381
+ const target = match[3];
1382
+ if (!target || target === '/' || target === '~') return false;
1383
+ if (/^\.\.?(?:\/|$)/.test(target)) return false;
1384
+
1385
+ const normalized = target.replace(/^['"]|['"]$/g, '').toLowerCase();
1386
+ const looksLikeCredentialPath = /(?:^|\/)(?:\.config|\.ssh|\.gnupg|\.aws|\.gcloud|\.gemini|\.resume_secrets|\.thumbgate|secrets?|credentials?)(?:\/|$)/.test(normalized)
1387
+ || /(?:key|secret|token|credential|gemini|gcloud|google|operator).*\.(?:json|pem|key|env)$/i.test(normalized)
1388
+ || /\.(?:pem|key)$/i.test(normalized);
1389
+
1390
+ return looksLikeCredentialPath;
1391
+ }
1392
+
1302
1393
  function evaluateMemoryGuard(toolName, toolInput = {}) {
1303
1394
  const affected = extractAffectedFiles(toolName, toolInput);
1304
1395
  const affectedFiles = affected.files;
@@ -1315,7 +1406,10 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
1315
1406
  }
1316
1407
 
1317
1408
  const command = String(toolInput.command || '');
1318
- if (toolName === 'Bash' && /\bgh\s+pr\s+create\b/i.test(command) && isConditionSatisfied('pr_create_allowed')) {
1409
+ const isPrCreateCommand = toolName === 'Bash' && (
1410
+ /\bgh\s+pr\s+create\b/i.test(command) || GH_API_PR_CREATE_PATTERN.test(command)
1411
+ );
1412
+ if (isPrCreateCommand && isConditionSatisfied('pr_create_allowed')) {
1319
1413
  const branchGovernanceViolation = buildBranchGovernanceViolation(
1320
1414
  governanceState,
1321
1415
  toolInput,
@@ -1328,7 +1422,10 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
1328
1422
  }
1329
1423
  }
1330
1424
 
1331
- if (toolName === 'Bash' && /\b(?:gh\s+pr\s+(?:create|merge)|gh\s+release\s+create|git\s+tag\b|(?:npm|yarn|pnpm)\s+publish\b)\b/i.test(command)) {
1425
+ if (toolName === 'Bash' && (
1426
+ /\b(?:gh\s+pr\s+(?:create|merge)|gh\s+release\s+create|git\s+tag\b|(?:npm|yarn|pnpm)\s+publish\b)\b/i.test(command) ||
1427
+ GH_API_PR_CREATE_PATTERN.test(command)
1428
+ )) {
1332
1429
  const branchGovernanceViolation = buildBranchGovernanceViolation(
1333
1430
  governanceState,
1334
1431
  toolInput,
@@ -1989,12 +2086,19 @@ function isApprovalGatesEnabled() {
1989
2086
  // PreToolUse hook interface (stdin/stdout JSON)
1990
2087
  // ---------------------------------------------------------------------------
1991
2088
 
1992
- function buildReminderOutput(context) {
2089
+ function buildPreToolUseOutput(fields = {}) {
1993
2090
  return {
2091
+ hookEventName: 'PreToolUse',
2092
+ ...fields,
2093
+ };
2094
+ }
2095
+
2096
+ function buildReminderOutput(context) {
2097
+ return buildPreToolUseOutput({
1994
2098
  additionalContext: context,
1995
2099
  systemReminder: context,
1996
2100
  thumbgateSystemReminder: context,
1997
- };
2101
+ });
1998
2102
  }
1999
2103
 
2000
2104
  // ---------------------------------------------------------------------------
@@ -2047,6 +2151,7 @@ function formatOutput(result, behavioralContext) {
2047
2151
  const proCta = buildBlockActionProCta() || '';
2048
2152
  return JSON.stringify({
2049
2153
  hookSpecificOutput: {
2154
+ hookEventName: 'PreToolUse',
2050
2155
  ...reminder,
2051
2156
  permissionDecision: 'deny',
2052
2157
  permissionDecisionReason: `[GATE:${result.gate}] ${result.message}${reasoningSuffix}${reminderSuffix}${proCta}`,
@@ -2059,6 +2164,7 @@ function formatOutput(result, behavioralContext) {
2059
2164
  const reminderSuffix = behavioralContext ? `\n\nSystem reminder:\n${behavioralContext}` : '';
2060
2165
  return JSON.stringify({
2061
2166
  hookSpecificOutput: {
2167
+ hookEventName: 'PreToolUse',
2062
2168
  ...reminder,
2063
2169
  permissionDecision: 'deny',
2064
2170
  permissionDecisionReason: `[GATE:${result.gate}] APPROVAL REQUIRED: ${result.message} — Ask the human to confirm this action before proceeding.${reasoningSuffix}${reminderSuffix}`,
@@ -2071,6 +2177,7 @@ function formatOutput(result, behavioralContext) {
2071
2177
  const context = `[GATE:${result.gate}] WARNING: ${result.message}${reasoningSuffix}${extra}`;
2072
2178
  return JSON.stringify({
2073
2179
  hookSpecificOutput: {
2180
+ hookEventName: 'PreToolUse',
2074
2181
  additionalContext: context,
2075
2182
  ...(behavioralContext ? {
2076
2183
  systemReminder: behavioralContext,
@@ -2204,9 +2311,8 @@ function buildRelevantLessonContext(toolName, toolInput) {
2204
2311
  const lessons = retrieveRelevantLessons(toolName, actionContext, { maxResults: 3 });
2205
2312
 
2206
2313
  const entropy = calculateRetrievalEntropy(lessons);
2207
- if (entropy > 0.7) {
2208
- recordStat("retrieval_entropy_high", "block");
2209
- return { decision: "deny", gate: "knowledge-conflict-gate", message: "✗ THUMBGATE: Action blocked due to high Knowledge Entropy (conflicting past lessons).", severity: "high" };
2314
+ if (entropy > KNOWLEDGE_ENTROPY_THRESHOLD) {
2315
+ return buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy);
2210
2316
  }
2211
2317
  return formatNegativeLessonContext(lessons);
2212
2318
  } catch {
@@ -2237,16 +2343,11 @@ async function buildRelevantLessonContextAsync(toolName, toolInput) {
2237
2343
  : retrieveRelevantLessons(toolName, actionContext, { maxResults: 3 });
2238
2344
 
2239
2345
  // Knowledge Conflict Detection: if retrieved lessons have high sentiment entropy,
2240
- // it indicates conflicting past evidence. Block and require human disambiguation.
2346
+ // it indicates conflicting past evidence. Warn by default; hard-block only in
2347
+ // strict mode for external/destructive side-effect commands.
2241
2348
  const entropy = calculateRetrievalEntropy(lessons);
2242
- if (entropy > 0.7) {
2243
- recordStat('retrieval_entropy_high', 'block');
2244
- return {
2245
- decision: 'deny',
2246
- gate: 'knowledge-conflict-gate',
2247
- message: '✗ THUMBGATE: Action blocked due to high Knowledge Entropy (conflicting past lessons). Please disambiguate your instructions or verify the intended behavior manually.',
2248
- severity: 'high',
2249
- };
2349
+ if (entropy > KNOWLEDGE_ENTROPY_THRESHOLD) {
2350
+ return buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy);
2250
2351
  }
2251
2352
 
2252
2353
  return formatNegativeLessonContext(lessons);
@@ -2273,6 +2374,36 @@ function formatNegativeLessonContext(lessons) {
2273
2374
  return `[ThumbGate] Past mistakes relevant to this action — read before proceeding:\n${formatted.join('\n')}`;
2274
2375
  }
2275
2376
 
2377
+ function isStrictKnowledgeConflictMode() {
2378
+ return process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1'
2379
+ || process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === 'true';
2380
+ }
2381
+
2382
+ function isKnowledgeConflictHardBlockAction(toolName, toolInput = {}) {
2383
+ if (!isStrictKnowledgeConflictMode()) return false;
2384
+ if (EDIT_LIKE_TOOLS.has(toolName)) return true;
2385
+ if (toolName !== 'Bash') return false;
2386
+ return KNOWLEDGE_CONFLICT_STRICT_BASH_PATTERN.test(String(toolInput.command || ''));
2387
+ }
2388
+
2389
+ function buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy) {
2390
+ const lessonContext = formatNegativeLessonContext(lessons);
2391
+ const message = `Knowledge conflict warning: retrieved lessons disagree for this action (entropy ${entropy}). Treat the reminders below as cautionary context, but do not stop unrelated work solely because memory is noisy.`;
2392
+
2393
+ if (isKnowledgeConflictHardBlockAction(toolName, toolInput)) {
2394
+ recordStat('retrieval_entropy_high', 'block');
2395
+ return {
2396
+ decision: 'deny',
2397
+ gate: 'knowledge-conflict-gate',
2398
+ message: `✗ THUMBGATE: ${message} Strict mode is enabled for destructive or external side-effect actions; verify intent or narrow the task before proceeding.`,
2399
+ severity: 'high',
2400
+ };
2401
+ }
2402
+
2403
+ recordStat('retrieval_entropy_high', 'warn');
2404
+ return mergeContextStrings(`[ThumbGate] ${message}`, lessonContext);
2405
+ }
2406
+
2276
2407
  function extractActionContext(toolName, toolInput) {
2277
2408
  if (!toolInput) return toolName;
2278
2409
  const parts = [toolName];
@@ -2659,6 +2790,7 @@ module.exports = {
2659
2790
  setTaskScope,
2660
2791
  setBranchGovernance,
2661
2792
  approveProtectedAction,
2793
+ breakGlassEmergency,
2662
2794
  getScopeState,
2663
2795
  getBranchGovernanceState,
2664
2796
  isConditionSatisfied,
@@ -2717,6 +2849,7 @@ module.exports = {
2717
2849
  getLocalOnlyScopeSources,
2718
2850
  isRemoteSideEffectCommand,
2719
2851
  evaluateLocalOnlyRemoteSideEffectGate,
2852
+ isAgentHookSettingsFile,
2720
2853
  PR_THREAD_RESOLUTION_ACTION,
2721
2854
  buildBlockActionProCta,
2722
2855
  applyDailyBlockCap,
@@ -3,13 +3,12 @@
3
3
  /**
4
4
  * install-shim.js — Install a stable shim at ~/.thumbgate/bin/thumbgate-hook
5
5
  *
6
- * The shim is a tiny shell script that always resolves thumbgate@latest,
7
- * so hook commands in settings.local.json never go stale. This is the
8
- * Volta-style pattern: a version-agnostic indirection layer that survives
9
- * across thumbgate upgrades.
6
+ * The shim is a tiny shell script that resolves the cached ThumbGate runtime
7
+ * first, so hook commands in settings.local.json stay stable across projects
8
+ * and agent restarts.
10
9
  *
11
10
  * The shim checks for a cached runtime binary first (fast path), and falls
12
- * back to `npx --yes thumbgate@latest` (slow path, self-installs).
11
+ * back to `npx --yes thumbgate@latest` (slow path, first-time self-install).
13
12
  */
14
13
 
15
14
  const fs = require('fs');
@@ -24,9 +23,10 @@ const RUNTIME_BIN = path.join(os.homedir(), '.thumbgate', 'runtime', 'node_modul
24
23
  * The shim script. Key design choices:
25
24
  * - Uses `exec` to replace the shell process (no zombie processes)
26
25
  * - Fast path: if cached runtime binary exists, exec it directly
27
- * - Slow path: npx --yes thumbgate@latest (auto-installs)
28
- * - Background upgrade: after the fast path succeeds once, spawn a
29
- * detached npm install to refresh the cache for next time
26
+ * - Slow path: npx --yes thumbgate@latest (first-time auto-installs)
27
+ * - No default self-mutation: background upgrades are opt-in via
28
+ * THUMBGATE_SHIM_AUTO_UPDATE=1 so source checkouts, enterprise pins, and
29
+ * dogfood runtimes cannot be overwritten by a hook side effect.
30
30
  */
31
31
  function shimContent() {
32
32
  const escapedRuntimeBin = JSON.stringify(RUNTIME_BIN);
@@ -35,7 +35,7 @@ function shimContent() {
35
35
  return `#!/usr/bin/env bash
36
36
  # ThumbGate hook shim — DO NOT EDIT
37
37
  # Installed by: thumbgate init
38
- # Purpose: version-agnostic hook entry point that always runs latest ThumbGate
38
+ # Purpose: stable hook entry point that runs the cached ThumbGate runtime
39
39
  # Pattern: Volta-style stable shim (see https://volta.sh)
40
40
 
41
41
  set -euo pipefail
@@ -45,8 +45,11 @@ RUNTIME_DIR=${escapedRuntimeDir}
45
45
 
46
46
  # Fast path: cached runtime binary exists and is executable
47
47
  if [ -x "$RUNTIME_BIN" ]; then
48
- # Spawn background upgrade (detached, no stdout/stderr, won't block hook)
49
- ( nohup npm install --prefix "$RUNTIME_DIR" --no-save --omit=dev thumbgate@latest >/dev/null 2>&1 & ) 2>/dev/null || true
48
+ # Optional background upgrade. Disabled by default so hooks never mutate a
49
+ # source checkout, enterprise pin, or dogfood runtime behind the operator's back.
50
+ if [ "\${THUMBGATE_SHIM_AUTO_UPDATE:-0}" = "1" ]; then
51
+ ( nohup npm install --prefix "$RUNTIME_DIR" --no-save --omit=dev thumbgate@latest >/dev/null 2>&1 & ) 2>/dev/null || true
52
+ fi
50
53
  exec "$RUNTIME_BIN" "$@"
51
54
  fi
52
55
 
package/src/api/server.js CHANGED
@@ -418,34 +418,18 @@ const TRACKED_LINK_TARGETS = Object.freeze({
418
418
  },
419
419
  allowCustomerEmail: true,
420
420
  },
421
- // 2026-05-19: Team is intake-led. Keep the tracked shortlink alive for
422
- // marketplaces and old outreach, but route it to workflow scope first
423
- // instead of blind 3-seat checkout.
421
+ // 2026-06-02: Teams/Aiventyx deprecated. Redirect legacy links to Pro.
424
422
  teams: {
425
- path: '/#workflow-sprint-intake',
426
- ctaId: 'go_teams',
423
+ path: '/go/pro?utm_source=legacy_teams&utm_medium=redirect',
424
+ ctaId: 'go_pro',
427
425
  ctaPlacement: 'link_router',
428
- eventType: 'team_intake_started',
429
- defaults: {
430
- utm_source: 'website',
431
- utm_medium: 'link_router',
432
- utm_campaign: 'team_intake',
433
- plan_id: 'team',
434
- },
426
+ eventType: 'cta_click',
435
427
  },
436
- // Aliases: /go/team → same as /go/teams, /go/checkout → same as /go/pro,
437
- // /go/trial → install guide (trial starts on init)
438
428
  team: {
439
- path: '/#workflow-sprint-intake',
440
- ctaId: 'go_team',
429
+ path: '/go/pro?utm_source=legacy_teams&utm_medium=redirect',
430
+ ctaId: 'go_pro',
441
431
  ctaPlacement: 'link_router',
442
- eventType: 'team_intake_started',
443
- defaults: {
444
- utm_source: 'website',
445
- utm_medium: 'link_router',
446
- utm_campaign: 'team_intake',
447
- plan_id: 'team',
448
- },
432
+ eventType: 'cta_click',
449
433
  },
450
434
  checkout: {
451
435
  path: '/checkout/pro',
@@ -6405,30 +6389,7 @@ ${hidden}
6405
6389
  <h1>Case Studies</h1>
6406
6390
  <p class="lede">Real integrations. No fabricated logos, no aspirational numbers — every claim below is reproducible.</p>
6407
6391
 
6408
- <article>
6409
- <h2>Aiventyx marketplace — Teams listing intake recovery</h2>
6410
- <p class="meta">Integration partner: <a href="https://www.aiventyx.com">Aiventyx</a> · Reported by: Qaiser Mehdi · Verified: 2026-05-13</p>
6411
-
6412
- <h3>The problem</h3>
6413
- <p>Aiventyx is a marketplace for AI tools. ThumbGate's Teams listing was their highest-CTR external surface — <span class="metric">62% CTR</span> (5 clicks on 8 views, May 7–9 window). When their integrator rolled out canonical tracked URLs, every Teams click started landing on:</p>
6414
- <p><code>{"error":"Tracked link not found","allowed":["gpt","pro","install","reddit","linkedin","x","github"]}</code></p>
6415
- <p>The <code>/go/teams</code> slug wasn't registered in our redirector — a 404 was eating every paid-intent click from their strongest external surface.</p>
6416
-
6417
- <h3>The fix</h3>
6418
- <p>Added <code>teams</code> to <code>TRACKED_LINK_TARGETS</code> and now routes it to <code>/?plan_id=team#workflow-sprint-intake</code>. Caller-supplied UTMs flow through to the intake path so the workflow, owner, and proof boundary are explicit before any Team checkout.</p>
6419
-
6420
- <h3>The verification</h3>
6421
- <p>Qaiser's own incognito test, May 13 6:04 AM (full email on record):</p>
6422
- <p><code>https://thumbgate.ai/go/teams?utm_source=aiventyx</code><br>
6423
- → 302 to the Team workflow intake<br>
6424
- → pricing source, campaign, and plan metadata preserved<br>
6425
- → buyer sees the scope-first path before any subscription decision</p>
6426
-
6427
- <h3>What this proves</h3>
6428
- <p>End-to-end attribution from a third-party marketplace through ThumbGate's redirector into the Team intake path, with the caller's UTM chain preserved. Regression tests pin the redirect contract so it can't silently break or regress into a blind Team checkout.</p>
6429
-
6430
- <p><a href="/go/teams?utm_source=case-study">Try the live redirect →</a></p>
6431
- </article>
6392
+ <p><em>New case studies for individual Pro operators coming soon.</em></p>
6432
6393
 
6433
6394
  <footer>
6434
6395
  <p>Want to be the next case study? The product is real, the integration is 30 seconds: <code>npx thumbgate init</code>. If you ship something with ThumbGate and want it documented here, email <a href="mailto:igor.ganapolsky@gmail.com">igor.ganapolsky@gmail.com</a>.</p>
@@ -1,160 +0,0 @@
1
- 'use strict';
2
-
3
- const { resolveAnalyticsWindow } = require('./analytics-window');
4
- const { getBillingSummaryLive } = require('./billing');
5
- const { generateDashboard } = require('./dashboard');
6
- const { getFeedbackPaths } = require('./feedback-loop');
7
- const { resolveHostedBillingConfig } = require('./hosted-config');
8
- const { loadOperatorConfig } = require('./operational-summary');
9
-
10
- function normalizeText(value) {
11
- if (value === undefined || value === null) return null;
12
- const text = String(value).trim();
13
- return text || null;
14
- }
15
-
16
- function shouldPreferHostedDashboard() {
17
- return String(process.env.THUMBGATE_METRICS_SOURCE || '').trim().toLowerCase() !== 'local';
18
- }
19
-
20
- function resolveHostedDashboardConfig() {
21
- const runtimeConfig = resolveHostedBillingConfig();
22
- const operatorConfig = loadOperatorConfig();
23
- // Match operational-summary's key priority chain so north-star and cfo
24
- // authenticate against the same hosted deployment consistently. Prior to
25
- // this change, north-star only read THUMBGATE_API_KEY, silently 401'ing
26
- // on machines configured via operator.json or THUMBGATE_OPERATOR_KEY.
27
- const apiKey = normalizeText(process.env.THUMBGATE_OPERATOR_KEY)
28
- || operatorConfig.operatorKey
29
- || normalizeText(process.env.THUMBGATE_API_KEY);
30
- const apiBaseUrl = normalizeText(process.env.THUMBGATE_BILLING_API_BASE_URL)
31
- || operatorConfig.baseUrl
32
- || runtimeConfig.billingApiBaseUrl;
33
- return {
34
- apiBaseUrl,
35
- apiKey,
36
- };
37
- }
38
-
39
- async function buildOperationalDashboard(options = {}) {
40
- const analyticsWindow = resolveAnalyticsWindow(options);
41
- const feedbackDir = options.feedbackDir || getFeedbackPaths().FEEDBACK_DIR;
42
- const billingSummary = await getBillingSummaryLive(analyticsWindow);
43
-
44
- return generateDashboard(feedbackDir, {
45
- analyticsWindow,
46
- billingSummary,
47
- billingSource: 'live',
48
- billingFallbackReason: null,
49
- });
50
- }
51
-
52
- async function fetchHostedDashboard(options = {}, config = resolveHostedDashboardConfig()) {
53
- const analyticsWindow = resolveAnalyticsWindow(options);
54
- if (!shouldPreferHostedDashboard()) {
55
- const err = new Error('Hosted operational dashboard is disabled.');
56
- err.code = 'hosted_dashboard_disabled';
57
- throw err;
58
- }
59
- if (!config.apiBaseUrl || !config.apiKey) {
60
- const err = new Error('Hosted operational dashboard is not configured.');
61
- err.code = 'hosted_dashboard_unconfigured';
62
- throw err;
63
- }
64
-
65
- const requestUrl = new URL('/v1/dashboard', config.apiBaseUrl);
66
- requestUrl.searchParams.set('window', analyticsWindow.window);
67
- requestUrl.searchParams.set('timezone', analyticsWindow.timeZone);
68
- requestUrl.searchParams.set('now', analyticsWindow.now);
69
-
70
- const response = await fetch(requestUrl, {
71
- method: 'GET',
72
- headers: {
73
- authorization: `Bearer ${config.apiKey}`,
74
- accept: 'application/json',
75
- },
76
- });
77
-
78
- if (!response.ok) {
79
- const detail = await response.text().catch(() => '');
80
- const err = new Error(`Hosted operational dashboard request failed (${response.status}): ${detail || 'unknown error'}`);
81
- err.code = 'hosted_dashboard_http_error';
82
- err.status = response.status;
83
- throw err;
84
- }
85
-
86
- return response.json();
87
- }
88
-
89
- async function getOperationalDashboard(options = {}) {
90
- const analyticsWindow = resolveAnalyticsWindow(options);
91
- try {
92
- const data = await fetchHostedDashboard(analyticsWindow);
93
- return {
94
- source: 'hosted',
95
- data,
96
- fallbackReason: null,
97
- hostedStatus: 200,
98
- };
99
- } catch (err) {
100
- const reason = err && err.message ? err.message : 'hosted_dashboard_unavailable';
101
- const status = err && typeof err.status === 'number' ? err.status : null;
102
- const code = err && err.code ? err.code : null;
103
-
104
- // Hosted deliberately disabled or never configured — local fallback is
105
- // intentional, not a degraded state. Tag as plain 'local'.
106
- if (code === 'hosted_dashboard_disabled' || code === 'hosted_dashboard_unconfigured') {
107
- return {
108
- source: 'local',
109
- data: await buildOperationalDashboard(analyticsWindow),
110
- fallbackReason: reason,
111
- hostedStatus: null,
112
- };
113
- }
114
-
115
- // Mirror operational-summary: auth failure is the dangerous case. A
116
- // dashboard that silently shows $0 revenue (from the local ledger) when
117
- // Stripe actually has paid customers is a lie the operator acts on.
118
- // Refuse to guess — surface an actionable error.
119
- if (status === 401 || status === 403) {
120
- const authErr = new Error(
121
- `Hosted operational dashboard rejected credentials (HTTP ${status}). ` +
122
- `The operator key on this machine does not match the one on the ` +
123
- `hosted deployment. Fix: set THUMBGATE_OPERATOR_KEY in this shell, ` +
124
- `or update the operatorKey field in ~/.config/thumbgate/operator.json, ` +
125
- `to match Railway's THUMBGATE_OPERATOR_KEY. ` +
126
- `Running north-star without hosted auth would report local-only ` +
127
- `data as ground truth, which may not reflect actual Stripe revenue. ` +
128
- `Original response: ${reason}`
129
- );
130
- authErr.code = 'hosted_dashboard_unauthorized';
131
- authErr.status = status;
132
- throw authErr;
133
- }
134
-
135
- // Non-auth failure — local fallback is still useful for dev workflows,
136
- // but tag the source so downstream renderers do not mistake it for
137
- // verified hosted truth.
138
- //
139
- // Log only the status code (trusted) — the full reason contains upstream
140
- // response text and is only returned structurally via fallbackReason.
141
- console.warn(
142
- `[operational-dashboard] Hosted dashboard unreachable (status=${status ?? 'network'}); ` +
143
- `falling back to LOCAL-UNVERIFIED state. Numbers below may not reflect actual Stripe revenue.`
144
- );
145
- return {
146
- source: 'local-unverified',
147
- data: await buildOperationalDashboard(analyticsWindow),
148
- fallbackReason: reason,
149
- hostedStatus: status,
150
- };
151
- }
152
- }
153
-
154
- module.exports = {
155
- buildOperationalDashboard,
156
- fetchHostedDashboard,
157
- getOperationalDashboard,
158
- resolveHostedDashboardConfig,
159
- shouldPreferHostedDashboard,
160
- };