thumbgate 1.27.12 → 1.27.13

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 (132) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/llms.txt +2 -1
  3. package/.well-known/mcp/server-card.json +1 -1
  4. package/README.md +2 -4
  5. package/adapters/claude/.mcp.json +2 -2
  6. package/adapters/mcp/server-stdio.js +1 -1
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
  9. package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
  10. package/bin/cli.js +78 -259
  11. package/config/gate-templates.json +0 -228
  12. package/config/gates/claim-verification.json +0 -18
  13. package/package.json +35 -25
  14. package/public/assets/brand/thumbgate-logo-transparent.svg +22 -0
  15. package/public/assets/brand/thumbgate-mark-inline-v3.svg +19 -0
  16. package/public/assets/brand/thumbgate-mark.svg +11 -5
  17. package/public/blog.html +0 -30
  18. package/public/brand/thumbgate-mark.svg +9 -5
  19. package/public/chatgpt-app.html +2 -2
  20. package/public/compare.html +2 -1
  21. package/public/dashboard.html +1 -1
  22. package/public/federal.html +1 -1
  23. package/public/index.html +95 -216
  24. package/public/learn.html +59 -35
  25. package/public/lessons.html +1 -1
  26. package/public/numbers.html +2 -2
  27. package/public/pro.html +7 -7
  28. package/scripts/aws-blocks-guardrails.js +228 -0
  29. package/scripts/cli-schema.js +22 -10
  30. package/scripts/dashboard-chat.js +2 -1
  31. package/scripts/document-intake.js +1 -49
  32. package/scripts/durability/step.js +3 -3
  33. package/scripts/gate-stats.js +5 -11
  34. package/scripts/gates-engine.js +0 -49
  35. package/scripts/gemini-embedding-policy.js +2 -1
  36. package/scripts/hook-stop-anti-claim.js +116 -184
  37. package/scripts/hosted-config.js +0 -12
  38. package/scripts/lesson-search.js +1 -15
  39. package/scripts/llm-client.js +187 -5
  40. package/scripts/plausible-domain-config.js +3 -1
  41. package/scripts/seo-gsd.js +240 -1
  42. package/scripts/tool-registry.js +2 -2
  43. package/scripts/vector-store.js +44 -0
  44. package/scripts/workspace-evolver.js +62 -2
  45. package/src/api/server.js +340 -131
  46. package/public/assets/brand/thumbgate-mark-inline.svg +0 -15
  47. package/public/compare/adopt-ai.html +0 -219
  48. package/public/compare/agentix-labs.html +0 -197
  49. package/public/compare/ai-experience-orchestration.html +0 -216
  50. package/public/compare/anthropic-claude-for-legal.html +0 -260
  51. package/public/compare/anthropic-containment.html +0 -280
  52. package/public/compare/arcade.html +0 -175
  53. package/public/compare/arcjet.html +0 -239
  54. package/public/compare/bumblebee.html +0 -307
  55. package/public/compare/claude-code-hooks.html +0 -294
  56. package/public/compare/databricks-unity-ai-gateway.html +0 -215
  57. package/public/compare/fallow.html +0 -351
  58. package/public/compare/heidi.html +0 -233
  59. package/public/compare/mem0.html +0 -342
  60. package/public/compare/oak-and-sparrow-gatekeeper.html +0 -289
  61. package/public/compare/rein.html +0 -236
  62. package/public/compare/sigmashake.html +0 -256
  63. package/public/compare/speclock.html +0 -342
  64. package/public/guides/agent-harness-optimization.html +0 -342
  65. package/public/guides/agentic-web-governance.html +0 -406
  66. package/public/guides/ai-agent-governance-sprint.html +0 -415
  67. package/public/guides/ai-agent-pre-action-approval-gates.html +0 -401
  68. package/public/guides/ai-agent-workflow-migration-checklist.html +0 -392
  69. package/public/guides/ai-deployment-readiness.html +0 -415
  70. package/public/guides/ai-mode-ads-agent-governance.html +0 -401
  71. package/public/guides/ai-search-topical-presence.html +0 -342
  72. package/public/guides/autoresearch-agent-safety.html +0 -342
  73. package/public/guides/background-agent-governance.html +0 -358
  74. package/public/guides/best-tools-stop-ai-agents-breaking-production.html +0 -363
  75. package/public/guides/browser-automation-safety.html +0 -342
  76. package/public/guides/chatgpt-ads-trust.html +0 -353
  77. package/public/guides/claude-code-feedback.html +0 -339
  78. package/public/guides/claude-code-prevent-repeated-mistakes.html +0 -161
  79. package/public/guides/claude-code-skills-guardrails.html +0 -343
  80. package/public/guides/claude-desktop.html +0 -356
  81. package/public/guides/code-knowledge-graph-guardrails.html +0 -365
  82. package/public/guides/codex-cli-guardrails.html +0 -339
  83. package/public/guides/cursor-agent-guardrails.html +0 -339
  84. package/public/guides/cursor-prevent-repeated-mistakes.html +0 -161
  85. package/public/guides/database-agent-safety.html +0 -406
  86. package/public/guides/deepseek-v4-runtime-guardrails.html +0 -346
  87. package/public/guides/developer-machine-supply-chain-guardrails.html +0 -358
  88. package/public/guides/gcp-mcp-guardrails.html +0 -147
  89. package/public/guides/gemini-cli-feedback-memory.html +0 -339
  90. package/public/guides/gpt-5-5-model-evaluation.html +0 -358
  91. package/public/guides/internal-ai-engineering-stack-guardrails.html +0 -348
  92. package/public/guides/long-running-agent-context-management.html +0 -346
  93. package/public/guides/mcp-tool-governance.html +0 -401
  94. package/public/guides/multica-thumbgate-setup.html +0 -134
  95. package/public/guides/native-messaging-host-security.html +0 -342
  96. package/public/guides/policy-engine-pre-action-gates.html +0 -346
  97. package/public/guides/pre-action-checks.html +0 -342
  98. package/public/guides/pretooluse-hooks-vs-advisory-prompt-rules.html +0 -342
  99. package/public/guides/prompt-tricks-to-workflow-rules.html +0 -365
  100. package/public/guides/proxy-pointer-rag-guardrails.html +0 -352
  101. package/public/guides/rag-precision-tuning-guardrails.html +0 -352
  102. package/public/guides/reasoning-compression-guardrails.html +0 -346
  103. package/public/guides/relational-knowledge-ai-recommendations.html +0 -342
  104. package/public/guides/roo-code-alternative-cline.html +0 -339
  105. package/public/guides/semantic-programmatic-seo-guardrails.html +0 -352
  106. package/public/guides/seo-agent-skills-guardrails.html +0 -344
  107. package/public/guides/stop-repeated-ai-agent-mistakes.html +0 -342
  108. package/public/learn/ac-dc-runtime-enforcement.html +0 -277
  109. package/public/learn/agent-harness-pattern.html +0 -181
  110. package/public/learn/agent-identity-connector-governance.html +0 -146
  111. package/public/learn/agent-swarms-shared-gates.html +0 -173
  112. package/public/learn/agentic-enterprise-context-brain.html +0 -117
  113. package/public/learn/agentic-os-team-governance.html +0 -146
  114. package/public/learn/ai-agent-governance.html +0 -158
  115. package/public/learn/ai-agent-persistent-memory.html +0 -211
  116. package/public/learn/anthropomorphic-claim-gates.html +0 -180
  117. package/public/learn/background-agent-control-layer.html +0 -184
  118. package/public/learn/claude-code-goal-with-rubrics.html +0 -205
  119. package/public/learn/codex-role-plugins-need-governance.html +0 -125
  120. package/public/learn/cost-aware-agent-gate-routing.html +0 -173
  121. package/public/learn/databricks-unity-ai-gateway-runtime-governance.html +0 -157
  122. package/public/learn/deterministic-agent-workflows.html +0 -185
  123. package/public/learn/feedback-loop-vs-decision-layer.html +0 -283
  124. package/public/learn/from-prototype-to-production.html +0 -223
  125. package/public/learn/learn.css +0 -51
  126. package/public/learn/mcp-pre-action-checks-explained.html +0 -172
  127. package/public/learn/pretix-stripe-connect-marketplaces.html +0 -161
  128. package/public/learn/regulated-agent-execution-boundary.html +0 -196
  129. package/public/learn/spec-driven-development.html +0 -168
  130. package/public/learn/stop-ai-agent-force-push.html +0 -134
  131. package/public/learn/vibe-coding-safety-net.html +0 -142
  132. package/scripts/reddit-browser-notification-watch.js +0 -230
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 {
@@ -215,6 +216,8 @@ const mcpOauth = require('../../scripts/mcp-oauth');
215
216
  // OAuth 2.1 (PKCE) authorization-server state for the remote MCP connector
216
217
  // (Claude Connectors Directory requires OAuth for authenticated services).
217
218
  const oauthStore = mcpOauth.createStore();
219
+ const pendingOauthAuthorizeRequests = new Map();
220
+ const OAUTH_AUTHORIZE_REQUEST_TTL_MS = 10 * 60 * 1000;
218
221
  const resendMailer = require('../../scripts/mailer/resend-mailer');
219
222
  const {
220
223
  buildContextFootprintReport,
@@ -244,6 +247,10 @@ const COMPARE_DIR = path.resolve(__dirname, '../../public/compare');
244
247
  const USE_CASES_DIR = path.resolve(__dirname, '../../public/use-cases');
245
248
  const PUBLIC_DIR = path.resolve(__dirname, '../../public');
246
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);
247
254
  const BUYER_INTENT_SCRIPT_PATH = path.resolve(__dirname, '../../public/js/buyer-intent.js');
248
255
  const STATIC_MIME_BY_EXT = Object.freeze({
249
256
  '.png': 'image/png',
@@ -460,6 +467,36 @@ const TRACKED_LINK_TARGETS = Object.freeze({
460
467
  },
461
468
  allowCustomerEmail: true,
462
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
+ },
463
500
  trial: {
464
501
  path: '/guide',
465
502
  ctaId: 'go_trial',
@@ -1680,6 +1717,8 @@ const FEEDBACK_LIST_LABELS = Object.freeze({
1680
1717
  positive: 'Recent wins',
1681
1718
  });
1682
1719
 
1720
+ const FEEDBACK_OMITTED_TAGS = Object.freeze(['audit-trail', 'auto-capture']);
1721
+
1683
1722
  function formatChatTimestamp(isoString) {
1684
1723
  if (!isoString) return 'unknown time';
1685
1724
  try {
@@ -1698,9 +1737,33 @@ function formatChatTimestamp(isoString) {
1698
1737
  }
1699
1738
  }
1700
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
+
1701
1765
  function buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeline }) {
1702
1766
  const signal = detectFeedbackSignalFromPrompt(ctx.prompt);
1703
-
1704
1767
  // One read of the time-windowed log, then in-memory counts + (signal-filtered,
1705
1768
  // placeholder-stripped) list. Counts include ALL entries (so "Feedback today: 5"
1706
1769
  // matches the dashboard tile); the list drops vague entries like literal "thumbs
@@ -1708,30 +1771,16 @@ function buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeli
1708
1771
  const windowed = readRecentFeedbackEntries(feedbackDir, null, intent.windowMs, 10000, { includeSignal: true });
1709
1772
  const windowPos = windowed.filter((r) => r.signal === 'positive').length;
1710
1773
  const windowNeg = windowed.filter((r) => r.signal === 'negative').length;
1711
- const entries = (signal ? windowed.filter((r) => r.signal === signal) : windowed)
1712
- .filter((r) => r.context && !isPlaceholder(r.context))
1713
- .slice(0, 5);
1774
+ const entries = buildFeedbackEntries(windowed, signal);
1714
1775
 
1715
- const lines = [];
1716
- lines.push(intent.windowMs
1776
+ const lines = [
1777
+ intent.windowMs
1717
1778
  ? `Feedback ${intent.windowLabel}: ${windowed.length} (${windowPos} positive, ${windowNeg} negative).`
1718
- : `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
+ ];
1719
1781
 
1720
1782
  if (intent.wantsList) {
1721
- if (entries.length) {
1722
- lines.push(`${FEEDBACK_LIST_LABELS[signal] || 'Recent feedback'} (${intent.windowLabel}):`);
1723
- for (const e of entries) {
1724
- const tsFormatted = formatChatTimestamp(e.timestamp);
1725
- const signalLabel = e.signal ? ` [${e.signal}]` : '';
1726
- const tagsList = Array.isArray(e.tags)
1727
- ? e.tags.filter(t => !['audit-trail', 'auto-capture'].includes(t))
1728
- : [];
1729
- const tagsStr = tagsList.length ? ` (${tagsList.join(', ')})` : '';
1730
- lines.push(` • ${tsFormatted}${signalLabel}${tagsStr} — ${e.context}`);
1731
- }
1732
- } else {
1733
- lines.push(`No ${signal || 'feedback'} entries found ${intent.windowLabel}.`);
1734
- }
1783
+ appendFeedbackListLines(lines, { entries, signal, intent });
1735
1784
  } else {
1736
1785
  lines.push(`Lesson pipeline: ${compactNumber(lessonPipeline.lessons || lessonPipeline.generated || 0)} lessons visible in the current dashboard snapshot.`);
1737
1786
  }
@@ -1939,7 +1988,7 @@ async function answerEnterpriseDataChat({ prompt, feedbackDir, parsed }) {
1939
1988
  const answerEnterpriseDialogflowChat = answerEnterpriseDataChat;
1940
1989
 
1941
1990
  function buildLossAnalyticsResponse(data, summaryOptions) {
1942
- return {
1991
+ return sanitizeHtmlUnsafeJsonValue({
1943
1992
  window: data.analytics.window || summaryOptions,
1944
1993
  lossAnalysis: data.analytics.lossAnalysis || null,
1945
1994
  buyerLoss: data.analytics.buyerLoss || null,
@@ -1951,7 +2000,7 @@ function buildLossAnalyticsResponse(data, summaryOptions) {
1951
2000
  ctas: data.analytics.telemetry && data.analytics.telemetry.ctas,
1952
2001
  visitors: data.analytics.telemetry && data.analytics.telemetry.visitors,
1953
2002
  },
1954
- };
2003
+ });
1955
2004
  }
1956
2005
 
1957
2006
  function createJourneyId(prefix) {
@@ -2054,17 +2103,111 @@ function buildCheckoutIntentHref(baseUrl, metadata = {}, overrides = {}) {
2054
2103
  });
2055
2104
  }
2056
2105
 
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
+
2057
2205
  function renderCheckoutIntentPage(prefilledEmail = '', parsed = null, options = {}) {
2058
2206
  const plausibleDomain = escapeHtmlAttribute(resolvePlausibleDataDomain({ host: 'thumbgate.ai' }));
2059
2207
  const includeHiddenAttribution = options.includeHiddenAttribution === true;
2060
- let hiddenInputs = '';
2061
- if (includeHiddenAttribution && parsed?.searchParams) {
2062
- for (const [key, value] of parsed.searchParams.entries()) {
2063
- if (key !== 'confirm' && key !== 'customer_email') {
2064
- hiddenInputs += `<input type="hidden" name="${escapeHtmlAttribute(key)}" value="${escapeHtmlAttribute(value)}">`;
2065
- }
2066
- }
2067
- }
2208
+ const hiddenInputs = includeHiddenAttribution
2209
+ ? buildCheckoutHiddenAttributionInputs(parsed)
2210
+ : '';
2068
2211
  return `<!doctype html>
2069
2212
  <html lang="en">
2070
2213
  <head>
@@ -2077,7 +2220,7 @@ function renderCheckoutIntentPage(prefilledEmail = '', parsed = null, options =
2077
2220
  body{background:#0a0a0a;color:#eee;font-family:system-ui,-apple-system,sans-serif;line-height:1.5}
2078
2221
  main{max-width:520px;margin:8vh auto;padding:0 20px}
2079
2222
  .brand{display:flex;align-items:center;gap:10px;margin-bottom:24px;font-size:14px;color:#94a3b8}
2080
- .brand-mark{width:24px;height:24px;background:#22d3ee;border-radius:6px;display:inline-block}
2223
+ .brand-mark{width:36px;height:36px;display:block;flex:0 0 auto;border-radius:9px}
2081
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}
2082
2225
  p{color:#cbd5e1;margin:8px 0}form{margin:0}
2083
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}
@@ -2096,7 +2239,7 @@ a{display:block;text-decoration:none}a.secondary{border:1px solid #374151;color:
2096
2239
  </head>
2097
2240
  <body>
2098
2241
  <main>
2099
- <div class="brand"><span class="brand-mark"></span><span>ThumbGate</span></div>
2242
+ <div class="brand"><img src="/thumbgate-icon.png?v=20260623-stripe-match" alt="" class="brand-mark" width="36" height="36"><span>ThumbGate</span></div>
2100
2243
  <h1>Start ThumbGate Pro</h1>
2101
2244
  <div class="price">$19<small>/mo</small></div>
2102
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>
@@ -2262,17 +2405,6 @@ function normalizeCheckoutCustomerEmail(value) {
2262
2405
  return email;
2263
2406
  }
2264
2407
 
2265
- function renderCheckoutIntentGate(parsed, responseHeaders = {}) {
2266
- let hiddenInputs = '';
2267
- for (const [key, value] of parsed.searchParams.entries()) {
2268
- if (key !== 'confirm' && key !== 'customer_email') hiddenInputs += `<input type=hidden name=${escapeHtmlAttribute(key)} value=${escapeHtmlAttribute(value)}>`;
2269
- }
2270
- return {
2271
- 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>`,
2272
- headers: responseHeaders,
2273
- };
2274
- }
2275
-
2276
2408
  function normalizeTrackedLinkSlug(value) {
2277
2409
  return String(value || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
2278
2410
  }
@@ -2283,6 +2415,27 @@ function normalizePublicPageSlug(value) {
2283
2415
  .replace(/[^a-z0-9-]/g, '');
2284
2416
  }
2285
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
+
2286
2439
  function getTrackedLinkTarget(slug) {
2287
2440
  const normalizedSlug = normalizeTrackedLinkSlug(slug);
2288
2441
  return TRACKED_LINK_TARGETS[normalizedSlug]
@@ -2318,8 +2471,10 @@ function appendTrackedLinkQueryParams(destinationUrl, parsed, target) {
2318
2471
  }
2319
2472
 
2320
2473
  function buildTrackedLinkDestination(target, hostedConfig, parsed) {
2321
- const destinationUrl = target.href
2322
- ? 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)
2323
2478
  : new URL(target.path || '/', hostedConfig.appOrigin);
2324
2479
  appendTrackedLinkQueryParams(destinationUrl, parsed, target);
2325
2480
  return destinationUrl;
@@ -2446,6 +2601,7 @@ function sendJson(res, statusCode, payload, extraHeaders = {}, options = {}) {
2446
2601
  const body = JSON.stringify(payload);
2447
2602
  res.writeHead(statusCode, {
2448
2603
  'Content-Type': 'application/json; charset=utf-8',
2604
+ 'X-Content-Type-Options': 'nosniff',
2449
2605
  'Content-Length': Buffer.byteLength(body),
2450
2606
  ...extraHeaders,
2451
2607
  });
@@ -2539,8 +2695,9 @@ function chatgptActionEventType(integration, suffix) {
2539
2695
  }
2540
2696
 
2541
2697
  function getPublicOrigin(req) {
2542
- const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() || 'http';
2543
- 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';
2544
2701
  return `${proto}://${host}`;
2545
2702
  }
2546
2703
 
@@ -2559,6 +2716,47 @@ function getRequestHostHeader(req) {
2559
2716
  return forwardedHost || req.headers.host || '';
2560
2717
  }
2561
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
+
2562
2760
  function isLoopbackHost(hostValue) {
2563
2761
  const rawHost = String(hostValue || '').split(',')[0].trim();
2564
2762
  if (!rawHost) {
@@ -2613,7 +2811,7 @@ function normalizePublicMarketingHtml(html, runtimeConfig, requestHost) {
2613
2811
  let output = String(html);
2614
2812
  output = output.replaceAll(DEFAULT_PUBLIC_APP_ORIGIN, appOrigin);
2615
2813
  try {
2616
- const host = requestHost || new URL(appOrigin).host;
2814
+ const host = normalizePublicRequestHost(requestHost) || new URL(appOrigin).host;
2617
2815
  const plausibleDomain = resolvePlausibleDataDomain({ host });
2618
2816
  output = output.replaceAll(
2619
2817
  'data-domain="thumbgate-production.up.railway.app"',
@@ -2654,13 +2852,8 @@ function loadPublicMarketingTemplateHtml(templatePath, runtimeConfig, pageContex
2654
2852
  '__PRO_PRICE_LABEL__': runtimeConfig.proPriceLabel,
2655
2853
  '__SPRINT_DIAGNOSTIC_CHECKOUT_URL__': runtimeConfig.sprintDiagnosticCheckoutUrl || SPRINT_DIAGNOSTIC_CHECKOUT_URL,
2656
2854
  '__WORKFLOW_SPRINT_CHECKOUT_URL__': runtimeConfig.workflowSprintCheckoutUrl || WORKFLOW_SPRINT_CHECKOUT_URL,
2657
- '__PAYPAL_DIAGNOSTIC_CHECKOUT_URL__': runtimeConfig.paypalDiagnosticCheckoutUrl || '',
2658
- '__PAYPAL_WORKFLOW_SPRINT_CHECKOUT_URL__': runtimeConfig.paypalWorkflowSprintCheckoutUrl || '',
2659
- '__MOR_SNAPSHOT_CHECKOUT_URL__': runtimeConfig.morSnapshotCheckoutUrl || '',
2660
- '__MOR_PROVIDER__': runtimeConfig.morProvider || 'Lemon Squeezy or Paddle',
2661
2855
  '__SPRINT_DIAGNOSTIC_PRICE_DOLLARS__': runtimeConfig.sprintDiagnosticPriceDollars || 499,
2662
2856
  '__WORKFLOW_SPRINT_PRICE_DOLLARS__': runtimeConfig.workflowSprintPriceDollars || 1500,
2663
- '__SNAPSHOT_PRICE_DOLLARS__': runtimeConfig.snapshotPriceDollars || 97,
2664
2857
  '__GA_MEASUREMENT_ID__': runtimeConfig.gaMeasurementId || '',
2665
2858
  '__GA_BOOTSTRAP__': gaBootstrap,
2666
2859
  '__GOOGLE_SITE_VERIFICATION_META__': googleSiteVerificationMeta,
@@ -3140,7 +3333,7 @@ nav .container { display: flex; justify-content: space-between; align-items: cen
3140
3333
  </head>
3141
3334
  <body>
3142
3335
  <nav><div class="container">
3143
- <a href="/dashboard" class="nav-logo"><img src="/assets/brand/thumbgate-mark-inline.svg" alt="ThumbGate" class="logo-mark" width="28" height="28"><span class="logo-text">ThumbGate</span></a>
3336
+ <a href="/dashboard" class="nav-logo"><img src="/assets/brand/thumbgate-mark-inline-v3.svg" alt="ThumbGate" class="logo-mark" width="28" height="28"><span class="logo-text">ThumbGate</span></a>
3144
3337
  <div class="nav-links">
3145
3338
  <a href="/dashboard">Dashboard</a>
3146
3339
  <a href="/lessons">Lessons</a>
@@ -3333,9 +3526,6 @@ function renderSitemapXml(runtimeConfig) {
3333
3526
  { path: '/learn/codex-role-plugins-need-governance', changefreq: 'weekly', priority: '0.85' },
3334
3527
  { path: '/learn/agentic-os-team-governance', changefreq: 'weekly', priority: '0.85' },
3335
3528
  { path: '/learn/cost-aware-agent-gate-routing', changefreq: 'weekly', priority: '0.85' },
3336
- { path: '/learn/databricks-unity-ai-gateway-runtime-governance', changefreq: 'weekly', priority: '0.85' },
3337
- { path: '/learn/anthropomorphic-claim-gates', changefreq: 'weekly', priority: '0.85' },
3338
- { path: '/learn/agent-identity-connector-governance', changefreq: 'weekly', priority: '0.9' },
3339
3529
  { path: '/learn/pretix-stripe-connect-marketplaces', changefreq: 'weekly', priority: '0.9' },
3340
3530
  { path: '/compare/claude-code-hooks', changefreq: 'weekly', priority: '0.85' },
3341
3531
  { path: '/compare/bumblebee', changefreq: 'weekly', priority: '0.85' },
@@ -3345,30 +3535,30 @@ function renderSitemapXml(runtimeConfig) {
3345
3535
  { path: '/compare/anthropic-claude-for-legal', changefreq: 'weekly', priority: '0.9' },
3346
3536
  ...THUMBGATE_SEO_SITEMAP_ENTRIES,
3347
3537
  ];
3348
- // Auto-include every hand-written comparison and guide page so /sitemap.xml can
3349
- // never drift out of sync with public/compare/*.html or public/guides/*.html.
3350
- // Crawlers and AI answer engines (Google AI Overviews/AI Mode, ChatGPT,
3351
- // Perplexity) only surface pages they can discover, so a page missing from the
3352
- // sitemap is invisible on its buyer-intent query. De-duped against entries
3353
- // already declared above (e.g. the seo-gsd specs), which keep their explicit
3354
- // priorities.
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.
3355
3543
  const declaredPaths = new Set(entries.map((entry) => entry.path));
3356
- const includePublicHtmlPages = (dirName, publicPrefix, priority) => {
3357
- try {
3358
- const files = fs.readdirSync(path.join(PUBLIC_DIR, dirName)).sort((a, b) => a.localeCompare(b));
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));
3359
3551
  for (const file of files) {
3360
3552
  if (!file.endsWith('.html')) continue;
3361
- const pagePath = `${publicPrefix}/${file.replace(/\.html$/, '')}`;
3362
- if (declaredPaths.has(pagePath)) continue;
3363
- declaredPaths.add(pagePath);
3364
- entries.push({ path: pagePath, changefreq: 'weekly', priority });
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 });
3365
3557
  }
3366
- } catch {
3367
- // Directory absent in a stripped bundle — fall back to the static entries.
3368
3558
  }
3369
- };
3370
- includePublicHtmlPages('compare', '/compare', '0.85');
3371
- includePublicHtmlPages('guides', '/guides', '0.82');
3559
+ } catch {
3560
+ // SEO directories absent in a stripped bundle — fall back to static entries.
3561
+ }
3372
3562
  return [
3373
3563
  '<?xml version="1.0" encoding="UTF-8"?>',
3374
3564
  '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
@@ -3494,7 +3684,7 @@ function servePublicMarketingPage({
3494
3684
  }, req.headers, 'seo_landing_view');
3495
3685
  }
3496
3686
 
3497
- const requestHost = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim();
3687
+ const requestHost = getSafePublicRequestHost(req);
3498
3688
  const html = renderHtml(hostedConfig, {
3499
3689
  serverVisitorId: journeyState.visitorId,
3500
3690
  serverSessionId: journeyState.sessionId,
@@ -3645,7 +3835,7 @@ function renderCheckoutSuccessPage(runtimeConfig) {
3645
3835
  </head>
3646
3836
  <body>
3647
3837
  <main>
3648
- <a href="/" class="brand-header"><img src="/assets/brand/thumbgate-mark-inline.svg" alt="ThumbGate" class="logo-mark" width="32" height="32"><span class="logo-text">ThumbGate</span></a>
3838
+ <a href="/" class="brand-header"><img src="/assets/brand/thumbgate-mark-inline-v3.svg" alt="ThumbGate" class="logo-mark" width="32" height="32"><span class="logo-text">ThumbGate</span></a>
3649
3839
  <span class="eyebrow">ThumbGate Pro</span>
3650
3840
  <h1>Your local Pro dashboard is ready.</h1>
3651
3841
  <p class="lead">This page verifies your Stripe session, provisions the key if needed, and gives you the exact command to save your license and launch your personal local dashboard.</p>
@@ -3873,34 +4063,15 @@ function renderCheckoutCancelledPage(runtimeConfig) {
3873
4063
  const workflowSprintCheckoutUrl = runtimeConfig.workflowSprintCheckoutUrl
3874
4064
  ? escapeHtmlAttribute(runtimeConfig.workflowSprintCheckoutUrl)
3875
4065
  : '';
3876
- const paypalDiagnosticCheckoutUrl = runtimeConfig.paypalDiagnosticCheckoutUrl
3877
- ? escapeHtmlAttribute(runtimeConfig.paypalDiagnosticCheckoutUrl)
3878
- : '';
3879
- const paypalWorkflowSprintCheckoutUrl = runtimeConfig.paypalWorkflowSprintCheckoutUrl
3880
- ? escapeHtmlAttribute(runtimeConfig.paypalWorkflowSprintCheckoutUrl)
3881
- : '';
3882
- const morSnapshotCheckoutUrl = runtimeConfig.morSnapshotCheckoutUrl
3883
- ? escapeHtmlAttribute(runtimeConfig.morSnapshotCheckoutUrl)
3884
- : '';
3885
4066
  const sprintDiagnosticPriceDollars = runtimeConfig.sprintDiagnosticPriceDollars || 499;
3886
4067
  const workflowSprintPriceDollars = runtimeConfig.workflowSprintPriceDollars || 1500;
3887
- const snapshotPriceDollars = runtimeConfig.snapshotPriceDollars || 97;
3888
4068
  const workflowSprintIntakeUrl = `${escapeHtmlAttribute(runtimeConfig.appOrigin)}/#workflow-sprint-intake`;
3889
4069
  const recoveryOfferLinks = [
3890
4070
  diagnosticCheckoutUrl
3891
- ? `<a href="${diagnosticCheckoutUrl}" data-recovery-offer="sprint_diagnostic_stripe" data-offer-price="${sprintDiagnosticPriceDollars}">Book $${sprintDiagnosticPriceDollars} diagnostic by Stripe</a>`
3892
- : '',
3893
- paypalDiagnosticCheckoutUrl
3894
- ? `<a href="${paypalDiagnosticCheckoutUrl}" data-recovery-offer="sprint_diagnostic_paypal" data-offer-price="${sprintDiagnosticPriceDollars}">Book $${sprintDiagnosticPriceDollars} diagnostic by PayPal</a>`
4071
+ ? `<a href="${diagnosticCheckoutUrl}" data-recovery-offer="sprint_diagnostic" data-offer-price="${sprintDiagnosticPriceDollars}">Book $${sprintDiagnosticPriceDollars} diagnostic</a>`
3895
4072
  : '',
3896
4073
  workflowSprintCheckoutUrl
3897
- ? `<a href="${workflowSprintCheckoutUrl}" data-recovery-offer="workflow_sprint_stripe" data-offer-price="${workflowSprintPriceDollars}">Start $${workflowSprintPriceDollars} sprint by Stripe</a>`
3898
- : '',
3899
- paypalWorkflowSprintCheckoutUrl
3900
- ? `<a href="${paypalWorkflowSprintCheckoutUrl}" data-recovery-offer="workflow_sprint_paypal" data-offer-price="${workflowSprintPriceDollars}">Start $${workflowSprintPriceDollars} sprint by PayPal</a>`
3901
- : '',
3902
- morSnapshotCheckoutUrl
3903
- ? `<a href="${morSnapshotCheckoutUrl}" data-recovery-offer="snapshot_mor" data-offer-price="${snapshotPriceDollars}">Buy $${snapshotPriceDollars} snapshot</a>`
4074
+ ? `<a href="${workflowSprintCheckoutUrl}" data-recovery-offer="workflow_sprint" data-offer-price="${workflowSprintPriceDollars}">Start $${workflowSprintPriceDollars} sprint</a>`
3904
4075
  : '',
3905
4076
  `<a href="${workflowTeardownCheckoutUrl}" data-recovery-offer="workflow_teardown" data-offer-price="99">Pay $99 teardown</a>`,
3906
4077
  `<a href="${quickReadCheckoutUrl}" data-recovery-offer="quick_read" data-offer-price="19">Pay $19 quick read</a>`,
@@ -4598,6 +4769,42 @@ function normalizeJobIdFromPath(pathname, suffix = '') {
4598
4769
  return match ? decodeURIComponent(match[1]) : null;
4599
4770
  }
4600
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
+
4601
4808
  function normalizeDocumentIdFromPath(pathname) {
4602
4809
  const match = pathname.match(/^\/v1\/documents\/([^/]+)$/);
4603
4810
  return match ? decodeURIComponent(match[1]) : null;
@@ -5523,7 +5730,7 @@ async function addContext(){
5523
5730
  if (isGetLikeRequest && (pathname === '/numbers' || pathname === '/numbers.html')) {
5524
5731
  // Route through servePublicMarketingPage so landing_page_view telemetry
5525
5732
  // + funnel-events.jsonl `discovery/landing_view` get captured with UTM
5526
- // attribution — critical for Zernio social CTAs that target /numbers.
5733
+ // attribution — critical for DirectSocial social CTAs that target /numbers.
5527
5734
  try {
5528
5735
  servePublicMarketingPage({
5529
5736
  req,
@@ -5665,13 +5872,12 @@ async function addContext(){
5665
5872
 
5666
5873
  if (isGetLikeRequest && pathname.startsWith('/learn/')) {
5667
5874
  try {
5668
- const slug = normalizePublicPageSlug(pathname.replace('/learn/', ''));
5669
- const articlePath = path.join(LEARN_DIR, `${slug}.html`);
5670
- if (!articlePath.startsWith(LEARN_DIR)) {
5671
- 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' });
5672
5878
  return;
5673
5879
  }
5674
- const requestHost = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim();
5880
+ const requestHost = getSafePublicRequestHost(req);
5675
5881
  const html = normalizePublicMarketingHtml(fs.readFileSync(articlePath, 'utf-8'), hostedConfig, requestHost);
5676
5882
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5677
5883
  } catch {
@@ -5682,10 +5888,9 @@ async function addContext(){
5682
5888
 
5683
5889
  if (isGetLikeRequest && pathname.startsWith('/guides/')) {
5684
5890
  try {
5685
- const slug = normalizePublicPageSlug(pathname.replace('/guides/', ''));
5686
- const guidePath = path.join(GUIDES_DIR, `${slug}.html`);
5687
- if (!guidePath.startsWith(GUIDES_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
5688
- const requestHost = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim();
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);
5689
5894
  const html = normalizePublicMarketingHtml(fs.readFileSync(guidePath, 'utf-8'), hostedConfig, requestHost);
5690
5895
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5691
5896
  } catch { sendJson(res, 404, { error: 'Guide not found' }); }
@@ -5694,10 +5899,9 @@ async function addContext(){
5694
5899
 
5695
5900
  if (isGetLikeRequest && pathname.startsWith('/compare/') && pathname !== '/compare') {
5696
5901
  try {
5697
- const slug = normalizePublicPageSlug(pathname.replace('/compare/', ''));
5698
- const comparePath = path.join(COMPARE_DIR, `${slug}.html`);
5699
- if (!comparePath.startsWith(COMPARE_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
5700
- const requestHost = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim();
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);
5701
5905
  const html = normalizePublicMarketingHtml(fs.readFileSync(comparePath, 'utf-8'), hostedConfig, requestHost);
5702
5906
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5703
5907
  } catch { sendJson(res, 404, { error: 'Comparison not found' }); }
@@ -5706,10 +5910,9 @@ async function addContext(){
5706
5910
 
5707
5911
  if (isGetLikeRequest && pathname.startsWith('/use-cases/')) {
5708
5912
  try {
5709
- const slug = normalizePublicPageSlug(pathname.replace('/use-cases/', ''));
5710
- const useCasePath = path.join(USE_CASES_DIR, `${slug}.html`);
5711
- if (!useCasePath.startsWith(USE_CASES_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
5712
- const requestHost = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim();
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);
5713
5916
  const html = normalizePublicMarketingHtml(fs.readFileSync(useCasePath, 'utf-8'), hostedConfig, requestHost);
5714
5917
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5715
5918
  } catch { sendJson(res, 404, { error: 'Use case not found' }); }
@@ -6212,7 +6415,7 @@ async function addContext(){
6212
6415
  // Public-facing broker lead-flow audit landing page. Wedge for the
6213
6416
  // real-estate broker outreach. Static HTML served from src/api/static.
6214
6417
  try {
6215
- const host = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim();
6418
+ const host = getSafePublicRequestHost(req);
6216
6419
  const html = normalizePublicMarketingHtml(fillTemplate(fs.readFileSync(
6217
6420
  path.resolve(__dirname, '../../assets/static/broker-audit.html'),
6218
6421
  'utf8'
@@ -6270,9 +6473,9 @@ async function addContext(){
6270
6473
  // Authorization endpoint: GET renders consent, POST issues the code.
6271
6474
  if (pathname === '/oauth/authorize') {
6272
6475
  if (isGetLikeRequest) {
6273
- const q = parsed.searchParams;
6274
- const fields = ['client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'resource'];
6275
- 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
+ );
6276
6479
  const html = `<!doctype html><html><head><meta charset="utf-8"><title>Authorize ThumbGate</title>
6277
6480
  <style>body{font:15px system-ui;margin:0;background:#0b0b0c;color:#eee;display:flex;min-height:100vh;align-items:center;justify-content:center}
6278
6481
  .card{background:#161618;border:1px solid #2a2a2e;border-radius:12px;padding:28px;max-width:420px}
@@ -6281,7 +6484,7 @@ button{width:100%;padding:11px;border-radius:8px;border:0;background:#10b981;col
6281
6484
  a{color:#8b9}</style></head><body><form class="card" method="post" action="/oauth/authorize">
6282
6485
  <h2>Authorize Claude → ThumbGate</h2>
6283
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>
6284
- ${hidden}
6487
+ <input type="hidden" name="auth_request_token" value="${escapeHtmlAttribute(authRequestToken)}">
6285
6488
  <input type="password" name="api_key" placeholder="ThumbGate API key" autocomplete="off" required>
6286
6489
  <button type="submit" name="approve" value="yes">Approve</button>
6287
6490
  </form></body></html>`;
@@ -6293,8 +6496,9 @@ ${hidden}
6293
6496
  req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
6294
6497
  req.on('end', () => {
6295
6498
  const form = new URLSearchParams(body);
6296
- const redirectUri = form.get('redirect_uri') || '';
6297
- const state = form.get('state') || '';
6499
+ const authorizationParams = getOauthAuthorizeParamsFromForm(form, hostedConfig);
6500
+ const redirectUri = authorizationParams.redirectUri;
6501
+ const state = authorizationParams.state;
6298
6502
  // Validate the presented ThumbGate key before issuing a code. When keys
6299
6503
  // are configured (production) the key MUST match a configured admin /
6300
6504
  // operator / reviewer key — otherwise OAuth would authenticate nobody.
@@ -6304,12 +6508,12 @@ ${hidden}
6304
6508
  return;
6305
6509
  }
6306
6510
  const issued = mcpOauth.createAuthorizationCode(oauthStore, {
6307
- clientId: form.get('client_id') || '',
6511
+ clientId: authorizationParams.clientId,
6308
6512
  redirectUri,
6309
- codeChallenge: form.get('code_challenge') || '',
6310
- codeChallengeMethod: form.get('code_challenge_method') || '',
6311
- scope: form.get('scope') || undefined,
6312
- 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'),
6313
6517
  boundKey: form.get('api_key') || '',
6314
6518
  state,
6315
6519
  });
@@ -8402,11 +8606,14 @@ ${hidden}
8402
8606
  {
8403
8607
  const documentId = normalizeDocumentIdFromPath(pathname);
8404
8608
  if (req.method === 'GET' && documentId) {
8609
+ if (!/^[a-zA-Z0-9-_]+$/.test(documentId)) {
8610
+ throw createHttpError(400, 'Invalid document ID format');
8611
+ }
8405
8612
  const document = readImportedDocument(documentId, {
8406
8613
  feedbackDir: requestFeedbackDir,
8407
8614
  });
8408
8615
  if (!document) {
8409
- throw createHttpError(404, `Imported document not found: ${documentId}`);
8616
+ throw createHttpError(404, `Imported document not found: ${escapeHtml(documentId)}`);
8410
8617
  }
8411
8618
  sendJson(res, 200, { document });
8412
8619
  return;
@@ -9502,6 +9709,8 @@ module.exports = {
9502
9709
  buildEnterpriseChatAnswer,
9503
9710
  answerEnterpriseDataChat,
9504
9711
  answerEnterpriseDialogflowChat,
9712
+ buildLossAnalyticsResponse,
9713
+ sanitizeHtmlUnsafeJsonValue,
9505
9714
  },
9506
9715
  };
9507
9716