thumbgate 1.27.6 → 1.27.8

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 (96) hide show
  1. package/.claude/commands/thumbgate-blocked.md +27 -0
  2. package/.claude/commands/thumbgate-doctor.md +30 -0
  3. package/.claude/commands/thumbgate-guard.md +36 -0
  4. package/.claude/commands/thumbgate-protect.md +30 -0
  5. package/.claude/commands/thumbgate-rules.md +30 -0
  6. package/.claude-plugin/plugin.json +1 -1
  7. package/.well-known/llms.txt +6 -2
  8. package/.well-known/mcp/server-card.json +1 -1
  9. package/README.md +49 -5
  10. package/adapters/claude/.mcp.json +2 -2
  11. package/adapters/letta/README.md +41 -0
  12. package/adapters/letta/thumbgate-letta-adapter.js +133 -0
  13. package/adapters/mcp/server-stdio.js +16 -1
  14. package/adapters/opencode/opencode.json +1 -1
  15. package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
  16. package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
  17. package/bench/observability-eval-suite.json +26 -0
  18. package/bin/cli.js +180 -2
  19. package/bin/postinstall.js +1 -1
  20. package/config/gate-templates.json +84 -0
  21. package/config/gates/claim-verification.json +6 -0
  22. package/config/gates/default.json +20 -0
  23. package/config/github-about.json +1 -1
  24. package/config/model-candidates.json +50 -0
  25. package/package.json +66 -25
  26. package/public/agent-manager.html +41 -1
  27. package/public/agents-cost-savings.html +1 -1
  28. package/public/ai-malpractice-prevention.html +2 -1
  29. package/public/assets/brand/github-social-preview.png +0 -0
  30. package/public/assets/brand/thumbgate-icon-512.png +0 -0
  31. package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
  32. package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
  33. package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
  34. package/public/assets/brand/thumbgate-mark-inline.svg +15 -0
  35. package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
  36. package/public/assets/brand/thumbgate-mark-team.svg +26 -0
  37. package/public/assets/brand/thumbgate-mark.svg +15 -0
  38. package/public/assets/brand/thumbgate-wordmark.svg +20 -0
  39. package/public/assets/claude-thumbgate-statusbar.svg +8 -0
  40. package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
  41. package/public/assets/legal-intake-control-flow.svg +66 -0
  42. package/public/blog.html +1 -1
  43. package/public/brand/thumbgate-mark.svg +15 -0
  44. package/public/brand/thumbgate-og.svg +16 -0
  45. package/public/codex-enterprise.html +1 -1
  46. package/public/codex-plugin.html +1 -1
  47. package/public/compare.html +23 -3
  48. package/public/dashboard.html +312 -30
  49. package/public/federal.html +1 -1
  50. package/public/guide.html +5 -4
  51. package/public/index.html +167 -49
  52. package/public/js/buyer-intent.js +672 -0
  53. package/public/learn.html +74 -7
  54. package/public/lessons.html +2 -1
  55. package/public/numbers.html +3 -3
  56. package/public/pricing.html +63 -15
  57. package/public/pro.html +7 -7
  58. package/scripts/activation-quickstart.js +187 -0
  59. package/scripts/agent-memory-lifecycle.js +211 -0
  60. package/scripts/async-eval-observability.js +236 -0
  61. package/scripts/auto-promote-gates.js +75 -4
  62. package/scripts/build-metadata.js +24 -3
  63. package/scripts/cli-schema.js +22 -0
  64. package/scripts/dashboard-chat.js +2 -1
  65. package/scripts/dashboard.js +8 -0
  66. package/scripts/export-databricks-bundle.js +5 -1
  67. package/scripts/export-dpo-pairs.js +7 -2
  68. package/scripts/feedback-aggregate.js +281 -0
  69. package/scripts/feedback-loop.js +34 -0
  70. package/scripts/filesystem-search.js +35 -10
  71. package/scripts/gates-engine.js +198 -6
  72. package/scripts/gemini-embedding-policy.js +2 -1
  73. package/scripts/hook-stop-anti-claim.js +227 -0
  74. package/scripts/hook-thumbgate-cache-updater.js +18 -2
  75. package/scripts/lesson-inference.js +8 -3
  76. package/scripts/lesson-search.js +17 -1
  77. package/scripts/operational-integrity.js +39 -5
  78. package/scripts/plausible-domain-config.js +4 -2
  79. package/scripts/rate-limiter.js +12 -6
  80. package/scripts/secret-redaction.js +166 -0
  81. package/scripts/security-scanner.js +100 -0
  82. package/scripts/self-distill-agent.js +3 -1
  83. package/scripts/self-harness-optimizer.js +141 -0
  84. package/scripts/seo-gsd.js +635 -0
  85. package/scripts/statusline-cache-path.js +17 -2
  86. package/scripts/statusline-cache-read.js +57 -0
  87. package/scripts/statusline-local-stats.js +9 -1
  88. package/scripts/statusline-meta.js +5 -2
  89. package/scripts/statusline.sh +13 -1
  90. package/scripts/sync-telemetry-from-prod.js +374 -0
  91. package/scripts/telemetry-analytics.js +9 -0
  92. package/scripts/thumbgate-search.js +85 -19
  93. package/scripts/tool-contract-validator.js +76 -0
  94. package/scripts/vector-store.js +44 -0
  95. package/scripts/workspace-evolver.js +62 -2
  96. package/src/api/server.js +715 -86
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',
@@ -1564,7 +1609,10 @@ function classifyEnterpriseChatTopic(prompt) {
1564
1609
  // today?" with an actual filtered list instead of a canned total.
1565
1610
  function parseChatIntent(prompt) {
1566
1611
  const lower = String(prompt || '').toLowerCase();
1567
- const wantsList = /\bwhat\b|\bwhich\b|\blist\b|\bshow me\b|\bexamples?\b|\btell me about\b/.test(lower);
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);
1568
1616
  let windowMs = null;
1569
1617
  let windowLabel = 'across all time';
1570
1618
  if (/\btoday\b/.test(lower)) { windowMs = 24 * 60 * 60 * 1000; windowLabel = 'today'; }
@@ -1606,12 +1654,16 @@ function bestFeedbackDescription(row) {
1606
1654
  function readRecentFeedbackEntries(feedbackDir, signal, windowMs, limit = 5, opts = {}) {
1607
1655
  try {
1608
1656
  if (!feedbackDir) return [];
1609
- const fsLocal = require('node:fs');
1610
- const pathLocal = require('node:path');
1611
- const logPath = pathLocal.join(feedbackDir, 'feedback-log.jsonl');
1612
- if (!fsLocal.existsSync(logPath)) return [];
1613
- const { readJsonl } = require('../../scripts/fs-utils');
1614
- const rows = readJsonl(logPath) || [];
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
+ })();
1615
1667
  const cutoff = windowMs ? Date.now() - windowMs : 0;
1616
1668
  const filtered = rows
1617
1669
  .filter((r) => !signal || r.signal === signal)
@@ -1622,7 +1674,11 @@ function readRecentFeedbackEntries(feedbackDir, signal, windowMs, limit = 5, opt
1622
1674
  })
1623
1675
  .reverse()
1624
1676
  .map((r) => {
1625
- const out = { timestamp: r.timestamp, context: bestFeedbackDescription(r) };
1677
+ const out = {
1678
+ timestamp: r.timestamp,
1679
+ context: bestFeedbackDescription(r),
1680
+ tags: Array.isArray(r.tags) ? r.tags : []
1681
+ };
1626
1682
  if (opts.includeSignal) out.signal = r.signal;
1627
1683
  return out;
1628
1684
  });
@@ -1661,9 +1717,53 @@ const FEEDBACK_LIST_LABELS = Object.freeze({
1661
1717
  positive: 'Recent wins',
1662
1718
  });
1663
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
+ }
1738
+ }
1739
+
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);
1744
+ }
1745
+
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}`;
1754
+ }
1755
+
1756
+ function appendFeedbackListLines(lines, { entries, signal, intent }) {
1757
+ if (!entries.length) {
1758
+ lines.push(`No ${signal || 'feedback'} entries found ${intent.windowLabel}.`);
1759
+ return;
1760
+ }
1761
+ lines.push(`${FEEDBACK_LIST_LABELS[signal] || 'Recent feedback'} (${intent.windowLabel}):`);
1762
+ lines.push(...entries.map(formatFeedbackEntry));
1763
+ }
1764
+
1664
1765
  function buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeline }) {
1665
1766
  const signal = detectFeedbackSignalFromPrompt(ctx.prompt);
1666
-
1667
1767
  // One read of the time-windowed log, then in-memory counts + (signal-filtered,
1668
1768
  // placeholder-stripped) list. Counts include ALL entries (so "Feedback today: 5"
1669
1769
  // matches the dashboard tile); the list drops vague entries like literal "thumbs
@@ -1671,24 +1771,16 @@ function buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeli
1671
1771
  const windowed = readRecentFeedbackEntries(feedbackDir, null, intent.windowMs, 10000, { includeSignal: true });
1672
1772
  const windowPos = windowed.filter((r) => r.signal === 'positive').length;
1673
1773
  const windowNeg = windowed.filter((r) => r.signal === 'negative').length;
1674
- const entries = (signal ? windowed.filter((r) => r.signal === signal) : windowed)
1675
- .filter((r) => r.context && !isPlaceholder(r.context))
1676
- .slice(0, 5);
1774
+ const entries = buildFeedbackEntries(windowed, signal);
1677
1775
 
1678
- const lines = [];
1679
- lines.push(intent.windowMs
1776
+ const lines = [
1777
+ intent.windowMs
1680
1778
  ? `Feedback ${intent.windowLabel}: ${windowed.length} (${windowPos} positive, ${windowNeg} negative).`
1681
- : `Feedback total: ${compactNumber(approval.total)} (${compactNumber(approval.positive)} positive, ${compactNumber(approval.negative)} negative).`);
1779
+ : `Feedback total: ${compactNumber(approval.total)} (${compactNumber(approval.positive)} positive, ${compactNumber(approval.negative)} negative).`,
1780
+ ];
1682
1781
 
1683
1782
  if (intent.wantsList) {
1684
- if (entries.length) {
1685
- lines.push(`${FEEDBACK_LIST_LABELS[signal] || 'Recent feedback'} (${intent.windowLabel}):`);
1686
- for (const e of entries) {
1687
- lines.push(` • ${(e.timestamp || '').slice(0, 10)} — ${e.context}`);
1688
- }
1689
- } else {
1690
- lines.push(`No ${signal || 'feedback'} entries found ${intent.windowLabel}.`);
1691
- }
1783
+ appendFeedbackListLines(lines, { entries, signal, intent });
1692
1784
  } else {
1693
1785
  lines.push(`Lesson pipeline: ${compactNumber(lessonPipeline.lessons || lessonPipeline.generated || 0)} lessons visible in the current dashboard snapshot.`);
1694
1786
  }
@@ -1896,7 +1988,7 @@ async function answerEnterpriseDataChat({ prompt, feedbackDir, parsed }) {
1896
1988
  const answerEnterpriseDialogflowChat = answerEnterpriseDataChat;
1897
1989
 
1898
1990
  function buildLossAnalyticsResponse(data, summaryOptions) {
1899
- return {
1991
+ return sanitizeHtmlUnsafeJsonValue({
1900
1992
  window: data.analytics.window || summaryOptions,
1901
1993
  lossAnalysis: data.analytics.lossAnalysis || null,
1902
1994
  buyerLoss: data.analytics.buyerLoss || null,
@@ -1908,13 +2000,50 @@ function buildLossAnalyticsResponse(data, summaryOptions) {
1908
2000
  ctas: data.analytics.telemetry && data.analytics.telemetry.ctas,
1909
2001
  visitors: data.analytics.telemetry && data.analytics.telemetry.visitors,
1910
2002
  },
1911
- };
2003
+ });
1912
2004
  }
1913
2005
 
1914
2006
  function createJourneyId(prefix) {
1915
2007
  return createTraceId(prefix).replace(/^trace_/, `${prefix}_`);
1916
2008
  }
1917
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
+
1918
2047
  function appendQueryParam(url, key, value) {
1919
2048
  const normalized = normalizeNullableText(value);
1920
2049
  if (normalized) {
@@ -1974,9 +2103,245 @@ function buildCheckoutIntentHref(baseUrl, metadata = {}, overrides = {}) {
1974
2103
  });
1975
2104
  }
1976
2105
 
1977
- 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 = {}) {
1978
2206
  const plausibleDomain = escapeHtmlAttribute(resolvePlausibleDataDomain({ host: 'thumbgate.ai' }));
1979
- 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>`;
1980
2345
  }
1981
2346
 
1982
2347
  function buildCheckoutBootstrapBody(parsed, req, journeyState = resolveJourneyState(req, parsed)) {
@@ -2040,17 +2405,6 @@ function normalizeCheckoutCustomerEmail(value) {
2040
2405
  return email;
2041
2406
  }
2042
2407
 
2043
- function renderCheckoutIntentGate(parsed, responseHeaders = {}) {
2044
- let hiddenInputs = '';
2045
- for (const [key, value] of parsed.searchParams.entries()) {
2046
- if (key !== 'confirm' && key !== 'customer_email') hiddenInputs += `<input type=hidden name=${escapeHtmlAttribute(key)} value=${escapeHtmlAttribute(value)}>`;
2047
- }
2048
- return {
2049
- 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>`,
2050
- headers: responseHeaders,
2051
- };
2052
- }
2053
-
2054
2408
  function normalizeTrackedLinkSlug(value) {
2055
2409
  return String(value || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
2056
2410
  }
@@ -2061,6 +2415,27 @@ function normalizePublicPageSlug(value) {
2061
2415
  .replace(/[^a-z0-9-]/g, '');
2062
2416
  }
2063
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
+
2064
2439
  function getTrackedLinkTarget(slug) {
2065
2440
  const normalizedSlug = normalizeTrackedLinkSlug(slug);
2066
2441
  return TRACKED_LINK_TARGETS[normalizedSlug]
@@ -2096,8 +2471,10 @@ function appendTrackedLinkQueryParams(destinationUrl, parsed, target) {
2096
2471
  }
2097
2472
 
2098
2473
  function buildTrackedLinkDestination(target, hostedConfig, parsed) {
2099
- const destinationUrl = target.href
2100
- ? 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)
2101
2478
  : new URL(target.path || '/', hostedConfig.appOrigin);
2102
2479
  appendTrackedLinkQueryParams(destinationUrl, parsed, target);
2103
2480
  return destinationUrl;
@@ -2224,6 +2601,7 @@ function sendJson(res, statusCode, payload, extraHeaders = {}, options = {}) {
2224
2601
  const body = JSON.stringify(payload);
2225
2602
  res.writeHead(statusCode, {
2226
2603
  'Content-Type': 'application/json; charset=utf-8',
2604
+ 'X-Content-Type-Options': 'nosniff',
2227
2605
  'Content-Length': Buffer.byteLength(body),
2228
2606
  ...extraHeaders,
2229
2607
  });
@@ -2317,8 +2695,9 @@ function chatgptActionEventType(integration, suffix) {
2317
2695
  }
2318
2696
 
2319
2697
  function getPublicOrigin(req) {
2320
- const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() || 'http';
2321
- 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';
2322
2701
  return `${proto}://${host}`;
2323
2702
  }
2324
2703
 
@@ -2337,6 +2716,47 @@ function getRequestHostHeader(req) {
2337
2716
  return forwardedHost || req.headers.host || '';
2338
2717
  }
2339
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
+
2340
2760
  function isLoopbackHost(hostValue) {
2341
2761
  const rawHost = String(hostValue || '').split(',')[0].trim();
2342
2762
  if (!rawHost) {
@@ -2382,7 +2802,7 @@ function stripTrailingSlashes(value) {
2382
2802
  return input.slice(0, end);
2383
2803
  }
2384
2804
 
2385
- function normalizePublicMarketingHtml(html, runtimeConfig) {
2805
+ function normalizePublicMarketingHtml(html, runtimeConfig, requestHost) {
2386
2806
  const appOrigin = runtimeConfig?.appOrigin
2387
2807
  ? stripTrailingSlashes(runtimeConfig.appOrigin)
2388
2808
  : '';
@@ -2391,11 +2811,14 @@ function normalizePublicMarketingHtml(html, runtimeConfig) {
2391
2811
  let output = String(html);
2392
2812
  output = output.replaceAll(DEFAULT_PUBLIC_APP_ORIGIN, appOrigin);
2393
2813
  try {
2394
- const host = new URL(appOrigin).host;
2814
+ const host = normalizePublicRequestHost(requestHost) || new URL(appOrigin).host;
2395
2815
  const plausibleDomain = resolvePlausibleDataDomain({ host });
2396
2816
  output = output.replaceAll(
2397
2817
  'data-domain="thumbgate-production.up.railway.app"',
2398
2818
  `data-domain="${escapeHtmlAttribute(plausibleDomain)}"`
2819
+ ).replaceAll(
2820
+ 'data-domain="thumbgate.ai"',
2821
+ `data-domain="${escapeHtmlAttribute(plausibleDomain)}"`
2399
2822
  );
2400
2823
  } catch {
2401
2824
  // appOrigin is normalized by hosted-config; leave static analytics domains
@@ -2444,7 +2867,7 @@ function loadPublicMarketingTemplateHtml(templatePath, runtimeConfig, pageContex
2444
2867
  '__GTM_PLAN_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/GO_TO_MARKET_REVENUE_WEDGE_2026-03.md',
2445
2868
  '__GITHUB_URL__': 'https://github.com/IgorGanapolsky/ThumbGate',
2446
2869
  '__POSTHOG_API_KEY__': runtimeConfig.posthogApiKey || '',
2447
- }), runtimeConfig);
2870
+ }), runtimeConfig, pageContext.requestHost);
2448
2871
  }
2449
2872
 
2450
2873
  function loadLandingPageHtml(runtimeConfig, pageContext = {}) {
@@ -2459,6 +2882,18 @@ function loadPricingPageHtml(runtimeConfig, pageContext = {}) {
2459
2882
  return loadPublicMarketingTemplateHtml(PRICING_PAGE_PATH, runtimeConfig, pageContext);
2460
2883
  }
2461
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
+
2462
2897
  function readOptionalPublicTemplate(filePath) {
2463
2898
  try {
2464
2899
  return fs.readFileSync(filePath, 'utf-8');
@@ -3091,6 +3526,7 @@ function renderSitemapXml(runtimeConfig) {
3091
3526
  { path: '/learn/codex-role-plugins-need-governance', changefreq: 'weekly', priority: '0.85' },
3092
3527
  { path: '/learn/agentic-os-team-governance', changefreq: 'weekly', priority: '0.85' },
3093
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' },
3094
3530
  { path: '/compare/claude-code-hooks', changefreq: 'weekly', priority: '0.85' },
3095
3531
  { path: '/compare/bumblebee', changefreq: 'weekly', priority: '0.85' },
3096
3532
  { path: '/compare/anthropic-containment', changefreq: 'weekly', priority: '0.85' },
@@ -3099,6 +3535,30 @@ function renderSitemapXml(runtimeConfig) {
3099
3535
  { path: '/compare/anthropic-claude-for-legal', changefreq: 'weekly', priority: '0.9' },
3100
3536
  ...THUMBGATE_SEO_SITEMAP_ENTRIES,
3101
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
+ }
3102
3562
  return [
3103
3563
  '<?xml version="1.0" encoding="UTF-8"?>',
3104
3564
  '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
@@ -3224,11 +3684,13 @@ function servePublicMarketingPage({
3224
3684
  }, req.headers, 'seo_landing_view');
3225
3685
  }
3226
3686
 
3687
+ const requestHost = getSafePublicRequestHost(req);
3227
3688
  const html = renderHtml(hostedConfig, {
3228
3689
  serverVisitorId: journeyState.visitorId,
3229
3690
  serverSessionId: journeyState.sessionId,
3230
3691
  serverAcquisitionId: journeyState.acquisitionId,
3231
3692
  serverTelemetryCaptured: landingTelemetryCaptured,
3693
+ requestHost,
3232
3694
  });
3233
3695
 
3234
3696
  sendHtml(
@@ -4307,6 +4769,42 @@ function normalizeJobIdFromPath(pathname, suffix = '') {
4307
4769
  return match ? decodeURIComponent(match[1]) : null;
4308
4770
  }
4309
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
+
4310
4808
  function normalizeDocumentIdFromPath(pathname) {
4311
4809
  const match = pathname.match(/^\/v1\/documents\/([^/]+)$/);
4312
4810
  return match ? decodeURIComponent(match[1]) : null;
@@ -5078,12 +5576,40 @@ async function addContext(){
5078
5576
  return;
5079
5577
  }
5080
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
+
5081
5598
  if (isGetLikeRequest && (pathname === '/guide' || pathname === '/guide.html')) {
5082
5599
  try {
5083
- const html = fs.readFileSync(GUIDE_PAGE_PATH, 'utf-8');
5084
- sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5085
- } catch {
5086
- 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');
5087
5613
  }
5088
5614
  return;
5089
5615
  }
@@ -5143,10 +5669,19 @@ async function addContext(){
5143
5669
 
5144
5670
  if (isGetLikeRequest && (pathname === '/learn' || pathname === '/learn.html')) {
5145
5671
  try {
5146
- const html = fs.readFileSync(LEARN_PAGE_PATH, 'utf-8');
5147
- sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5148
- } catch {
5149
- 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');
5150
5685
  }
5151
5686
  return;
5152
5687
  }
@@ -5337,13 +5872,13 @@ async function addContext(){
5337
5872
 
5338
5873
  if (isGetLikeRequest && pathname.startsWith('/learn/')) {
5339
5874
  try {
5340
- const slug = normalizePublicPageSlug(pathname.replace('/learn/', ''));
5341
- const articlePath = path.join(LEARN_DIR, `${slug}.html`);
5342
- if (!articlePath.startsWith(LEARN_DIR)) {
5343
- 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' });
5344
5878
  return;
5345
5879
  }
5346
- const html = fs.readFileSync(articlePath, 'utf-8');
5880
+ const requestHost = getSafePublicRequestHost(req);
5881
+ const html = normalizePublicMarketingHtml(fs.readFileSync(articlePath, 'utf-8'), hostedConfig, requestHost);
5347
5882
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5348
5883
  } catch {
5349
5884
  sendJson(res, 404, { error: 'Article not found' });
@@ -5353,10 +5888,10 @@ async function addContext(){
5353
5888
 
5354
5889
  if (isGetLikeRequest && pathname.startsWith('/guides/')) {
5355
5890
  try {
5356
- const slug = normalizePublicPageSlug(pathname.replace('/guides/', ''));
5357
- const guidePath = path.join(GUIDES_DIR, `${slug}.html`);
5358
- if (!guidePath.startsWith(GUIDES_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
5359
- 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);
5360
5895
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5361
5896
  } catch { sendJson(res, 404, { error: 'Guide not found' }); }
5362
5897
  return;
@@ -5364,10 +5899,10 @@ async function addContext(){
5364
5899
 
5365
5900
  if (isGetLikeRequest && pathname.startsWith('/compare/') && pathname !== '/compare') {
5366
5901
  try {
5367
- const slug = normalizePublicPageSlug(pathname.replace('/compare/', ''));
5368
- const comparePath = path.join(COMPARE_DIR, `${slug}.html`);
5369
- if (!comparePath.startsWith(COMPARE_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
5370
- 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);
5371
5906
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5372
5907
  } catch { sendJson(res, 404, { error: 'Comparison not found' }); }
5373
5908
  return;
@@ -5375,10 +5910,10 @@ async function addContext(){
5375
5910
 
5376
5911
  if (isGetLikeRequest && pathname.startsWith('/use-cases/')) {
5377
5912
  try {
5378
- const slug = normalizePublicPageSlug(pathname.replace('/use-cases/', ''));
5379
- const useCasePath = path.join(USE_CASES_DIR, `${slug}.html`);
5380
- if (!useCasePath.startsWith(USE_CASES_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
5381
- 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);
5382
5917
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5383
5918
  } catch { sendJson(res, 404, { error: 'Use case not found' }); }
5384
5919
  return;
@@ -5395,6 +5930,18 @@ async function addContext(){
5395
5930
  return;
5396
5931
  }
5397
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
+
5398
5945
  if (isGetLikeRequest && (
5399
5946
  pathname === '/favicon.ico'
5400
5947
  || pathname === '/thumbgate-logo.png'
@@ -5476,8 +6023,70 @@ async function addContext(){
5476
6023
  // because no real crawler appends customer_email to discovered URLs.
5477
6024
  const hasCustomerEmailHint = !!parsed?.searchParams?.has('customer_email');
5478
6025
  const botShouldBypass = !botClassification.isBot || hasCustomerEmailHint;
5479
- 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)
5480
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
+ }
5481
6090
  // Plausible funnel event #1 of 3: page view. Fired before interstitial
5482
6091
  // deflection so we get the full top-of-funnel count, with isBot as a
5483
6092
  // prop so the dashboard can filter human vs. crawler traffic. Fire-and-forget.
@@ -5536,10 +6145,14 @@ async function addContext(){
5536
6145
  billingCycle: analyticsMetadata.billingCycle,
5537
6146
  landingPath: analyticsMetadata.landingPath,
5538
6147
  isBot: botClassification.isBot ? 'true' : 'false',
6148
+ interstitialSampled: interstitialSampled ? 'true' : 'false',
6149
+ interstitialSampleRate,
5539
6150
  reason: botClassification.reason,
5540
6151
  }, req.headers, eventType);
5541
6152
  const prefilledEmail = parsed?.searchParams?.get('customer_email') || '';
5542
- const html = renderCheckoutIntentPage(prefilledEmail);
6153
+ const html = renderCheckoutIntentPage(prefilledEmail, parsed, {
6154
+ includeHiddenAttribution: !botClassification.isBot,
6155
+ });
5543
6156
  sendHtml(res, 200, html, responseHeaders);
5544
6157
  return;
5545
6158
  }
@@ -5802,12 +6415,13 @@ async function addContext(){
5802
6415
  // Public-facing broker lead-flow audit landing page. Wedge for the
5803
6416
  // real-estate broker outreach. Static HTML served from src/api/static.
5804
6417
  try {
6418
+ const host = getSafePublicRequestHost(req);
5805
6419
  const html = normalizePublicMarketingHtml(fillTemplate(fs.readFileSync(
5806
6420
  path.resolve(__dirname, '../../assets/static/broker-audit.html'),
5807
6421
  'utf8'
5808
6422
  ), {
5809
6423
  '__POSTHOG_API_KEY__': hostedConfig.posthogApiKey || '',
5810
- }), hostedConfig);
6424
+ }), hostedConfig, host);
5811
6425
  if (isHeadRequest) {
5812
6426
  sendHtml(res, 200, html, {}, { headOnly: true });
5813
6427
  return;
@@ -5859,9 +6473,9 @@ async function addContext(){
5859
6473
  // Authorization endpoint: GET renders consent, POST issues the code.
5860
6474
  if (pathname === '/oauth/authorize') {
5861
6475
  if (isGetLikeRequest) {
5862
- const q = parsed.searchParams;
5863
- const fields = ['client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'resource'];
5864
- 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
+ );
5865
6479
  const html = `<!doctype html><html><head><meta charset="utf-8"><title>Authorize ThumbGate</title>
5866
6480
  <style>body{font:15px system-ui;margin:0;background:#0b0b0c;color:#eee;display:flex;min-height:100vh;align-items:center;justify-content:center}
5867
6481
  .card{background:#161618;border:1px solid #2a2a2e;border-radius:12px;padding:28px;max-width:420px}
@@ -5870,7 +6484,7 @@ button{width:100%;padding:11px;border-radius:8px;border:0;background:#10b981;col
5870
6484
  a{color:#8b9}</style></head><body><form class="card" method="post" action="/oauth/authorize">
5871
6485
  <h2>Authorize Claude → ThumbGate</h2>
5872
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>
5873
- ${hidden}
6487
+ <input type="hidden" name="auth_request_token" value="${escapeHtmlAttribute(authRequestToken)}">
5874
6488
  <input type="password" name="api_key" placeholder="ThumbGate API key" autocomplete="off" required>
5875
6489
  <button type="submit" name="approve" value="yes">Approve</button>
5876
6490
  </form></body></html>`;
@@ -5882,8 +6496,9 @@ ${hidden}
5882
6496
  req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
5883
6497
  req.on('end', () => {
5884
6498
  const form = new URLSearchParams(body);
5885
- const redirectUri = form.get('redirect_uri') || '';
5886
- const state = form.get('state') || '';
6499
+ const authorizationParams = getOauthAuthorizeParamsFromForm(form, hostedConfig);
6500
+ const redirectUri = authorizationParams.redirectUri;
6501
+ const state = authorizationParams.state;
5887
6502
  // Validate the presented ThumbGate key before issuing a code. When keys
5888
6503
  // are configured (production) the key MUST match a configured admin /
5889
6504
  // operator / reviewer key — otherwise OAuth would authenticate nobody.
@@ -5893,12 +6508,12 @@ ${hidden}
5893
6508
  return;
5894
6509
  }
5895
6510
  const issued = mcpOauth.createAuthorizationCode(oauthStore, {
5896
- clientId: form.get('client_id') || '',
6511
+ clientId: authorizationParams.clientId,
5897
6512
  redirectUri,
5898
- codeChallenge: form.get('code_challenge') || '',
5899
- codeChallengeMethod: form.get('code_challenge_method') || '',
5900
- scope: form.get('scope') || undefined,
5901
- 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'),
5902
6517
  boundKey: form.get('api_key') || '',
5903
6518
  state,
5904
6519
  });
@@ -7303,7 +7918,9 @@ ${hidden}
7303
7918
 
7304
7919
  try {
7305
7920
  if (req.method === 'GET' && pathname === '/v1/feedback/stats') {
7306
- const stats = analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH);
7921
+ const stats = shouldAggregateFeedback()
7922
+ ? computeAggregateFeedbackStats({ feedbackDir: requestFeedbackPaths.FEEDBACK_DIR })
7923
+ : analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH);
7307
7924
  try {
7308
7925
  const { getStatuslineMeta } = require('../../scripts/statusline-meta');
7309
7926
  const meta = getStatuslineMeta({ env: process.env });
@@ -7335,6 +7952,9 @@ ${hidden}
7335
7952
  stats.geminiKeyStatus = 'validated';
7336
7953
  }
7337
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;
7338
7958
  sendJson(res, 200, stats);
7339
7959
  return;
7340
7960
  }
@@ -7930,11 +8550,13 @@ ${hidden}
7930
8550
  const signal = parsed.searchParams.get('signal') || null;
7931
8551
  let results;
7932
8552
  try {
8553
+ const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
7933
8554
  results = searchThumbgate({
7934
8555
  query,
7935
8556
  limit: Number.isFinite(limit) ? limit : 10,
7936
8557
  source,
7937
8558
  signal,
8559
+ feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
7938
8560
  });
7939
8561
  } catch (err) {
7940
8562
  throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
@@ -7952,11 +8574,13 @@ ${hidden}
7952
8574
  const body = await parseJsonBody(req);
7953
8575
  let results;
7954
8576
  try {
8577
+ const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
7955
8578
  results = searchThumbgate({
7956
8579
  query: body.query || body.q || '',
7957
8580
  limit: body.limit,
7958
8581
  source: body.source,
7959
8582
  signal: body.signal,
8583
+ feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
7960
8584
  });
7961
8585
  } catch (err) {
7962
8586
  throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
@@ -7982,11 +8606,14 @@ ${hidden}
7982
8606
  {
7983
8607
  const documentId = normalizeDocumentIdFromPath(pathname);
7984
8608
  if (req.method === 'GET' && documentId) {
8609
+ if (!/^[a-zA-Z0-9-_]+$/.test(documentId)) {
8610
+ throw createHttpError(400, 'Invalid document ID format');
8611
+ }
7985
8612
  const document = readImportedDocument(documentId, {
7986
8613
  feedbackDir: requestFeedbackDir,
7987
8614
  });
7988
8615
  if (!document) {
7989
- throw createHttpError(404, `Imported document not found: ${documentId}`);
8616
+ throw createHttpError(404, `Imported document not found: ${escapeHtml(documentId)}`);
7990
8617
  }
7991
8618
  sendJson(res, 200, { document });
7992
8619
  return;
@@ -9082,6 +9709,8 @@ module.exports = {
9082
9709
  buildEnterpriseChatAnswer,
9083
9710
  answerEnterpriseDataChat,
9084
9711
  answerEnterpriseDialogflowChat,
9712
+ buildLossAnalyticsResponse,
9713
+ sanitizeHtmlUnsafeJsonValue,
9085
9714
  },
9086
9715
  };
9087
9716