thumbgate 1.27.7 → 1.27.9

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