thumbgate 1.27.4 → 1.27.7

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.
Files changed (104) hide show
  1. package/.claude/commands/dashboard.md +15 -0
  2. package/.claude/commands/thumbgate-blocked.md +27 -0
  3. package/.claude/commands/thumbgate-dashboard.md +15 -0
  4. package/.claude/commands/thumbgate-doctor.md +30 -0
  5. package/.claude/commands/thumbgate-guard.md +36 -0
  6. package/.claude/commands/thumbgate-protect.md +30 -0
  7. package/.claude/commands/thumbgate-rules.md +30 -0
  8. package/.claude-plugin/plugin.json +2 -1
  9. package/.well-known/llms.txt +6 -2
  10. package/.well-known/mcp/server-card.json +1 -1
  11. package/README.md +49 -5
  12. package/adapters/claude/.mcp.json +2 -2
  13. package/adapters/letta/README.md +41 -0
  14. package/adapters/letta/thumbgate-letta-adapter.js +133 -0
  15. package/adapters/mcp/server-stdio.js +16 -1
  16. package/adapters/opencode/opencode.json +1 -1
  17. package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
  18. package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
  19. package/bench/observability-eval-suite.json +26 -0
  20. package/bin/cli.js +230 -6
  21. package/bin/postinstall.js +1 -1
  22. package/commands/dashboard.md +15 -0
  23. package/commands/thumbgate-dashboard.md +15 -0
  24. package/config/gate-templates.json +84 -0
  25. package/config/gates/claim-verification.json +12 -0
  26. package/config/gates/default.json +20 -0
  27. package/config/github-about.json +1 -1
  28. package/config/model-candidates.json +50 -0
  29. package/config/post-deploy-marketing-pages.json +5 -0
  30. package/package.json +67 -25
  31. package/public/agent-manager.html +41 -1
  32. package/public/agents-cost-savings.html +1 -1
  33. package/public/ai-malpractice-prevention.html +2 -1
  34. package/public/assets/brand/github-social-preview.png +0 -0
  35. package/public/assets/brand/thumbgate-icon-512.png +0 -0
  36. package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
  37. package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
  38. package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
  39. package/public/assets/brand/thumbgate-mark-inline.svg +15 -0
  40. package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
  41. package/public/assets/brand/thumbgate-mark-team.svg +26 -0
  42. package/public/assets/brand/thumbgate-mark.svg +15 -0
  43. package/public/assets/brand/thumbgate-wordmark.svg +20 -0
  44. package/public/assets/claude-thumbgate-statusbar.svg +8 -0
  45. package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
  46. package/public/assets/legal-intake-control-flow.svg +66 -0
  47. package/public/blog.html +1 -1
  48. package/public/brand/thumbgate-mark.svg +15 -0
  49. package/public/brand/thumbgate-og.svg +16 -0
  50. package/public/codex-enterprise.html +1 -1
  51. package/public/codex-plugin.html +1 -1
  52. package/public/compare.html +23 -3
  53. package/public/dashboard.html +316 -30
  54. package/public/federal.html +1 -1
  55. package/public/guide.html +5 -4
  56. package/public/index.html +167 -49
  57. package/public/js/buyer-intent.js +672 -0
  58. package/public/learn.html +88 -7
  59. package/public/lessons.html +2 -1
  60. package/public/numbers.html +3 -3
  61. package/public/pricing.html +63 -15
  62. package/public/pro.html +7 -7
  63. package/scripts/activation-quickstart.js +187 -0
  64. package/scripts/agent-memory-lifecycle.js +211 -0
  65. package/scripts/async-eval-observability.js +236 -0
  66. package/scripts/auto-promote-gates.js +75 -4
  67. package/scripts/billing.js +12 -1
  68. package/scripts/build-metadata.js +24 -3
  69. package/scripts/cli-schema.js +42 -10
  70. package/scripts/dashboard-chat.js +53 -7
  71. package/scripts/dashboard.js +12 -17
  72. package/scripts/export-databricks-bundle.js +5 -1
  73. package/scripts/export-dpo-pairs.js +7 -2
  74. package/scripts/feedback-aggregate.js +281 -0
  75. package/scripts/feedback-loop.js +121 -0
  76. package/scripts/filesystem-search.js +35 -10
  77. package/scripts/gates-engine.js +234 -7
  78. package/scripts/gemini-embedding-policy.js +2 -1
  79. package/scripts/hook-stop-anti-claim.js +227 -0
  80. package/scripts/hook-thumbgate-cache-updater.js +18 -2
  81. package/scripts/hybrid-feedback-context.js +1 -0
  82. package/scripts/lesson-inference.js +8 -3
  83. package/scripts/lesson-search.js +17 -1
  84. package/scripts/operational-integrity.js +39 -5
  85. package/scripts/plausible-domain-config.js +15 -2
  86. package/scripts/plausible-server-events.js +4 -4
  87. package/scripts/rate-limiter.js +12 -6
  88. package/scripts/secret-redaction.js +166 -0
  89. package/scripts/security-scanner.js +100 -0
  90. package/scripts/self-distill-agent.js +3 -1
  91. package/scripts/self-harness-optimizer.js +141 -0
  92. package/scripts/seo-gsd.js +635 -0
  93. package/scripts/statusline-cache-path.js +17 -2
  94. package/scripts/statusline-cache-read.js +57 -0
  95. package/scripts/statusline-local-stats.js +9 -1
  96. package/scripts/statusline-meta.js +5 -2
  97. package/scripts/statusline.sh +13 -1
  98. package/scripts/sync-telemetry-from-prod.js +374 -0
  99. package/scripts/telemetry-analytics.js +9 -0
  100. package/scripts/thumbgate-search.js +85 -19
  101. package/scripts/tool-contract-validator.js +76 -0
  102. package/scripts/vector-store.js +44 -0
  103. package/scripts/workspace-evolver.js +62 -2
  104. package/src/api/server.js +862 -146
package/src/api/server.js CHANGED
@@ -3,6 +3,7 @@ const http = require('http');
3
3
  const https = require('https');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const crypto = require('node:crypto');
6
7
  const { EventEmitter } = require('node:events');
7
8
  const pkg = require('../../package.json');
8
9
  const {
@@ -168,7 +169,13 @@ const {
168
169
  buildReviewSnapshot,
169
170
  readDashboardReviewState,
170
171
  writeDashboardReviewState,
172
+ collectAllFeedbackEntries,
171
173
  } = require('../../scripts/dashboard');
174
+ const {
175
+ collectAggregateLogEntries,
176
+ computeAggregateFeedbackStats,
177
+ shouldAggregateFeedback,
178
+ } = require('../../scripts/feedback-aggregate');
172
179
  const {
173
180
  guardDfcxWebhook,
174
181
  } = require('../../adapters/gcp/dfcx-webhook-gate');
@@ -209,6 +216,8 @@ const mcpOauth = require('../../scripts/mcp-oauth');
209
216
  // OAuth 2.1 (PKCE) authorization-server state for the remote MCP connector
210
217
  // (Claude Connectors Directory requires OAuth for authenticated services).
211
218
  const oauthStore = mcpOauth.createStore();
219
+ const pendingOauthAuthorizeRequests = new Map();
220
+ const OAUTH_AUTHORIZE_REQUEST_TTL_MS = 10 * 60 * 1000;
212
221
  const resendMailer = require('../../scripts/mailer/resend-mailer');
213
222
  const {
214
223
  buildContextFootprintReport,
@@ -231,12 +240,17 @@ const LEARN_PAGE_PATH = path.resolve(__dirname, '../../public/learn.html');
231
240
  const NUMBERS_PAGE_PATH = path.resolve(__dirname, '../../public/numbers.html');
232
241
  const FEDERAL_PAGE_PATH = path.resolve(__dirname, '../../public/federal.html');
233
242
  const PRICING_PAGE_PATH = path.resolve(__dirname, '../../public/pricing.html');
243
+ const ABOUT_PAGE_PATH = path.resolve(__dirname, '../../public/about.html');
234
244
  const LEARN_DIR = path.resolve(__dirname, '../../public/learn');
235
245
  const GUIDES_DIR = path.resolve(__dirname, '../../public/guides');
236
246
  const COMPARE_DIR = path.resolve(__dirname, '../../public/compare');
237
247
  const USE_CASES_DIR = path.resolve(__dirname, '../../public/use-cases');
238
248
  const PUBLIC_DIR = path.resolve(__dirname, '../../public');
239
249
  const PUBLIC_ASSETS_DIR = path.resolve(__dirname, '../../public/assets');
250
+ const LEARN_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(LEARN_DIR);
251
+ const GUIDE_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(GUIDES_DIR);
252
+ const COMPARE_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(COMPARE_DIR);
253
+ const USE_CASE_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(USE_CASES_DIR);
240
254
  const BUYER_INTENT_SCRIPT_PATH = path.resolve(__dirname, '../../public/js/buyer-intent.js');
241
255
  const STATIC_MIME_BY_EXT = Object.freeze({
242
256
  '.png': 'image/png',
@@ -355,6 +369,7 @@ function serveStaticFile(res, filePath, { headOnly = false, cacheSeconds = 86400
355
369
  res.setHeader('Content-Type', contentType);
356
370
  res.setHeader('Content-Length', stat.size);
357
371
  res.setHeader('Cache-Control', `public, max-age=${cacheSeconds}, immutable`);
372
+ res.setHeader('Referrer-Policy', 'same-origin');
358
373
  if (headOnly) {
359
374
  res.end();
360
375
  return;
@@ -452,6 +467,36 @@ const TRACKED_LINK_TARGETS = Object.freeze({
452
467
  },
453
468
  allowCustomerEmail: true,
454
469
  },
470
+ diagnostic: {
471
+ configUrlKey: 'sprintDiagnosticCheckoutUrl',
472
+ fallbackHref: SPRINT_DIAGNOSTIC_CHECKOUT_URL,
473
+ external: true,
474
+ ctaId: 'go_diagnostic',
475
+ ctaPlacement: 'link_router',
476
+ eventType: 'cta_click',
477
+ defaults: {
478
+ utm_source: 'website',
479
+ utm_medium: 'link_router',
480
+ utm_campaign: 'sprint_diagnostic',
481
+ plan_id: 'sprint_diagnostic',
482
+ },
483
+ allowCustomerEmail: true,
484
+ },
485
+ sprint: {
486
+ configUrlKey: 'workflowSprintCheckoutUrl',
487
+ fallbackHref: WORKFLOW_SPRINT_CHECKOUT_URL,
488
+ external: true,
489
+ ctaId: 'go_sprint',
490
+ ctaPlacement: 'link_router',
491
+ eventType: 'cta_click',
492
+ defaults: {
493
+ utm_source: 'website',
494
+ utm_medium: 'link_router',
495
+ utm_campaign: 'workflow_sprint',
496
+ plan_id: 'workflow_sprint',
497
+ },
498
+ allowCustomerEmail: true,
499
+ },
455
500
  trial: {
456
501
  path: '/guide',
457
502
  ctaId: 'go_trial',
@@ -1550,14 +1595,104 @@ function normalizeEnterpriseChatPrompt(value) {
1550
1595
 
1551
1596
  function classifyEnterpriseChatTopic(prompt) {
1552
1597
  const lower = String(prompt || '').toLowerCase();
1553
- if (/gate|block|deny|prevent|guard/.test(lower)) return 'gates';
1554
- if (/lesson|memory|feedback|thumb|mistake|negative|positive/.test(lower)) return 'feedback';
1598
+ // Feedback-specific words run FIRST: "what mistakes were blocked today" is a
1599
+ // feedback question, not a gates question — must not be hijacked by /block/.
1600
+ if (/mistake|lesson|memory|feedback|thumb|negative|positive|what (?:went )?wrong|fail|win|success|worked|good/.test(lower)) return 'feedback';
1601
+ if (/gate|block|deny|prevent|guard|enforce/.test(lower)) return 'gates';
1555
1602
  if (/team|agent|org|enterprise|rollout/.test(lower)) return 'team';
1556
1603
  if (/token|cost|saving|budget|spend/.test(lower)) return 'cost';
1557
1604
  if (/vertex|gcp|google|dialogflow|dfcx|cloud/.test(lower)) return 'cloud';
1558
1605
  return 'overview';
1559
1606
  }
1560
1607
 
1608
+ // Parse intent: LIST vs COUNT, and time window. Lets us answer "what mistakes
1609
+ // today?" with an actual filtered list instead of a canned total.
1610
+ function parseChatIntent(prompt) {
1611
+ const lower = String(prompt || '').toLowerCase();
1612
+ const terms = new Set(lower.split(/[^a-z0-9]+/).filter(Boolean));
1613
+ const hasTerm = (term) => terms.has(term);
1614
+ const wantsList = lower.includes('tell me about') ||
1615
+ ['what', 'which', 'list', 'show', 'example', 'examples'].some(hasTerm);
1616
+ let windowMs = null;
1617
+ let windowLabel = 'across all time';
1618
+ if (/\btoday\b/.test(lower)) { windowMs = 24 * 60 * 60 * 1000; windowLabel = 'today'; }
1619
+ else if (/\byesterday\b/.test(lower)) { windowMs = 48 * 60 * 60 * 1000; windowLabel = 'yesterday'; }
1620
+ else if (/\bthis week\b|\b7 ?d(ay)?s?\b|\blast week\b/.test(lower)) { windowMs = 7 * 24 * 60 * 60 * 1000; windowLabel = 'the last 7 days'; }
1621
+ else if (/\bthis month\b|\b30 ?d(ay)?s?\b|\blast month\b/.test(lower)) { windowMs = 30 * 24 * 60 * 60 * 1000; windowLabel = 'the last 30 days'; }
1622
+ return { wantsList, windowMs, windowLabel };
1623
+ }
1624
+
1625
+ // Read recent feedback entries (signal-filtered, time-filtered) directly from
1626
+ // the feedback log. Bounded + best-effort — never throws into the chat handler.
1627
+ // Treat short/placeholder context values as "no real description" so we don't
1628
+ // surface `"thumbs down"` × 3 as a useful list. Real feedback always has a
1629
+ // concrete sentence somewhere (whatWentWrong, distillation, whatToChange) —
1630
+ // pick the longest informative one.
1631
+ // Short tokens that mean "no real description" — kept as a plain Set, not a
1632
+ // big regex, to keep complexity low and easy to extend.
1633
+ const PLACEHOLDER_TOKENS = new Set([
1634
+ 'thumbs down', 'thumbs up', 'thumb down', 'thumb up',
1635
+ 'good', 'bad', 'ok', 'nice', 'verify', 'verifies', 'verification', 'test', 'testing',
1636
+ ]);
1637
+
1638
+ function isPlaceholder(text) {
1639
+ const t = String(text || '').trim();
1640
+ if (!t || t.length < 20) return true;
1641
+ return PLACEHOLDER_TOKENS.has(t.toLowerCase().replace(/\.$/, ''));
1642
+ }
1643
+
1644
+ function bestFeedbackDescription(row) {
1645
+ const candidates = [row.whatWentWrong, row.distillation, row.context, row.whatToChange, row.whatWorked, row.reasoning]
1646
+ .map((c) => String(c || '').trim());
1647
+ // Prefer the longest non-placeholder candidate; fall back to any non-empty.
1648
+ const informative = candidates.filter((c) => c && !isPlaceholder(c));
1649
+ const fallback = candidates.find(Boolean) || '';
1650
+ const best = informative.reduce((a, b) => (b.length > a.length ? b : a), '') || fallback;
1651
+ return best.slice(0, 220);
1652
+ }
1653
+
1654
+ function readRecentFeedbackEntries(feedbackDir, signal, windowMs, limit = 5, opts = {}) {
1655
+ try {
1656
+ if (!feedbackDir) return [];
1657
+ const rows = shouldAggregateFeedback()
1658
+ ? collectAggregateLogEntries('feedback-log.jsonl', { feedbackDir }).entries
1659
+ : (() => {
1660
+ const fsLocal = require('node:fs');
1661
+ const pathLocal = require('node:path');
1662
+ const logPath = pathLocal.join(feedbackDir, 'feedback-log.jsonl');
1663
+ if (!fsLocal.existsSync(logPath)) return [];
1664
+ const { readJsonl } = require('../../scripts/fs-utils');
1665
+ return readJsonl(logPath) || [];
1666
+ })();
1667
+ const cutoff = windowMs ? Date.now() - windowMs : 0;
1668
+ const filtered = rows
1669
+ .filter((r) => !signal || r.signal === signal)
1670
+ .filter((r) => {
1671
+ if (!cutoff) return true;
1672
+ const t = r.timestamp ? Date.parse(r.timestamp) : Number.NaN;
1673
+ return Number.isFinite(t) && t >= cutoff;
1674
+ })
1675
+ .reverse()
1676
+ .map((r) => {
1677
+ const out = {
1678
+ timestamp: r.timestamp,
1679
+ context: bestFeedbackDescription(r),
1680
+ tags: Array.isArray(r.tags) ? r.tags : []
1681
+ };
1682
+ if (opts.includeSignal) out.signal = r.signal;
1683
+ return out;
1684
+ });
1685
+ // For list display, drop entries with no real description so the list is useful,
1686
+ // not three "thumbs down" placeholders. For count-only (high limit), keep all.
1687
+ const useful = opts.skipPlaceholders === false || limit > 50
1688
+ ? filtered
1689
+ : filtered.filter((e) => e.context && !isPlaceholder(e.context));
1690
+ return useful.slice(0, limit);
1691
+ } catch {
1692
+ return [];
1693
+ }
1694
+ }
1695
+
1561
1696
  function containsUnsafeEnterpriseChatInput(prompt) {
1562
1697
  return /[;&|`$<>\\]/.test(String(prompt || ''));
1563
1698
  }
@@ -1567,101 +1702,135 @@ function compactNumber(value) {
1567
1702
  return Number.isFinite(n) ? n : 0;
1568
1703
  }
1569
1704
 
1570
- function isTodayScopedPrompt(prompt) {
1571
- return /\btoday\b|\bthis day\b|\blast 24\b|\b24 hours\b/i.test(String(prompt || ''));
1705
+ // Pick a signal preference from the prompt. Returns 'negative' | 'positive' | null.
1706
+ function detectFeedbackSignalFromPrompt(prompt) {
1707
+ const lower = String(prompt || '').toLowerCase();
1708
+ const wantsNeg = /mistake|wrong|fail|negative|thumbs? *down|block/.test(lower);
1709
+ const wantsPos = /positive|thumbs? *up|worked|success|wins?\b|good/.test(lower);
1710
+ if (wantsNeg && !wantsPos) return 'negative';
1711
+ if (wantsPos && !wantsNeg) return 'positive';
1712
+ return null;
1713
+ }
1714
+
1715
+ const FEEDBACK_LIST_LABELS = Object.freeze({
1716
+ negative: 'Recent mistakes',
1717
+ positive: 'Recent wins',
1718
+ });
1719
+
1720
+ const FEEDBACK_OMITTED_TAGS = Object.freeze(['audit-trail', 'auto-capture']);
1721
+
1722
+ function formatChatTimestamp(isoString) {
1723
+ if (!isoString) return 'unknown time';
1724
+ try {
1725
+ const d = new Date(isoString);
1726
+ if (Number.isNaN(d.getTime())) return isoString;
1727
+ const pad = (n) => String(n).padStart(2, '0');
1728
+ const yyyy = d.getFullYear();
1729
+ const mm = pad(d.getMonth() + 1);
1730
+ const dd = pad(d.getDate());
1731
+ const hh = pad(d.getHours());
1732
+ const min = pad(d.getMinutes());
1733
+ const ss = pad(d.getSeconds());
1734
+ return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}`;
1735
+ } catch {
1736
+ return isoString;
1737
+ }
1572
1738
  }
1573
1739
 
1574
- function getTodayGateAudit(gateAudit) {
1575
- const days = gateAudit && Array.isArray(gateAudit.days) ? gateAudit.days : [];
1576
- return days.length > 0 ? days[days.length - 1] : null;
1740
+ function buildFeedbackEntries(windowed, signal) {
1741
+ return (signal ? windowed.filter((r) => r.signal === signal) : windowed)
1742
+ .filter((r) => r.context && !isPlaceholder(r.context))
1743
+ .slice(0, 5);
1577
1744
  }
1578
1745
 
1579
- function formatGateBuckets(byGate) {
1580
- return Object.entries(byGate || {})
1581
- .filter(([, count]) => compactNumber(count) > 0)
1582
- .sort((a, b) => compactNumber(b[1]) - compactNumber(a[1]))
1583
- .slice(0, 3)
1584
- .map(([gateId, count]) => `${gateId} (${compactNumber(count)}x)`);
1746
+ function formatFeedbackEntry(entry) {
1747
+ const tsFormatted = formatChatTimestamp(entry.timestamp);
1748
+ const signalLabel = entry.signal ? ` [${entry.signal}]` : '';
1749
+ const tagsList = Array.isArray(entry.tags)
1750
+ ? entry.tags.filter((tag) => !FEEDBACK_OMITTED_TAGS.includes(tag))
1751
+ : [];
1752
+ const tagsStr = tagsList.length ? ` (${tagsList.join(', ')})` : '';
1753
+ return ` • ${tsFormatted}${signalLabel}${tagsStr} — ${entry.context}`;
1585
1754
  }
1586
1755
 
1587
- function buildTodayGateAnswer(prompt, dashboardData, gateStats, gates) {
1588
- if (!isTodayScopedPrompt(prompt)) return null;
1589
- const lower = String(prompt || '').toLowerCase();
1590
- const gateAudit = dashboardData.gateAudit || {};
1591
- const prevention = dashboardData.prevention || {};
1592
- const today = getTodayGateAudit(gateAudit) || { deny: 0, warn: 0, intercepted: 0, byGate: {} };
1593
- const activeGateCount = gates.length || compactNumber(gateStats.totalGates);
1594
-
1595
- if (/\b(activated|promoted|created|enabled)\b/.test(lower)) {
1596
- const promotionsToday = compactNumber(prevention.promotionsToday);
1597
- const promotedIds = Array.isArray(prevention.promotionIdsToday) ? prevention.promotionIdsToday.filter(Boolean) : [];
1598
- return [
1599
- `Gates activated today: ${promotionsToday}.`,
1600
- `Active gates now: ${activeGateCount}.`,
1601
- promotedIds.length > 0 ? `Promoted today: ${promotedIds.slice(0, 3).join(', ')}.` : '',
1602
- ].filter(Boolean);
1756
+ function appendFeedbackListLines(lines, { entries, signal, intent }) {
1757
+ if (!entries.length) {
1758
+ lines.push(`No ${signal || 'feedback'} entries found ${intent.windowLabel}.`);
1759
+ return;
1603
1760
  }
1761
+ lines.push(`${FEEDBACK_LIST_LABELS[signal] || 'Recent feedback'} (${intent.windowLabel}):`);
1762
+ lines.push(...entries.map(formatFeedbackEntry));
1763
+ }
1764
+
1765
+ function buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeline }) {
1766
+ const signal = detectFeedbackSignalFromPrompt(ctx.prompt);
1767
+ // One read of the time-windowed log, then in-memory counts + (signal-filtered,
1768
+ // placeholder-stripped) list. Counts include ALL entries (so "Feedback today: 5"
1769
+ // matches the dashboard tile); the list drops vague entries like literal "thumbs
1770
+ // down" / "good" so it surfaces only entries with real, actionable description.
1771
+ const windowed = readRecentFeedbackEntries(feedbackDir, null, intent.windowMs, 10000, { includeSignal: true });
1772
+ const windowPos = windowed.filter((r) => r.signal === 'positive').length;
1773
+ const windowNeg = windowed.filter((r) => r.signal === 'negative').length;
1774
+ const entries = buildFeedbackEntries(windowed, signal);
1775
+
1776
+ const lines = [
1777
+ intent.windowMs
1778
+ ? `Feedback ${intent.windowLabel}: ${windowed.length} (${windowPos} positive, ${windowNeg} negative).`
1779
+ : `Feedback total: ${compactNumber(approval.total)} (${compactNumber(approval.positive)} positive, ${compactNumber(approval.negative)} negative).`,
1780
+ ];
1604
1781
 
1605
- if (/\b(what|which)\b/.test(lower) && /\b(mistake|mistakes|block|blocked|prevent|prevented)\b/.test(lower)) {
1606
- const gateBuckets = formatGateBuckets(today.byGate);
1607
- return [
1608
- `Today: ${compactNumber(today.deny)} blocked actions and ${compactNumber(today.warn)} warning checkpoints.`,
1609
- gateBuckets.length > 0
1610
- ? `Top blocked/warned gates today: ${gateBuckets.join(', ')}.`
1611
- : 'No per-gate blocked mistake names are present in today\'s local audit snapshot.',
1612
- ];
1782
+ if (intent.wantsList) {
1783
+ appendFeedbackListLines(lines, { entries, signal, intent });
1784
+ } else {
1785
+ lines.push(`Lesson pipeline: ${compactNumber(lessonPipeline.lessons || lessonPipeline.generated || 0)} lessons visible in the current dashboard snapshot.`);
1613
1786
  }
1787
+ return { lines, sources: ['feedback log', intent.wantsList ? 'feedback contexts' : 'lesson pipeline'] };
1788
+ }
1614
1789
 
1615
- if (/\b(prevent|prevented|intercept|intercepted)\b/.test(lower)) {
1616
- return [
1617
- `Mistakes prevented today: ${compactNumber(today.intercepted)} interventions (${compactNumber(today.deny)} blocked, ${compactNumber(today.warn)} warned).`,
1618
- `All-time blocked actions recorded: ${compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked)}.`,
1619
- ];
1620
- }
1790
+ const GATE_EVENTS_REGEX = /activat|fired|trigger|block|denied|prevent|enforce|hit/;
1621
1791
 
1622
- if (/\b(block|blocked|deny|denied)\b/.test(lower)) {
1623
- return [
1624
- `Mistakes blocked today: ${compactNumber(today.deny)} deny decisions.`,
1625
- `Warnings today: ${compactNumber(today.warn)}; all-time blocked actions recorded: ${compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked)}.`,
1626
- ];
1627
- }
1792
+ function describeGate(g) {
1793
+ return `${g.name || g.id || 'unnamed'}${g.severity ? ' [' + g.severity + ']' : ''}`;
1794
+ }
1628
1795
 
1629
- return null;
1796
+ function buildGatesSection({ ctx, intent, gates, gateStats }) {
1797
+ const asksEvents = GATE_EVENTS_REGEX.test(String(ctx.prompt || '').toLowerCase());
1798
+ const totalActive = gates.length || compactNumber(gateStats.totalGates);
1799
+ const totalBlocked = compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked);
1800
+
1801
+ const lines = [];
1802
+ if (asksEvents) {
1803
+ lines.push(`Blocked actions recorded (all time): ${totalBlocked}.`);
1804
+ if (intent.windowMs) {
1805
+ lines.push(`(Per-${intent.windowLabel} block-event breakdown isn't tracked in the local dashboard snapshot — only the running total. Filter the gate-events log directly for a precise window.)`);
1806
+ }
1807
+ } else {
1808
+ lines.push(`Active gates: ${totalActive}.`);
1809
+ }
1810
+ if (intent.wantsList && gates.length) {
1811
+ lines.push('Active gates:');
1812
+ for (const g of gates.slice(0, 8)) lines.push(` • ${describeGate(g)}`);
1813
+ } else if (gates[0] && !intent.wantsList) {
1814
+ lines.push(`Example gate: ${describeGate(gates[0])}.`);
1815
+ }
1816
+ return { lines, sources: ['gate stats'] };
1630
1817
  }
1631
1818
 
1632
- function buildEnterpriseChatSection(topic, dashboardData, status, prompt) {
1819
+ function buildEnterpriseChatSection(topic, dashboardData, status, ctx = {}) {
1633
1820
  const approval = dashboardData.approval || {};
1634
1821
  const gates = Array.isArray(dashboardData.gates) ? dashboardData.gates : [];
1635
1822
  const gateStats = dashboardData.gateStats || {};
1636
1823
  const team = dashboardData.team || {};
1637
1824
  const tokenSavings = dashboardData.tokenSavings || {};
1638
1825
  const lessonPipeline = dashboardData.lessonPipeline || {};
1826
+ const intent = ctx.intent || { wantsList: false, windowMs: null, windowLabel: 'across all time' };
1827
+ const feedbackDir = ctx.feedbackDir || null;
1639
1828
 
1640
1829
  if (topic === 'feedback') {
1641
- return {
1642
- lines: [
1643
- `Feedback total: ${compactNumber(approval.total)} (${compactNumber(approval.positive)} positive, ${compactNumber(approval.negative)} negative).`,
1644
- `Lesson pipeline: ${compactNumber(lessonPipeline.lessons || lessonPipeline.generated || 0)} lessons visible in the current dashboard snapshot.`,
1645
- ],
1646
- sources: ['feedback log', 'lesson pipeline'],
1647
- };
1830
+ return buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeline });
1648
1831
  }
1649
1832
  if (topic === 'gates') {
1650
- const todayGateAnswer = buildTodayGateAnswer(prompt, dashboardData, gateStats, gates);
1651
- if (todayGateAnswer) {
1652
- return {
1653
- lines: todayGateAnswer,
1654
- sources: ['gate audit', 'gate stats'],
1655
- };
1656
- }
1657
- return {
1658
- lines: [
1659
- `Active gates: ${gates.length || compactNumber(gateStats.totalGates)}.`,
1660
- `Blocked actions recorded: ${compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked)}.`,
1661
- gates[0] ? `Example gate: ${gates[0].name || gates[0].id || 'unnamed gate'}.` : '',
1662
- ].filter(Boolean),
1663
- sources: ['gate stats'],
1664
- };
1833
+ return buildGatesSection({ ctx, intent, gates, gateStats });
1665
1834
  }
1666
1835
  if (topic === 'team') {
1667
1836
  return {
@@ -1702,13 +1871,22 @@ function buildEnterpriseChatSection(topic, dashboardData, status, prompt) {
1702
1871
  };
1703
1872
  }
1704
1873
 
1705
- function buildEnterpriseChatAnswer(prompt, dashboardData, status) {
1874
+ function buildEnterpriseChatAnswer(prompt, dashboardData, status, opts = {}) {
1706
1875
  const topic = classifyEnterpriseChatTopic(prompt);
1707
- const section = buildEnterpriseChatSection(topic, dashboardData, status, prompt);
1876
+ const intent = parseChatIntent(prompt);
1877
+ const section = buildEnterpriseChatSection(topic, dashboardData, status, {
1878
+ intent,
1879
+ feedbackDir: opts.feedbackDir,
1880
+ prompt,
1881
+ });
1882
+
1883
+ // List-style answers want newlines; single-line answers join with space.
1884
+ const hasList = section.lines.some((l) => /^\s*•/.test(l));
1885
+ const answer = hasList ? section.lines.join('\n') : section.lines.join(' ');
1708
1886
 
1709
1887
  return {
1710
1888
  topic,
1711
- answer: section.lines.join(' '),
1889
+ answer,
1712
1890
  sources: ['local dashboard data', ...section.sources],
1713
1891
  };
1714
1892
  }
@@ -1724,6 +1902,7 @@ async function trySendLocalDashboardChat(res, parsed, feedbackDir, prompt, suffi
1724
1902
  prompt,
1725
1903
  dashboardResult.data,
1726
1904
  buildEnterpriseDataChatStatus(),
1905
+ { feedbackDir },
1727
1906
  );
1728
1907
  sendJson(res, 200, {
1729
1908
  ok: true,
@@ -1735,7 +1914,7 @@ async function trySendLocalDashboardChat(res, parsed, feedbackDir, prompt, suffi
1735
1914
  grounded: true,
1736
1915
  });
1737
1916
  return true;
1738
- } catch (_) {
1917
+ } catch {
1739
1918
  return false;
1740
1919
  }
1741
1920
  }
@@ -1771,7 +1950,7 @@ async function answerEnterpriseDataChat({ prompt, feedbackDir, parsed }) {
1771
1950
  // The dashboard's real local/open-source chatbot turn goes through /v1/chat,
1772
1951
  // which uses lesson retrieval + optional LanceDB vector search + the user's
1773
1952
  // configured local or BYO model.
1774
- const chat = buildEnterpriseChatAnswer(normalizedPrompt, dashboardData, status);
1953
+ const chat = buildEnterpriseChatAnswer(normalizedPrompt, dashboardData, status, { feedbackDir });
1775
1954
  const dfcxRequest = {
1776
1955
  fulfillmentInfo: { tag: 'chat-with-data' },
1777
1956
  sessionInfo: {
@@ -1809,7 +1988,7 @@ async function answerEnterpriseDataChat({ prompt, feedbackDir, parsed }) {
1809
1988
  const answerEnterpriseDialogflowChat = answerEnterpriseDataChat;
1810
1989
 
1811
1990
  function buildLossAnalyticsResponse(data, summaryOptions) {
1812
- return {
1991
+ return sanitizeHtmlUnsafeJsonValue({
1813
1992
  window: data.analytics.window || summaryOptions,
1814
1993
  lossAnalysis: data.analytics.lossAnalysis || null,
1815
1994
  buyerLoss: data.analytics.buyerLoss || null,
@@ -1821,13 +2000,50 @@ function buildLossAnalyticsResponse(data, summaryOptions) {
1821
2000
  ctas: data.analytics.telemetry && data.analytics.telemetry.ctas,
1822
2001
  visitors: data.analytics.telemetry && data.analytics.telemetry.visitors,
1823
2002
  },
1824
- };
2003
+ });
1825
2004
  }
1826
2005
 
1827
2006
  function createJourneyId(prefix) {
1828
2007
  return createTraceId(prefix).replace(/^trace_/, `${prefix}_`);
1829
2008
  }
1830
2009
 
2010
+ function normalizeCheckoutInterstitialSampleRate(value) {
2011
+ const parsed = Number.parseFloat(String(value || '').trim());
2012
+ if (!Number.isFinite(parsed) || parsed <= 0) {
2013
+ return 0;
2014
+ }
2015
+ if (parsed > 1) {
2016
+ return Math.min(parsed / 100, 1);
2017
+ }
2018
+ return Math.min(parsed, 1);
2019
+ }
2020
+
2021
+ function stableUnitInterval(value) {
2022
+ const text = String(value || '');
2023
+ let hash = 2166136261;
2024
+ for (const character of text) {
2025
+ hash ^= character.codePointAt(0);
2026
+ hash = Math.imul(hash, 16777619);
2027
+ }
2028
+ return (hash >>> 0) / 4294967296;
2029
+ }
2030
+
2031
+ function shouldSampleCheckoutInterstitial({ sampleRate, traceId, analyticsMetadata }) {
2032
+ if (sampleRate <= 0) {
2033
+ return false;
2034
+ }
2035
+ if (sampleRate >= 1) {
2036
+ return true;
2037
+ }
2038
+ const seed = [
2039
+ analyticsMetadata?.visitorId,
2040
+ analyticsMetadata?.sessionId,
2041
+ analyticsMetadata?.acquisitionId,
2042
+ traceId,
2043
+ ].filter(Boolean).join(':');
2044
+ return stableUnitInterval(seed || traceId) < sampleRate;
2045
+ }
2046
+
1831
2047
  function appendQueryParam(url, key, value) {
1832
2048
  const normalized = normalizeNullableText(value);
1833
2049
  if (normalized) {
@@ -1887,9 +2103,245 @@ function buildCheckoutIntentHref(baseUrl, metadata = {}, overrides = {}) {
1887
2103
  });
1888
2104
  }
1889
2105
 
1890
- function renderCheckoutIntentPage(prefilledEmail = '') {
2106
+ const CHECKOUT_HIDDEN_ATTRIBUTION_KEYS = Object.freeze([
2107
+ 'trace_id',
2108
+ 'acquisition_id',
2109
+ 'visitor_id',
2110
+ 'session_id',
2111
+ 'visitor_session_id',
2112
+ 'install_id',
2113
+ 'utm_source',
2114
+ 'utm_medium',
2115
+ 'utm_campaign',
2116
+ 'utm_content',
2117
+ 'utm_term',
2118
+ 'creator',
2119
+ 'community',
2120
+ 'post_id',
2121
+ 'comment_id',
2122
+ 'campaign_variant',
2123
+ 'offer_code',
2124
+ 'cta_id',
2125
+ 'cta_placement',
2126
+ 'plan_id',
2127
+ 'billing_cycle',
2128
+ 'seat_count',
2129
+ 'landing_path',
2130
+ 'referrer_host',
2131
+ ]);
2132
+
2133
+ function normalizeHiddenAttributionValue(value) {
2134
+ const normalized = normalizeNullableText(value);
2135
+ if (!normalized) return '';
2136
+ return normalized.slice(0, 512);
2137
+ }
2138
+
2139
+ function buildCheckoutHiddenAttributionInputs(parsed = null) {
2140
+ if (!parsed?.searchParams) return '';
2141
+
2142
+ const inputs = [];
2143
+ for (const key of CHECKOUT_HIDDEN_ATTRIBUTION_KEYS) {
2144
+ const value = normalizeHiddenAttributionValue(parsed.searchParams.get(key));
2145
+ if (value) {
2146
+ inputs.push(`<input type="hidden" name="${key}" value="${escapeHtmlAttribute(value)}">`);
2147
+ }
2148
+ }
2149
+ return inputs.join('');
2150
+ }
2151
+
2152
+ function prunePendingOauthAuthorizeRequests(now = Date.now()) {
2153
+ for (const [token, entry] of pendingOauthAuthorizeRequests.entries()) {
2154
+ if (!entry || entry.expiresAt <= now) {
2155
+ pendingOauthAuthorizeRequests.delete(token);
2156
+ }
2157
+ }
2158
+ }
2159
+
2160
+ function createPendingOauthAuthorizeRequest(params, now = Date.now()) {
2161
+ prunePendingOauthAuthorizeRequests(now);
2162
+ const token = crypto.randomBytes(32).toString('base64url');
2163
+ pendingOauthAuthorizeRequests.set(token, {
2164
+ params,
2165
+ expiresAt: now + OAUTH_AUTHORIZE_REQUEST_TTL_MS,
2166
+ });
2167
+ return token;
2168
+ }
2169
+
2170
+ function consumePendingOauthAuthorizeRequest(token, now = Date.now()) {
2171
+ if (!token) return null;
2172
+ prunePendingOauthAuthorizeRequests(now);
2173
+ const entry = pendingOauthAuthorizeRequests.get(token);
2174
+ if (!entry) return null;
2175
+ pendingOauthAuthorizeRequests.delete(token);
2176
+ return entry.params;
2177
+ }
2178
+
2179
+ function getOauthAuthorizeParamsFromQuery(searchParams) {
2180
+ return {
2181
+ clientId: searchParams.get('client_id') || '',
2182
+ redirectUri: searchParams.get('redirect_uri') || '',
2183
+ codeChallenge: searchParams.get('code_challenge') || '',
2184
+ codeChallengeMethod: searchParams.get('code_challenge_method') || '',
2185
+ scope: searchParams.get('scope') || undefined,
2186
+ state: searchParams.get('state') || '',
2187
+ resource: searchParams.get('resource') || '',
2188
+ };
2189
+ }
2190
+
2191
+ function getOauthAuthorizeParamsFromForm(form, hostedConfig) {
2192
+ const pending = consumePendingOauthAuthorizeRequest(form.get('auth_request_token') || '');
2193
+ if (pending) return pending;
2194
+ return {
2195
+ clientId: form.get('client_id') || '',
2196
+ redirectUri: form.get('redirect_uri') || '',
2197
+ codeChallenge: form.get('code_challenge') || '',
2198
+ codeChallengeMethod: form.get('code_challenge_method') || '',
2199
+ scope: form.get('scope') || undefined,
2200
+ state: form.get('state') || '',
2201
+ resource: form.get('resource') || buildPublicUrl(hostedConfig, '/mcp'),
2202
+ };
2203
+ }
2204
+
2205
+ function renderCheckoutIntentPage(prefilledEmail = '', parsed = null, options = {}) {
1891
2206
  const plausibleDomain = escapeHtmlAttribute(resolvePlausibleDataDomain({ host: 'thumbgate.ai' }));
1892
- return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Confirm — ThumbGate Pro</title><script defer data-domain="${plausibleDomain}" src="https://plausible.io/js/script.tagged-events.js"></script><script>window.plausible=window.plausible||function(){(window.plausible.q=window.plausible.q||[]).push(arguments)};</script><style>body{background:#0a0a0a;color:#eee;font-family:system-ui,-apple-system,sans-serif;line-height:1.5}main{max-width:520px;margin:8vh auto;padding:0 20px}.brand{display:flex;align-items:center;gap:10px;margin-bottom:24px;font-size:14px;color:#94a3b8}.brand-mark{width:24px;height:24px;background:#22d3ee;border-radius:6px;display:inline-block}h1{font-size:24px;margin:0 0 8px;color:#fff}.price{font-size:32px;font-weight:700;color:#22d3ee;margin:8px 0 4px}.price small{font-size:14px;color:#94a3b8;font-weight:400}p{color:#cbd5e1;margin:8px 0}form{margin:0}input[type=email]{width:100%;box-sizing:border-box;padding:14px 16px;border:1px solid #374151;border-radius:8px;background:#111827;color:#fff;font-size:15px;margin:16px 0 0;outline:none}input[type=email]:focus{border-color:#22d3ee}input[type=email]::placeholder{color:#64748b}button.primary{background:#22d3ee;color:#000;padding:16px;text-align:center;border-radius:8px;font-weight:700;font-size:16px;margin:10px 0;border:none;cursor:pointer;width:100%}a{display:block;text-decoration:none}a.secondary{border:1px solid #374151;color:#cbd5e1;padding:12px;text-align:center;border-radius:8px;margin:8px 0 0;font-size:14px}.trust{margin:24px 0;padding:16px;border:1px solid #1f2937;border-radius:8px;background:#0f172a}.trust-item{font-size:13px;color:#cbd5e1;padding:4px 0;display:flex;gap:8px}.trust-item::before{content:"✓";color:#22d3ee;font-weight:700}.choice-note{font-size:13px;color:#94a3b8;margin-top:14px}.back{text-align:center;color:#64748b;font-size:12px;margin-top:24px}.back a{color:#64748b;display:inline}.email-note{font-size:12px;color:#64748b;margin:4px 0 0}</style><main><div class="brand"><span class="brand-mark"></span><span>ThumbGate</span></div><h1>Start ThumbGate Pro</h1><div class="price">$19<small>/mo</small></div><p>The npm package runs your gates locally. <strong>Pro</strong> is what keeps them working across every machine, every agent runtime, and every breaking-change week.</p><form action="/checkout/pro" method="GET" data-i="pro_checkout_confirmed"><input type="hidden" name="confirm" value="1"><input type="email" name="customer_email" value="${escapeHtmlAttribute(prefilledEmail)}" placeholder="you@company.com" required autocomplete="email"><p class="email-note">Pre-fills your Stripe receipt. We only email if you ask.</p><button type="submit" class="primary">Pay $19/mo with Stripe →</button></form><a class="secondary" data-i="workflow_sprint_intake" href="/#workflow-sprint-intake">Not sure yet? Send the workflow first</a><p class="choice-note">Cancel anytime. 7-day refund, no questions. Diagnostics and sprints have their own pages.</p><div class="trust"><div class="trust-item">Lessons synced across all your machines — no local SQLite to babysit</div><div class="trust-item">Adapter matrix kept current for Claude Code, Cursor, Codex, Gemini, Amp, Cline, OpenCode — version drift is our problem, not yours</div><div class="trust-item">Hosted dashboard: gate stats, DPO export, org-wide rule library</div><div class="trust-item">24×7 ops on the rule engine — SonarCloud regressions fixed in &lt;24h</div></div><p class="back"><a href="/">← Back to thumbgate.ai</a></p></main><script>document.querySelector('form').addEventListener('submit',e=>{if(navigator.sendBeacon)navigator.sendBeacon('/v1/telemetry/ping',new Blob([JSON.stringify({eventType:'checkout_interstitial_cta_clicked',clientType:'web',page:'/checkout/pro',ctaId:'pro_checkout_confirmed',ctaPlacement:'checkout_interstitial',customerEmail:document.querySelector('input[name=customer_email]').value})],{type:'application/json'}));try{window.plausible&&window.plausible('Checkout Pro Email Submitted',{props:{page:'/checkout/pro',source:'interstitial'}})}catch(_){}})</script></html>`;
2207
+ const includeHiddenAttribution = options.includeHiddenAttribution === true;
2208
+ const hiddenInputs = includeHiddenAttribution
2209
+ ? buildCheckoutHiddenAttributionInputs(parsed)
2210
+ : '';
2211
+ return `<!doctype html>
2212
+ <html lang="en">
2213
+ <head>
2214
+ <meta charset="utf-8">
2215
+ <meta name="viewport" content="width=device-width,initial-scale=1">
2216
+ <title>Confirm - ThumbGate Pro</title>
2217
+ <script defer data-domain="${plausibleDomain}" src="https://plausible.io/js/script.tagged-events.js"></script>
2218
+ <script>window.plausible=window.plausible||function(){(window.plausible.q=window.plausible.q||[]).push(arguments)};</script>
2219
+ <style>
2220
+ body{background:#0a0a0a;color:#eee;font-family:system-ui,-apple-system,sans-serif;line-height:1.5}
2221
+ main{max-width:520px;margin:8vh auto;padding:0 20px}
2222
+ .brand{display:flex;align-items:center;gap:10px;margin-bottom:24px;font-size:14px;color:#94a3b8}
2223
+ .brand-mark{width:24px;height:24px;background:#22d3ee;border-radius:6px;display:inline-block}
2224
+ h1{font-size:24px;margin:0 0 8px;color:#fff}.price{font-size:32px;font-weight:700;color:#22d3ee;margin:8px 0 4px}.price small{font-size:14px;color:#94a3b8;font-weight:400}
2225
+ p{color:#cbd5e1;margin:8px 0}form{margin:0}
2226
+ input[type=email]{width:100%;box-sizing:border-box;padding:14px 16px;border:1px solid #374151;border-radius:8px;background:#111827;color:#fff;font-size:15px;margin:16px 0 0;outline:none}
2227
+ input[type=email]:focus{border-color:#22d3ee}input[type=email]::placeholder{color:#64748b}
2228
+ button.primary{background:#22d3ee;color:#000;padding:16px;text-align:center;border-radius:8px;font-weight:700;font-size:16px;margin:10px 0;border:none;cursor:pointer;width:100%}
2229
+ a{display:block;text-decoration:none}a.secondary{border:1px solid #374151;color:#cbd5e1;padding:12px;text-align:center;border-radius:8px;margin:8px 0 0;font-size:14px}
2230
+ .trust,.objection-box{margin:24px 0;padding:16px;border:1px solid #1f2937;border-radius:8px;background:#0f172a}
2231
+ .trust-item{font-size:13px;color:#cbd5e1;padding:4px 0;display:flex;gap:8px}.trust-item::before{content:"✓";color:#22d3ee;font-weight:700}
2232
+ .choice-note,.email-note,.objection-note{font-size:13px;color:#94a3b8;margin-top:10px}
2233
+ .objection-grid{display:grid;gap:8px;margin-top:12px}
2234
+ .objection-grid button{border:1px solid #374151;background:#111827;color:#e5e7eb;border-radius:8px;padding:10px 12px;text-align:left;cursor:pointer}
2235
+ .objection-grid button:hover,.objection-grid button:focus{border-color:#22d3ee;outline:none}
2236
+ .feedback-saved{display:none;color:#22d3ee;font-size:13px;margin-top:10px}
2237
+ .back{text-align:center;color:#64748b;font-size:12px;margin-top:24px}.back a{color:#64748b;display:inline}
2238
+ </style>
2239
+ </head>
2240
+ <body>
2241
+ <main>
2242
+ <div class="brand"><span class="brand-mark"></span><span>ThumbGate</span></div>
2243
+ <h1>Start ThumbGate Pro</h1>
2244
+ <div class="price">$19<small>/mo</small></div>
2245
+ <p>The npm package runs your gates locally. <strong>Pro</strong> is what keeps them working across every machine, every agent runtime, and every breaking-change week.</p>
2246
+ <form action="/checkout/pro" method="GET" data-i="pro_checkout_confirmed">
2247
+ ${hiddenInputs}
2248
+ <input type="hidden" name="confirm" value="1">
2249
+ <input type="email" name="customer_email" value="${escapeHtmlAttribute(prefilledEmail)}" placeholder="you@company.com" autocomplete="email">
2250
+ <p class="email-note">Optional. Stripe can collect your email on the secure checkout page.</p>
2251
+ <button type="submit" class="primary">Pay $19/mo with Stripe →</button>
2252
+ </form>
2253
+ <a class="secondary" data-i="workflow_sprint_intake" href="/#workflow-sprint-intake">Not sure yet? Send the workflow first</a>
2254
+ <p class="choice-note">Cancel anytime. 7-day refund, no questions. Diagnostics and sprints have their own pages.</p>
2255
+ <div class="objection-box" aria-label="Checkout feedback">
2256
+ <strong>Not buying today?</strong>
2257
+ <p class="objection-note">Tap one reason so we know what to fix. This does not sign you up.</p>
2258
+ <div class="objection-grid">
2259
+ <button type="button" data-reason="price_unclear">Price or scope is unclear</button>
2260
+ <button type="button" data-reason="need_more_proof">Need more proof first</button>
2261
+ <button type="button" data-reason="need_team_plan">Need a team/workflow plan instead</button>
2262
+ <button type="button" data-reason="not_urgent">Not urgent right now</button>
2263
+ </div>
2264
+ <div class="feedback-saved" id="feedback-saved">Feedback saved.</div>
2265
+ </div>
2266
+ <div class="trust"><div class="trust-item">Lessons synced across all your machines — no local SQLite to babysit</div><div class="trust-item">Adapter matrix kept current for Claude Code, Cursor, Codex, Gemini, Amp, Cline, OpenCode — version drift is our problem, not yours</div><div class="trust-item">Hosted dashboard: gate stats, DPO export, org-wide rule library</div><div class="trust-item">24×7 ops on the rule engine — SonarCloud regressions fixed in &lt;24h</div></div>
2267
+ <p class="back"><a href="/">← Back to thumbgate.ai</a></p>
2268
+ </main>
2269
+ <script>
2270
+ (function(){
2271
+ var params = new URLSearchParams(window.location.search);
2272
+ var submitted = false;
2273
+ var feedbackSent = false;
2274
+ function pick(key, fallback) { return params.get(key) || fallback || null; }
2275
+ function sendTelemetry(eventType, extra) {
2276
+ var payload = Object.assign({
2277
+ eventType: eventType,
2278
+ clientType: 'web',
2279
+ page: '/checkout/pro',
2280
+ traceId: pick('trace_id'),
2281
+ acquisitionId: pick('acquisition_id'),
2282
+ visitorId: pick('visitor_id'),
2283
+ sessionId: pick('visitor_session_id') || pick('session_id'),
2284
+ installId: pick('install_id'),
2285
+ source: pick('utm_source') || pick('source') || 'website',
2286
+ utmSource: pick('utm_source') || pick('source') || 'website',
2287
+ utmMedium: pick('utm_medium') || 'checkout_interstitial',
2288
+ utmCampaign: pick('utm_campaign') || 'pro_pack',
2289
+ utmContent: pick('utm_content'),
2290
+ utmTerm: pick('utm_term'),
2291
+ creator: pick('creator') || pick('creator_handle'),
2292
+ community: pick('community') || pick('subreddit'),
2293
+ postId: pick('post_id'),
2294
+ commentId: pick('comment_id'),
2295
+ campaignVariant: pick('campaign_variant'),
2296
+ offerCode: pick('offer_code'),
2297
+ ctaId: pick('cta_id') || 'pricing_pro',
2298
+ ctaPlacement: pick('cta_placement') || 'checkout_interstitial',
2299
+ planId: pick('plan_id') || 'pro',
2300
+ billingCycle: pick('billing_cycle') || 'monthly',
2301
+ landingPath: pick('landing_path') || '/',
2302
+ referrerHost: pick('referrer_host'),
2303
+ referrer: document.referrer || null
2304
+ }, extra || {});
2305
+ var body = JSON.stringify(payload);
2306
+ if (navigator.sendBeacon) {
2307
+ navigator.sendBeacon('/v1/telemetry/ping', new Blob([body], { type: 'application/json' }));
2308
+ return;
2309
+ }
2310
+ fetch('/v1/telemetry/ping', { method:'POST', headers:{ 'content-type':'application/json' }, body: body, keepalive: true }).catch(function(){});
2311
+ }
2312
+ document.querySelector('form').addEventListener('submit', function(){
2313
+ submitted = true;
2314
+ var emailInput = document.querySelector('input[name=customer_email]');
2315
+ sendTelemetry('checkout_interstitial_cta_clicked', {
2316
+ ctaId: 'pro_checkout_confirmed',
2317
+ ctaPlacement: 'checkout_interstitial',
2318
+ customerEmail: emailInput ? emailInput.value : null
2319
+ });
2320
+ try { window.plausible && window.plausible('Checkout Pro Email Submitted', { props: { page:'/checkout/pro', source:'interstitial' } }); } catch(_) {}
2321
+ });
2322
+ document.querySelectorAll('[data-reason]').forEach(function(button) {
2323
+ button.addEventListener('click', function(){
2324
+ var reason = button.getAttribute('data-reason');
2325
+ feedbackSent = true;
2326
+ sendTelemetry('reason_not_buying', {
2327
+ reasonCode: reason,
2328
+ ctaId: 'checkout_interstitial_reason_' + reason,
2329
+ ctaPlacement: 'checkout_interstitial_feedback'
2330
+ });
2331
+ try { window.plausible && window.plausible('Checkout Pro Reason Not Buying', { props: { page:'/checkout/pro', reasonCode: reason } }); } catch(_) {}
2332
+ var saved = document.getElementById('feedback-saved');
2333
+ if (saved) saved.style.display = 'block';
2334
+ });
2335
+ });
2336
+ window.addEventListener('pagehide', function(){
2337
+ if (!submitted && !feedbackSent) {
2338
+ sendTelemetry('checkout_interstitial_abandoned', { reasonCode: 'left_without_confirming' });
2339
+ }
2340
+ });
2341
+ })();
2342
+ </script>
2343
+ </body>
2344
+ </html>`;
1893
2345
  }
1894
2346
 
1895
2347
  function buildCheckoutBootstrapBody(parsed, req, journeyState = resolveJourneyState(req, parsed)) {
@@ -1953,17 +2405,6 @@ function normalizeCheckoutCustomerEmail(value) {
1953
2405
  return email;
1954
2406
  }
1955
2407
 
1956
- function renderCheckoutIntentGate(parsed, responseHeaders = {}) {
1957
- let hiddenInputs = '';
1958
- for (const [key, value] of parsed.searchParams.entries()) {
1959
- if (key !== 'confirm' && key !== 'customer_email') hiddenInputs += `<input type=hidden name=${escapeHtmlAttribute(key)} value=${escapeHtmlAttribute(value)}>`;
1960
- }
1961
- return {
1962
- html: `<!doctype html><h1>Email for Stripe receipt</h1><form action=/checkout/pro>${hiddenInputs}<input type=hidden name=confirm value=1><input name=customer_email type=email required><button>Continue</button></form>`,
1963
- headers: responseHeaders,
1964
- };
1965
- }
1966
-
1967
2408
  function normalizeTrackedLinkSlug(value) {
1968
2409
  return String(value || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
1969
2410
  }
@@ -1974,6 +2415,27 @@ function normalizePublicPageSlug(value) {
1974
2415
  .replace(/[^a-z0-9-]/g, '');
1975
2416
  }
1976
2417
 
2418
+ function buildPublicHtmlFileMap(directory) {
2419
+ const entries = new Map();
2420
+ try {
2421
+ for (const fileName of fs.readdirSync(directory)) {
2422
+ if (!/^[a-z0-9-]+\.html$/i.test(fileName)) continue;
2423
+ const slug = normalizePublicPageSlug(fileName);
2424
+ if (!slug) continue;
2425
+ entries.set(slug, path.join(directory, fileName));
2426
+ }
2427
+ } catch (error) {
2428
+ if (error?.code !== 'ENOENT') throw error;
2429
+ }
2430
+ return entries;
2431
+ }
2432
+
2433
+ function resolvePublicHtmlFile(publicPageMap, rawSlug) {
2434
+ const slug = normalizePublicPageSlug(rawSlug);
2435
+ if (!slug) return null;
2436
+ return publicPageMap.get(slug) || null;
2437
+ }
2438
+
1977
2439
  function getTrackedLinkTarget(slug) {
1978
2440
  const normalizedSlug = normalizeTrackedLinkSlug(slug);
1979
2441
  return TRACKED_LINK_TARGETS[normalizedSlug]
@@ -2009,8 +2471,10 @@ function appendTrackedLinkQueryParams(destinationUrl, parsed, target) {
2009
2471
  }
2010
2472
 
2011
2473
  function buildTrackedLinkDestination(target, hostedConfig, parsed) {
2012
- const destinationUrl = target.href
2013
- ? new URL(target.href)
2474
+ const configuredHref = target.configUrlKey ? hostedConfig[target.configUrlKey] : null;
2475
+ const href = target.href || configuredHref || target.fallbackHref;
2476
+ const destinationUrl = href
2477
+ ? new URL(href)
2014
2478
  : new URL(target.path || '/', hostedConfig.appOrigin);
2015
2479
  appendTrackedLinkQueryParams(destinationUrl, parsed, target);
2016
2480
  return destinationUrl;
@@ -2137,6 +2601,7 @@ function sendJson(res, statusCode, payload, extraHeaders = {}, options = {}) {
2137
2601
  const body = JSON.stringify(payload);
2138
2602
  res.writeHead(statusCode, {
2139
2603
  'Content-Type': 'application/json; charset=utf-8',
2604
+ 'X-Content-Type-Options': 'nosniff',
2140
2605
  'Content-Length': Buffer.byteLength(body),
2141
2606
  ...extraHeaders,
2142
2607
  });
@@ -2211,7 +2676,7 @@ function appendBestEffortTelemetry(feedbackDir, payload, headers, context) {
2211
2676
  evidence: [err?.message ? err.message : 'unknown_error'],
2212
2677
  },
2213
2678
  });
2214
- } catch (_) {}
2679
+ } catch {}
2215
2680
  return false;
2216
2681
  }
2217
2682
  }
@@ -2230,8 +2695,9 @@ function chatgptActionEventType(integration, suffix) {
2230
2695
  }
2231
2696
 
2232
2697
  function getPublicOrigin(req) {
2233
- const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() || 'http';
2234
- const host = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim() || 'localhost';
2698
+ const rawProto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
2699
+ const proto = rawProto === 'https' ? 'https' : 'http';
2700
+ const host = getSafePublicRequestHost(req) || 'localhost';
2235
2701
  return `${proto}://${host}`;
2236
2702
  }
2237
2703
 
@@ -2250,6 +2716,47 @@ function getRequestHostHeader(req) {
2250
2716
  return forwardedHost || req.headers.host || '';
2251
2717
  }
2252
2718
 
2719
+ function normalizePublicRequestHost(value) {
2720
+ const rawHost = String(value || '').split(',')[0].trim().toLowerCase();
2721
+ if (!rawHost || rawHost.length > 253) return '';
2722
+
2723
+ const hostWithoutPort = rawHost.startsWith('[')
2724
+ ? rawHost.slice(1).split(']')[0]
2725
+ : rawHost.split(':')[0];
2726
+ const port = rawHost.startsWith('[')
2727
+ ? rawHost.split(']:')[1] || ''
2728
+ : rawHost.split(':')[1] || '';
2729
+
2730
+ if (!isAllowedPublicHostName(hostWithoutPort)) {
2731
+ return '';
2732
+ }
2733
+ if (port && !/^\d{1,5}$/.test(port)) return '';
2734
+ if (port && Number(port) > 65535) return '';
2735
+ return port ? `${hostWithoutPort}:${port}` : hostWithoutPort;
2736
+ }
2737
+
2738
+ function isAllowedPublicHostName(hostname) {
2739
+ return hostname === 'localhost'
2740
+ || hostname === '::1'
2741
+ || isIpv4Host(hostname)
2742
+ || isDnsHostName(hostname);
2743
+ }
2744
+
2745
+ function isIpv4Host(hostname) {
2746
+ const parts = hostname.split('.');
2747
+ return parts.length === 4 && parts.every((part) => /^\d{1,3}$/.test(part));
2748
+ }
2749
+
2750
+ function isDnsHostName(hostname) {
2751
+ return hostname
2752
+ .split('.')
2753
+ .every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
2754
+ }
2755
+
2756
+ function getSafePublicRequestHost(req) {
2757
+ return normalizePublicRequestHost(getRequestHostHeader(req));
2758
+ }
2759
+
2253
2760
  function isLoopbackHost(hostValue) {
2254
2761
  const rawHost = String(hostValue || '').split(',')[0].trim();
2255
2762
  if (!rawHost) {
@@ -2295,7 +2802,7 @@ function stripTrailingSlashes(value) {
2295
2802
  return input.slice(0, end);
2296
2803
  }
2297
2804
 
2298
- function normalizePublicMarketingHtml(html, runtimeConfig) {
2805
+ function normalizePublicMarketingHtml(html, runtimeConfig, requestHost) {
2299
2806
  const appOrigin = runtimeConfig?.appOrigin
2300
2807
  ? stripTrailingSlashes(runtimeConfig.appOrigin)
2301
2808
  : '';
@@ -2304,11 +2811,14 @@ function normalizePublicMarketingHtml(html, runtimeConfig) {
2304
2811
  let output = String(html);
2305
2812
  output = output.replaceAll(DEFAULT_PUBLIC_APP_ORIGIN, appOrigin);
2306
2813
  try {
2307
- const host = new URL(appOrigin).host;
2814
+ const host = normalizePublicRequestHost(requestHost) || new URL(appOrigin).host;
2308
2815
  const plausibleDomain = resolvePlausibleDataDomain({ host });
2309
2816
  output = output.replaceAll(
2310
2817
  'data-domain="thumbgate-production.up.railway.app"',
2311
2818
  `data-domain="${escapeHtmlAttribute(plausibleDomain)}"`
2819
+ ).replaceAll(
2820
+ 'data-domain="thumbgate.ai"',
2821
+ `data-domain="${escapeHtmlAttribute(plausibleDomain)}"`
2312
2822
  );
2313
2823
  } catch {
2314
2824
  // appOrigin is normalized by hosted-config; leave static analytics domains
@@ -2357,7 +2867,7 @@ function loadPublicMarketingTemplateHtml(templatePath, runtimeConfig, pageContex
2357
2867
  '__GTM_PLAN_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/GO_TO_MARKET_REVENUE_WEDGE_2026-03.md',
2358
2868
  '__GITHUB_URL__': 'https://github.com/IgorGanapolsky/ThumbGate',
2359
2869
  '__POSTHOG_API_KEY__': runtimeConfig.posthogApiKey || '',
2360
- }), runtimeConfig);
2870
+ }), runtimeConfig, pageContext.requestHost);
2361
2871
  }
2362
2872
 
2363
2873
  function loadLandingPageHtml(runtimeConfig, pageContext = {}) {
@@ -2372,6 +2882,18 @@ function loadPricingPageHtml(runtimeConfig, pageContext = {}) {
2372
2882
  return loadPublicMarketingTemplateHtml(PRICING_PAGE_PATH, runtimeConfig, pageContext);
2373
2883
  }
2374
2884
 
2885
+ function loadAboutPageHtml(runtimeConfig, pageContext = {}) {
2886
+ return loadPublicMarketingTemplateHtml(ABOUT_PAGE_PATH, runtimeConfig, pageContext);
2887
+ }
2888
+
2889
+ function loadGuidePageHtml(runtimeConfig, pageContext = {}) {
2890
+ return loadPublicMarketingTemplateHtml(GUIDE_PAGE_PATH, runtimeConfig, pageContext);
2891
+ }
2892
+
2893
+ function loadLearnPageHtml(runtimeConfig, pageContext = {}) {
2894
+ return loadPublicMarketingTemplateHtml(LEARN_PAGE_PATH, runtimeConfig, pageContext);
2895
+ }
2896
+
2375
2897
  function readOptionalPublicTemplate(filePath) {
2376
2898
  try {
2377
2899
  return fs.readFileSync(filePath, 'utf-8');
@@ -3004,6 +3526,7 @@ function renderSitemapXml(runtimeConfig) {
3004
3526
  { path: '/learn/codex-role-plugins-need-governance', changefreq: 'weekly', priority: '0.85' },
3005
3527
  { path: '/learn/agentic-os-team-governance', changefreq: 'weekly', priority: '0.85' },
3006
3528
  { path: '/learn/cost-aware-agent-gate-routing', changefreq: 'weekly', priority: '0.85' },
3529
+ { path: '/learn/pretix-stripe-connect-marketplaces', changefreq: 'weekly', priority: '0.9' },
3007
3530
  { path: '/compare/claude-code-hooks', changefreq: 'weekly', priority: '0.85' },
3008
3531
  { path: '/compare/bumblebee', changefreq: 'weekly', priority: '0.85' },
3009
3532
  { path: '/compare/anthropic-containment', changefreq: 'weekly', priority: '0.85' },
@@ -3012,6 +3535,30 @@ function renderSitemapXml(runtimeConfig) {
3012
3535
  { path: '/compare/anthropic-claude-for-legal', changefreq: 'weekly', priority: '0.9' },
3013
3536
  ...THUMBGATE_SEO_SITEMAP_ENTRIES,
3014
3537
  ];
3538
+ // Auto-include every hand-written SEO page so /sitemap.xml can never drift out
3539
+ // of sync with public/compare/*.html or public/guides/*.html. Crawlers and AI
3540
+ // answer engines only surface pages they can discover, so a buyer-intent page
3541
+ // missing from the sitemap is invisible on its query. De-duped against entries
3542
+ // already declared above (e.g. seo-gsd specs), which keep explicit priorities.
3543
+ const declaredPaths = new Set(entries.map((entry) => entry.path));
3544
+ try {
3545
+ const seoDirectories = [
3546
+ { dir: 'compare', route: '/compare', priority: '0.85' },
3547
+ { dir: 'guides', route: '/guides', priority: '0.85' },
3548
+ ];
3549
+ for (const catalog of seoDirectories) {
3550
+ const files = fs.readdirSync(path.join(PUBLIC_DIR, catalog.dir)).sort((a, b) => a.localeCompare(b));
3551
+ for (const file of files) {
3552
+ if (!file.endsWith('.html')) continue;
3553
+ const publicPath = `${catalog.route}/${file.replace(/\.html$/, '')}`;
3554
+ if (declaredPaths.has(publicPath)) continue;
3555
+ declaredPaths.add(publicPath);
3556
+ entries.push({ path: publicPath, changefreq: 'weekly', priority: catalog.priority });
3557
+ }
3558
+ }
3559
+ } catch {
3560
+ // SEO directories absent in a stripped bundle — fall back to static entries.
3561
+ }
3015
3562
  return [
3016
3563
  '<?xml version="1.0" encoding="UTF-8"?>',
3017
3564
  '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
@@ -3137,11 +3684,13 @@ function servePublicMarketingPage({
3137
3684
  }, req.headers, 'seo_landing_view');
3138
3685
  }
3139
3686
 
3687
+ const requestHost = getSafePublicRequestHost(req);
3140
3688
  const html = renderHtml(hostedConfig, {
3141
3689
  serverVisitorId: journeyState.visitorId,
3142
3690
  serverSessionId: journeyState.sessionId,
3143
3691
  serverAcquisitionId: journeyState.acquisitionId,
3144
3692
  serverTelemetryCaptured: landingTelemetryCaptured,
3693
+ requestHost,
3145
3694
  });
3146
3695
 
3147
3696
  sendHtml(
@@ -3423,7 +3972,7 @@ function renderCheckoutSuccessPage(runtimeConfig) {
3423
3972
  if (window.sessionStorage) {
3424
3973
  window.sessionStorage.setItem(marker, '1');
3425
3974
  }
3426
- } catch (_) {
3975
+ } catch {
3427
3976
  sendTelemetry(eventType, extra);
3428
3977
  }
3429
3978
  }
@@ -4220,6 +4769,42 @@ function normalizeJobIdFromPath(pathname, suffix = '') {
4220
4769
  return match ? decodeURIComponent(match[1]) : null;
4221
4770
  }
4222
4771
 
4772
+ function escapeHtml(value) {
4773
+ return String(value)
4774
+ .replaceAll('&', '&amp;')
4775
+ .replaceAll('<', '&lt;')
4776
+ .replaceAll('>', '&gt;')
4777
+ .replaceAll('"', '&quot;')
4778
+ .replaceAll("'", '&#39;');
4779
+ }
4780
+
4781
+ function escapeHtmlUnsafeJsonString(value) {
4782
+ return String(value)
4783
+ .replaceAll('<', String.raw`\u003c`)
4784
+ .replaceAll('>', String.raw`\u003e`)
4785
+ .replaceAll('&', String.raw`\u0026`)
4786
+ .replaceAll('\u2028', String.raw`\u2028`)
4787
+ .replaceAll('\u2029', String.raw`\u2029`);
4788
+ }
4789
+
4790
+ function sanitizeHtmlUnsafeJsonValue(value) {
4791
+ if (typeof value === 'string') {
4792
+ return escapeHtmlUnsafeJsonString(value);
4793
+ }
4794
+ if (Array.isArray(value)) {
4795
+ return value.map((entry) => sanitizeHtmlUnsafeJsonValue(entry));
4796
+ }
4797
+ if (!value || typeof value !== 'object') {
4798
+ return value;
4799
+ }
4800
+ return Object.fromEntries(
4801
+ Object.entries(value).map(([key, entry]) => [
4802
+ escapeHtmlUnsafeJsonString(key),
4803
+ sanitizeHtmlUnsafeJsonValue(entry),
4804
+ ])
4805
+ );
4806
+ }
4807
+
4223
4808
  function normalizeDocumentIdFromPath(pathname) {
4224
4809
  const match = pathname.match(/^\/v1\/documents\/([^/]+)$/);
4225
4810
  return match ? decodeURIComponent(match[1]) : null;
@@ -4991,12 +5576,40 @@ async function addContext(){
4991
5576
  return;
4992
5577
  }
4993
5578
 
5579
+ if (isGetLikeRequest && (pathname === '/about' || pathname === '/about.html')) {
5580
+ try {
5581
+ servePublicMarketingPage({
5582
+ req,
5583
+ res,
5584
+ parsed,
5585
+ hostedConfig,
5586
+ isHeadRequest,
5587
+ renderHtml: loadAboutPageHtml,
5588
+ extraTelemetry: {
5589
+ pageType: 'about',
5590
+ },
5591
+ });
5592
+ } catch (err) {
5593
+ sendText(res, 500, err.message || 'About page unavailable');
5594
+ }
5595
+ return;
5596
+ }
5597
+
4994
5598
  if (isGetLikeRequest && (pathname === '/guide' || pathname === '/guide.html')) {
4995
5599
  try {
4996
- const html = fs.readFileSync(GUIDE_PAGE_PATH, 'utf-8');
4997
- sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
4998
- } catch {
4999
- sendJson(res, 404, { error: 'Guide page not found' });
5600
+ servePublicMarketingPage({
5601
+ req,
5602
+ res,
5603
+ parsed,
5604
+ hostedConfig,
5605
+ isHeadRequest,
5606
+ renderHtml: loadGuidePageHtml,
5607
+ extraTelemetry: {
5608
+ pageType: 'guide',
5609
+ },
5610
+ });
5611
+ } catch (err) {
5612
+ sendText(res, 500, err.message || 'Guide page unavailable');
5000
5613
  }
5001
5614
  return;
5002
5615
  }
@@ -5056,10 +5669,19 @@ async function addContext(){
5056
5669
 
5057
5670
  if (isGetLikeRequest && (pathname === '/learn' || pathname === '/learn.html')) {
5058
5671
  try {
5059
- const html = fs.readFileSync(LEARN_PAGE_PATH, 'utf-8');
5060
- sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5061
- } catch {
5062
- sendJson(res, 404, { error: 'Learn page not found' });
5672
+ servePublicMarketingPage({
5673
+ req,
5674
+ res,
5675
+ parsed,
5676
+ hostedConfig,
5677
+ isHeadRequest,
5678
+ renderHtml: loadLearnPageHtml,
5679
+ extraTelemetry: {
5680
+ pageType: 'learn',
5681
+ },
5682
+ });
5683
+ } catch (err) {
5684
+ sendText(res, 500, err.message || 'Learn page unavailable');
5063
5685
  }
5064
5686
  return;
5065
5687
  }
@@ -5250,13 +5872,13 @@ async function addContext(){
5250
5872
 
5251
5873
  if (isGetLikeRequest && pathname.startsWith('/learn/')) {
5252
5874
  try {
5253
- const slug = normalizePublicPageSlug(pathname.replace('/learn/', ''));
5254
- const articlePath = path.join(LEARN_DIR, `${slug}.html`);
5255
- if (!articlePath.startsWith(LEARN_DIR)) {
5256
- sendJson(res, 403, { error: 'Forbidden' });
5875
+ const articlePath = resolvePublicHtmlFile(LEARN_PAGE_PATHS_BY_SLUG, pathname.replace('/learn/', ''));
5876
+ if (!articlePath) {
5877
+ sendJson(res, 404, { error: 'Article not found' });
5257
5878
  return;
5258
5879
  }
5259
- const html = fs.readFileSync(articlePath, 'utf-8');
5880
+ const requestHost = getSafePublicRequestHost(req);
5881
+ const html = normalizePublicMarketingHtml(fs.readFileSync(articlePath, 'utf-8'), hostedConfig, requestHost);
5260
5882
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5261
5883
  } catch {
5262
5884
  sendJson(res, 404, { error: 'Article not found' });
@@ -5266,10 +5888,10 @@ async function addContext(){
5266
5888
 
5267
5889
  if (isGetLikeRequest && pathname.startsWith('/guides/')) {
5268
5890
  try {
5269
- const slug = normalizePublicPageSlug(pathname.replace('/guides/', ''));
5270
- const guidePath = path.join(GUIDES_DIR, `${slug}.html`);
5271
- if (!guidePath.startsWith(GUIDES_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
5272
- const html = fs.readFileSync(guidePath, 'utf-8');
5891
+ const guidePath = resolvePublicHtmlFile(GUIDE_PAGE_PATHS_BY_SLUG, pathname.replace('/guides/', ''));
5892
+ if (!guidePath) { sendJson(res, 404, { error: 'Guide not found' }); return; }
5893
+ const requestHost = getSafePublicRequestHost(req);
5894
+ const html = normalizePublicMarketingHtml(fs.readFileSync(guidePath, 'utf-8'), hostedConfig, requestHost);
5273
5895
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5274
5896
  } catch { sendJson(res, 404, { error: 'Guide not found' }); }
5275
5897
  return;
@@ -5277,10 +5899,10 @@ async function addContext(){
5277
5899
 
5278
5900
  if (isGetLikeRequest && pathname.startsWith('/compare/') && pathname !== '/compare') {
5279
5901
  try {
5280
- const slug = normalizePublicPageSlug(pathname.replace('/compare/', ''));
5281
- const comparePath = path.join(COMPARE_DIR, `${slug}.html`);
5282
- if (!comparePath.startsWith(COMPARE_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
5283
- const html = fs.readFileSync(comparePath, 'utf-8');
5902
+ const comparePath = resolvePublicHtmlFile(COMPARE_PAGE_PATHS_BY_SLUG, pathname.replace('/compare/', ''));
5903
+ if (!comparePath) { sendJson(res, 404, { error: 'Comparison not found' }); return; }
5904
+ const requestHost = getSafePublicRequestHost(req);
5905
+ const html = normalizePublicMarketingHtml(fs.readFileSync(comparePath, 'utf-8'), hostedConfig, requestHost);
5284
5906
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5285
5907
  } catch { sendJson(res, 404, { error: 'Comparison not found' }); }
5286
5908
  return;
@@ -5288,10 +5910,10 @@ async function addContext(){
5288
5910
 
5289
5911
  if (isGetLikeRequest && pathname.startsWith('/use-cases/')) {
5290
5912
  try {
5291
- const slug = normalizePublicPageSlug(pathname.replace('/use-cases/', ''));
5292
- const useCasePath = path.join(USE_CASES_DIR, `${slug}.html`);
5293
- if (!useCasePath.startsWith(USE_CASES_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
5294
- const html = fs.readFileSync(useCasePath, 'utf-8');
5913
+ const useCasePath = resolvePublicHtmlFile(USE_CASE_PAGE_PATHS_BY_SLUG, pathname.replace('/use-cases/', ''));
5914
+ if (!useCasePath) { sendJson(res, 404, { error: 'Use case not found' }); return; }
5915
+ const requestHost = getSafePublicRequestHost(req);
5916
+ const html = normalizePublicMarketingHtml(fs.readFileSync(useCasePath, 'utf-8'), hostedConfig, requestHost);
5295
5917
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5296
5918
  } catch { sendJson(res, 404, { error: 'Use case not found' }); }
5297
5919
  return;
@@ -5308,6 +5930,18 @@ async function addContext(){
5308
5930
  return;
5309
5931
  }
5310
5932
 
5933
+ if (isGetLikeRequest && pathname.startsWith('/media/')) {
5934
+ const rel = pathname.slice('/media/'.length);
5935
+ const mediaDir = path.join(PUBLIC_DIR, 'media');
5936
+ const resolved = path.resolve(mediaDir, rel);
5937
+ if (!resolved.startsWith(mediaDir + path.sep) && resolved !== mediaDir) {
5938
+ sendJson(res, 403, { error: 'Forbidden' });
5939
+ return;
5940
+ }
5941
+ serveStaticFile(res, resolved, { headOnly: isHeadRequest });
5942
+ return;
5943
+ }
5944
+
5311
5945
  if (isGetLikeRequest && (
5312
5946
  pathname === '/favicon.ico'
5313
5947
  || pathname === '/thumbgate-logo.png'
@@ -5389,8 +6023,70 @@ async function addContext(){
5389
6023
  // because no real crawler appends customer_email to discovered URLs.
5390
6024
  const hasCustomerEmailHint = !!parsed?.searchParams?.has('customer_email');
5391
6025
  const botShouldBypass = !botClassification.isBot || hasCustomerEmailHint;
5392
- const isConfirmedCheckout = req.method === 'POST'
6026
+ // 2026-06-04 audit: after the POST-401 fix, 3 of 4 fresh /checkout/pro
6027
+ // sessions had customer_email=null in Stripe (zombie sessions with no
6028
+ // recovery surface). Root cause: POSTs were auto-confirmed regardless of
6029
+ // whether the email query param was present. Require email on POSTs too
6030
+ // so emails-less POSTs fall through to the interstitial form instead of
6031
+ // creating an un-recoverable Stripe session.
6032
+ const isConfirmedCheckout = (req.method === 'POST' && hasCustomerEmailHint)
5393
6033
  || (hasConfirmFlag && botShouldBypass);
6034
+ // 2026-06-05 revenue bypass: env-gated direct-to-Stripe redirect.
6035
+ // Live 30d billing showed 254 interstitial views → 1 Stripe click-through
6036
+ // → 0 paid. When THUMBGATE_CHECKOUT_INTERSTITIAL_BYPASS=1 is set we
6037
+ // route raw /checkout/pro GETs (no confirm=1, no POST) straight to the
6038
+ // pro Stripe Payment Link, preserving UTM + attribution metadata via
6039
+ // buildCheckoutFallbackUrl. Default-off; bot-deflection still applies
6040
+ // (bot + no email hint still falls through to the existing interstitial).
6041
+ const interstitialBypassEnabled = process.env.THUMBGATE_CHECKOUT_INTERSTITIAL_BYPASS === '1';
6042
+ const interstitialSampleRate = normalizeCheckoutInterstitialSampleRate(
6043
+ process.env.THUMBGATE_CHECKOUT_INTERSTITIAL_SAMPLE_RATE
6044
+ );
6045
+ const interstitialSampled = shouldSampleCheckoutInterstitial({
6046
+ sampleRate: interstitialSampleRate,
6047
+ traceId,
6048
+ analyticsMetadata,
6049
+ });
6050
+ if (
6051
+ !isConfirmedCheckout
6052
+ && interstitialBypassEnabled
6053
+ && req.method !== 'POST'
6054
+ && botShouldBypass
6055
+ && !interstitialSampled
6056
+ ) {
6057
+ // Always target the pro Stripe Payment Link directly. The
6058
+ // hostedConfig.checkoutFallbackUrl (e.g. https://thumbgate.ai/go/pro)
6059
+ // is a router that 302s back to /checkout/pro, which would create a
6060
+ // redirect loop when bypass is on. Env override via
6061
+ // THUMBGATE_CHECKOUT_PRO_STRIPE_URL is supported for future
6062
+ // price-link rotation without a redeploy.
6063
+ const bypassTarget = process.env.THUMBGATE_CHECKOUT_PRO_STRIPE_URL
6064
+ || FIRST_FAILURE_RULE_CHECKOUT_URL;
6065
+ appendBestEffortTelemetry(FEEDBACK_DIR, {
6066
+ eventType: 'checkout_interstitial_bypass_redirect',
6067
+ clientType: 'web',
6068
+ traceId,
6069
+ acquisitionId: analyticsMetadata.acquisitionId,
6070
+ visitorId: analyticsMetadata.visitorId,
6071
+ sessionId: analyticsMetadata.sessionId,
6072
+ utmSource: analyticsMetadata.utmSource,
6073
+ utmMedium: analyticsMetadata.utmMedium,
6074
+ utmCampaign: analyticsMetadata.utmCampaign,
6075
+ utmContent: analyticsMetadata.utmContent,
6076
+ utmTerm: analyticsMetadata.utmTerm,
6077
+ referrer: analyticsMetadata.referrer,
6078
+ referrerHost: analyticsMetadata.referrerHost,
6079
+ page: '/checkout/pro',
6080
+ planId: analyticsMetadata.planId,
6081
+ interstitialSampleRate,
6082
+ }, req.headers, 'checkout_interstitial_bypass_redirect');
6083
+ res.writeHead(302, {
6084
+ ...responseHeaders,
6085
+ Location: buildCheckoutFallbackUrl(bypassTarget, analyticsMetadata),
6086
+ });
6087
+ res.end();
6088
+ return;
6089
+ }
5394
6090
  // Plausible funnel event #1 of 3: page view. Fired before interstitial
5395
6091
  // deflection so we get the full top-of-funnel count, with isBot as a
5396
6092
  // prop so the dashboard can filter human vs. crawler traffic. Fire-and-forget.
@@ -5449,10 +6145,14 @@ async function addContext(){
5449
6145
  billingCycle: analyticsMetadata.billingCycle,
5450
6146
  landingPath: analyticsMetadata.landingPath,
5451
6147
  isBot: botClassification.isBot ? 'true' : 'false',
6148
+ interstitialSampled: interstitialSampled ? 'true' : 'false',
6149
+ interstitialSampleRate,
5452
6150
  reason: botClassification.reason,
5453
6151
  }, req.headers, eventType);
5454
6152
  const prefilledEmail = parsed?.searchParams?.get('customer_email') || '';
5455
- const html = renderCheckoutIntentPage(prefilledEmail);
6153
+ const html = renderCheckoutIntentPage(prefilledEmail, parsed, {
6154
+ includeHiddenAttribution: !botClassification.isBot,
6155
+ });
5456
6156
  sendHtml(res, 200, html, responseHeaders);
5457
6157
  return;
5458
6158
  }
@@ -5715,12 +6415,13 @@ async function addContext(){
5715
6415
  // Public-facing broker lead-flow audit landing page. Wedge for the
5716
6416
  // real-estate broker outreach. Static HTML served from src/api/static.
5717
6417
  try {
6418
+ const host = getSafePublicRequestHost(req);
5718
6419
  const html = normalizePublicMarketingHtml(fillTemplate(fs.readFileSync(
5719
6420
  path.resolve(__dirname, '../../assets/static/broker-audit.html'),
5720
6421
  'utf8'
5721
6422
  ), {
5722
6423
  '__POSTHOG_API_KEY__': hostedConfig.posthogApiKey || '',
5723
- }), hostedConfig);
6424
+ }), hostedConfig, host);
5724
6425
  if (isHeadRequest) {
5725
6426
  sendHtml(res, 200, html, {}, { headOnly: true });
5726
6427
  return;
@@ -5772,9 +6473,9 @@ async function addContext(){
5772
6473
  // Authorization endpoint: GET renders consent, POST issues the code.
5773
6474
  if (pathname === '/oauth/authorize') {
5774
6475
  if (isGetLikeRequest) {
5775
- const q = parsed.searchParams;
5776
- const fields = ['client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'resource'];
5777
- const hidden = fields.map((f) => `<input type="hidden" name="${f}" value="${escapeHtmlAttribute(q.get(f) || '')}">`).join('\n');
6476
+ const authRequestToken = createPendingOauthAuthorizeRequest(
6477
+ getOauthAuthorizeParamsFromQuery(parsed.searchParams)
6478
+ );
5778
6479
  const html = `<!doctype html><html><head><meta charset="utf-8"><title>Authorize ThumbGate</title>
5779
6480
  <style>body{font:15px system-ui;margin:0;background:#0b0b0c;color:#eee;display:flex;min-height:100vh;align-items:center;justify-content:center}
5780
6481
  .card{background:#161618;border:1px solid #2a2a2e;border-radius:12px;padding:28px;max-width:420px}
@@ -5783,7 +6484,7 @@ button{width:100%;padding:11px;border-radius:8px;border:0;background:#10b981;col
5783
6484
  a{color:#8b9}</style></head><body><form class="card" method="post" action="/oauth/authorize">
5784
6485
  <h2>Authorize Claude → ThumbGate</h2>
5785
6486
  <p>Paste your ThumbGate API key to let this connector act as you. Get one with <code>npx thumbgate init</code> or from your <a href="/dashboard">dashboard</a>.</p>
5786
- ${hidden}
6487
+ <input type="hidden" name="auth_request_token" value="${escapeHtmlAttribute(authRequestToken)}">
5787
6488
  <input type="password" name="api_key" placeholder="ThumbGate API key" autocomplete="off" required>
5788
6489
  <button type="submit" name="approve" value="yes">Approve</button>
5789
6490
  </form></body></html>`;
@@ -5795,8 +6496,9 @@ ${hidden}
5795
6496
  req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
5796
6497
  req.on('end', () => {
5797
6498
  const form = new URLSearchParams(body);
5798
- const redirectUri = form.get('redirect_uri') || '';
5799
- const state = form.get('state') || '';
6499
+ const authorizationParams = getOauthAuthorizeParamsFromForm(form, hostedConfig);
6500
+ const redirectUri = authorizationParams.redirectUri;
6501
+ const state = authorizationParams.state;
5800
6502
  // Validate the presented ThumbGate key before issuing a code. When keys
5801
6503
  // are configured (production) the key MUST match a configured admin /
5802
6504
  // operator / reviewer key — otherwise OAuth would authenticate nobody.
@@ -5806,12 +6508,12 @@ ${hidden}
5806
6508
  return;
5807
6509
  }
5808
6510
  const issued = mcpOauth.createAuthorizationCode(oauthStore, {
5809
- clientId: form.get('client_id') || '',
6511
+ clientId: authorizationParams.clientId,
5810
6512
  redirectUri,
5811
- codeChallenge: form.get('code_challenge') || '',
5812
- codeChallengeMethod: form.get('code_challenge_method') || '',
5813
- scope: form.get('scope') || undefined,
5814
- resource: form.get('resource') || buildPublicUrl(hostedConfig, '/mcp'),
6513
+ codeChallenge: authorizationParams.codeChallenge,
6514
+ codeChallengeMethod: authorizationParams.codeChallengeMethod,
6515
+ scope: authorizationParams.scope,
6516
+ resource: authorizationParams.resource || buildPublicUrl(hostedConfig, '/mcp'),
5815
6517
  boundKey: form.get('api_key') || '',
5816
6518
  state,
5817
6519
  });
@@ -6082,7 +6784,7 @@ ${hidden}
6082
6784
  evidence: [err?.message ? err.message : 'unknown_error'],
6083
6785
  },
6084
6786
  });
6085
- } catch (_) {
6787
+ } catch {
6086
6788
  // Telemetry is best-effort and must never fail the caller.
6087
6789
  }
6088
6790
  }
@@ -6449,7 +7151,7 @@ ${hidden}
6449
7151
  context: 'failed to persist install-email capture to ledger',
6450
7152
  metadata: { error: err?.message || 'unknown' },
6451
7153
  });
6452
- } catch (_) {}
7154
+ } catch {}
6453
7155
  }
6454
7156
 
6455
7157
  // Privacy-clean telemetry ping for funnel attribution (no email).
@@ -7216,7 +7918,9 @@ ${hidden}
7216
7918
 
7217
7919
  try {
7218
7920
  if (req.method === 'GET' && pathname === '/v1/feedback/stats') {
7219
- const stats = analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH);
7921
+ const stats = shouldAggregateFeedback()
7922
+ ? computeAggregateFeedbackStats({ feedbackDir: requestFeedbackPaths.FEEDBACK_DIR })
7923
+ : analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH);
7220
7924
  try {
7221
7925
  const { getStatuslineMeta } = require('../../scripts/statusline-meta');
7222
7926
  const meta = getStatuslineMeta({ env: process.env });
@@ -7248,6 +7952,9 @@ ${hidden}
7248
7952
  stats.geminiKeyStatus = 'validated';
7249
7953
  }
7250
7954
  stats.hybridInferenceAvailable = !!(stats.geminiConfigured || stats.perplexityConfigured);
7955
+ stats.localLlmConfigured = Boolean(process.env.THUMBGATE_LOCAL_LLM_ENDPOINT);
7956
+ stats.localLlmEndpoint = process.env.THUMBGATE_LOCAL_LLM_ENDPOINT || null;
7957
+ stats.localLlmModel = process.env.THUMBGATE_LOCAL_LLM_MODEL || null;
7251
7958
  sendJson(res, 200, stats);
7252
7959
  return;
7253
7960
  }
@@ -7843,11 +8550,13 @@ ${hidden}
7843
8550
  const signal = parsed.searchParams.get('signal') || null;
7844
8551
  let results;
7845
8552
  try {
8553
+ const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
7846
8554
  results = searchThumbgate({
7847
8555
  query,
7848
8556
  limit: Number.isFinite(limit) ? limit : 10,
7849
8557
  source,
7850
8558
  signal,
8559
+ feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
7851
8560
  });
7852
8561
  } catch (err) {
7853
8562
  throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
@@ -7865,11 +8574,13 @@ ${hidden}
7865
8574
  const body = await parseJsonBody(req);
7866
8575
  let results;
7867
8576
  try {
8577
+ const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
7868
8578
  results = searchThumbgate({
7869
8579
  query: body.query || body.q || '',
7870
8580
  limit: body.limit,
7871
8581
  source: body.source,
7872
8582
  signal: body.signal,
8583
+ feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
7873
8584
  });
7874
8585
  } catch (err) {
7875
8586
  throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
@@ -7895,11 +8606,14 @@ ${hidden}
7895
8606
  {
7896
8607
  const documentId = normalizeDocumentIdFromPath(pathname);
7897
8608
  if (req.method === 'GET' && documentId) {
8609
+ if (!/^[a-zA-Z0-9-_]+$/.test(documentId)) {
8610
+ throw createHttpError(400, 'Invalid document ID format');
8611
+ }
7898
8612
  const document = readImportedDocument(documentId, {
7899
8613
  feedbackDir: requestFeedbackDir,
7900
8614
  });
7901
8615
  if (!document) {
7902
- throw createHttpError(404, `Imported document not found: ${documentId}`);
8616
+ throw createHttpError(404, `Imported document not found: ${escapeHtml(documentId)}`);
7903
8617
  }
7904
8618
  sendJson(res, 200, { document });
7905
8619
  return;
@@ -7926,7 +8640,7 @@ ${hidden}
7926
8640
  feedbackDir: getSafeDataDir(),
7927
8641
  limit: 10,
7928
8642
  });
7929
- } catch (_) { /* best-effort — conversation window is optional */ }
8643
+ } catch { /* best-effort — conversation window is optional */ }
7930
8644
  }
7931
8645
  const result = captureFeedback({
7932
8646
  signal: body.signal,
@@ -8995,6 +9709,8 @@ module.exports = {
8995
9709
  buildEnterpriseChatAnswer,
8996
9710
  answerEnterpriseDataChat,
8997
9711
  answerEnterpriseDialogflowChat,
9712
+ buildLossAnalyticsResponse,
9713
+ sanitizeHtmlUnsafeJsonValue,
8998
9714
  },
8999
9715
  };
9000
9716