thumbgate 1.27.16 → 1.27.18

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 (165) hide show
  1. package/.claude-plugin/marketplace.json +85 -0
  2. package/.claude-plugin/plugin.json +3 -4
  3. package/.well-known/llms.txt +12 -33
  4. package/.well-known/mcp/server-card.json +8 -8
  5. package/README.md +30 -215
  6. package/adapters/chatgpt/openapi.yaml +1695 -0
  7. package/adapters/claude/.mcp.json +2 -2
  8. package/adapters/gemini/function-declarations.json +0 -1
  9. package/adapters/mcp/server-stdio.js +11 -263
  10. package/adapters/opencode/opencode.json +1 -1
  11. package/bench/thumbgate-bench.json +2 -2
  12. package/bin/cli.js +122 -1421
  13. package/bin/postinstall.js +27 -11
  14. package/config/gate-templates.json +0 -216
  15. package/config/gates/claim-verification.json +0 -12
  16. package/config/gates/default.json +2 -31
  17. package/config/github-about.json +2 -2
  18. package/config/mcp-allowlists.json +13 -23
  19. package/config/merge-quality-checks.json +1 -0
  20. package/config/model-candidates.json +6 -121
  21. package/openapi/openapi.yaml +0 -12
  22. package/package.json +98 -228
  23. package/public/blog.html +4 -4
  24. package/public/codex-plugin.html +20 -72
  25. package/public/compare.html +8 -31
  26. package/public/dashboard.html +166 -930
  27. package/public/federal.html +2 -2
  28. package/public/guide.html +9 -33
  29. package/public/index.html +116 -495
  30. package/public/learn.html +18 -191
  31. package/public/lessons.html +10 -168
  32. package/public/numbers.html +7 -7
  33. package/public/pro.html +11 -34
  34. package/scripts/agent-memory-lifecycle.js +0 -211
  35. package/scripts/agent-readiness.js +3 -167
  36. package/scripts/agent-reward-model.js +1 -53
  37. package/scripts/auto-promote-gates.js +10 -82
  38. package/scripts/auto-wire-hooks.js +0 -14
  39. package/scripts/billing.js +1 -93
  40. package/scripts/build-metadata.js +10 -50
  41. package/scripts/cli-feedback.js +6 -18
  42. package/scripts/cli-schema.js +0 -90
  43. package/scripts/cli-telemetry.js +1 -6
  44. package/scripts/commercial-offer.js +2 -82
  45. package/scripts/context-manager.js +6 -74
  46. package/scripts/dashboard.js +2 -68
  47. package/scripts/durability/step.js +3 -3
  48. package/scripts/export-databricks-bundle.js +1 -5
  49. package/scripts/export-dpo-pairs.js +2 -7
  50. package/scripts/feedback-loop.js +1 -123
  51. package/scripts/feedback-quality.js +0 -87
  52. package/scripts/filesystem-search.js +10 -35
  53. package/scripts/gate-stats.js +0 -89
  54. package/scripts/gates-engine.js +85 -1105
  55. package/scripts/gemini-embedding-policy.js +1 -2
  56. package/scripts/hook-runtime.js +14 -20
  57. package/scripts/hook-thumbgate-cache-updater.js +2 -18
  58. package/scripts/hybrid-feedback-context.js +7 -142
  59. package/scripts/lesson-inference.js +3 -8
  60. package/scripts/lesson-search.js +1 -17
  61. package/scripts/license.js +1 -0
  62. package/scripts/llm-client.js +9 -390
  63. package/scripts/local-model-profile.js +8 -15
  64. package/scripts/memory-scope-readiness.js +0 -159
  65. package/scripts/meta-agent-loop.js +0 -36
  66. package/scripts/operational-integrity.js +5 -39
  67. package/scripts/oss-pr-opportunity-scout.js +5 -35
  68. package/scripts/plausible-server-events.js +6 -9
  69. package/scripts/pro-local-dashboard.js +4 -4
  70. package/scripts/proxy-pointer-rag-guardrails.js +1 -42
  71. package/scripts/rate-limiter.js +13 -64
  72. package/scripts/secret-scanner.js +5 -44
  73. package/scripts/security-scanner.js +10 -260
  74. package/scripts/self-distill-agent.js +1 -3
  75. package/scripts/seo-gsd.js +2 -916
  76. package/scripts/statusline-cache-path.js +2 -17
  77. package/scripts/statusline-local-stats.js +1 -9
  78. package/scripts/statusline-meta.js +2 -28
  79. package/scripts/statusline.sh +4 -20
  80. package/scripts/telemetry-analytics.js +0 -357
  81. package/scripts/thompson-sampling.js +10 -31
  82. package/scripts/thumbgate-bench.js +1 -16
  83. package/scripts/thumbgate-search.js +19 -85
  84. package/scripts/tool-registry.js +1 -169
  85. package/scripts/vector-store.js +0 -45
  86. package/scripts/workflow-sentinel.js +53 -286
  87. package/scripts/workspace-evolver.js +2 -62
  88. package/src/api/server.js +290 -2578
  89. package/.claude/commands/dashboard.md +0 -15
  90. package/.claude/commands/thumbgate-blocked.md +0 -27
  91. package/.claude/commands/thumbgate-dashboard.md +0 -15
  92. package/.claude/commands/thumbgate-doctor.md +0 -30
  93. package/.claude/commands/thumbgate-guard.md +0 -36
  94. package/.claude/commands/thumbgate-protect.md +0 -30
  95. package/.claude/commands/thumbgate-rules.md +0 -30
  96. package/.well-known/agentic-verify.txt +0 -1
  97. package/adapters/gcp/dfcx-webhook-gate.js +0 -295
  98. package/adapters/letta/README.md +0 -41
  99. package/adapters/letta/thumbgate-letta-adapter.js +0 -133
  100. package/adapters/policy-engine/ethicore-guardian-client.js +0 -68
  101. package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +0 -260
  102. package/bench/observability-eval-suite.json +0 -26
  103. package/bin/dashboard-cli.js +0 -7
  104. package/commands/dashboard.md +0 -15
  105. package/commands/thumbgate-dashboard.md +0 -15
  106. package/config/builtin-lessons.json +0 -23
  107. package/config/gate-classifier-routing.json +0 -98
  108. package/config/post-deploy-marketing-pages.json +0 -61
  109. package/public/about.html +0 -162
  110. package/public/agent-manager.html +0 -179
  111. package/public/agents-cost-savings.html +0 -153
  112. package/public/ai-malpractice-prevention.html +0 -818
  113. package/public/assets/brand/github-social-preview.png +0 -0
  114. package/public/assets/brand/thumbgate-icon-512.png +0 -0
  115. package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
  116. package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
  117. package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
  118. package/public/assets/brand/thumbgate-logo-transparent.svg +0 -22
  119. package/public/assets/brand/thumbgate-mark-inline-v3.svg +0 -19
  120. package/public/assets/brand/thumbgate-mark-pro.svg +0 -23
  121. package/public/assets/brand/thumbgate-mark-team.svg +0 -26
  122. package/public/assets/brand/thumbgate-mark.svg +0 -21
  123. package/public/assets/brand/thumbgate-wordmark.svg +0 -20
  124. package/public/assets/claude-thumbgate-statusbar.svg +0 -8
  125. package/public/assets/codex-thumbgate-statusbar-test.svg +0 -9
  126. package/public/assets/legal-intake-control-flow.svg +0 -66
  127. package/public/brand/thumbgate-mark.svg +0 -19
  128. package/public/brand/thumbgate-og.svg +0 -16
  129. package/public/chatgpt-app.html +0 -330
  130. package/public/codex-enterprise.html +0 -123
  131. package/public/js/buyer-intent.js +0 -672
  132. package/public/pricing.html +0 -399
  133. package/scripts/action-receipts.js +0 -324
  134. package/scripts/activation-quickstart.js +0 -187
  135. package/scripts/agent-operations-planner.js +0 -621
  136. package/scripts/ai-component-inventory.js +0 -367
  137. package/scripts/async-eval-observability.js +0 -236
  138. package/scripts/audit.js +0 -65
  139. package/scripts/aws-blocks-guardrails.js +0 -228
  140. package/scripts/classifier-routing.js +0 -130
  141. package/scripts/dashboard-chat.js +0 -332
  142. package/scripts/feedback-aggregate.js +0 -281
  143. package/scripts/feedback-sanitizer.js +0 -105
  144. package/scripts/hob-pack.js +0 -591
  145. package/scripts/hook-stop-anti-claim.js +0 -301
  146. package/scripts/install-shim.js +0 -87
  147. package/scripts/mcp-oauth.js +0 -293
  148. package/scripts/noop-detect.js +0 -285
  149. package/scripts/omlx-smoke.js +0 -192
  150. package/scripts/parallel-workflow-orchestrator.js +0 -293
  151. package/scripts/plan-gate.js +0 -243
  152. package/scripts/plausible-domain-config.js +0 -99
  153. package/scripts/qa-scenario-planner.js +0 -136
  154. package/scripts/repeat-metric.js +0 -137
  155. package/scripts/secret-fixture-tokens.js +0 -61
  156. package/scripts/secret-redaction.js +0 -166
  157. package/scripts/self-harness-optimizer.js +0 -141
  158. package/scripts/self-healing-check.js +0 -193
  159. package/scripts/silent-failure-cluster.js +0 -531
  160. package/scripts/statusline-cache-read.js +0 -57
  161. package/scripts/sync-telemetry-from-prod.js +0 -374
  162. package/scripts/tool-contract-validator.js +0 -76
  163. package/scripts/trajectory-scorer.js +0 -63
  164. package/scripts/verify-marketing-pages-deployed.js +0 -195
  165. package/scripts/visitor-journey.js +0 -172
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 {
@@ -97,9 +96,6 @@ const {
97
96
  const {
98
97
  recordCheckoutFunnelEvent,
99
98
  } = require('../../scripts/plausible-server-events');
100
- const {
101
- resolvePlausibleDataDomain,
102
- } = require('../../scripts/plausible-domain-config');
103
99
  const {
104
100
  buildCloudflareSandboxPlan,
105
101
  } = require('../../scripts/cloudflare-dynamic-sandbox');
@@ -169,16 +165,7 @@ const {
169
165
  buildReviewSnapshot,
170
166
  readDashboardReviewState,
171
167
  writeDashboardReviewState,
172
- collectAllFeedbackEntries,
173
168
  } = require('../../scripts/dashboard');
174
- const {
175
- collectAggregateLogEntries,
176
- computeAggregateFeedbackStats,
177
- shouldAggregateFeedback,
178
- } = require('../../scripts/feedback-aggregate');
179
- const {
180
- guardDfcxWebhook,
181
- } = require('../../adapters/gcp/dfcx-webhook-gate');
182
169
  const {
183
170
  buildDashboardRenderSpec,
184
171
  } = require('../../scripts/dashboard-render-spec');
@@ -212,12 +199,6 @@ const {
212
199
  } = require('../../scripts/rate-limiter');
213
200
  const { sendProblem, PROBLEM_TYPES } = require('../../scripts/problem-detail');
214
201
  const { TOOLS: MCP_TOOLS } = require('../../scripts/tool-registry');
215
- const mcpOauth = require('../../scripts/mcp-oauth');
216
- // OAuth 2.1 (PKCE) authorization-server state for the remote MCP connector
217
- // (Claude Connectors Directory requires OAuth for authenticated services).
218
- const oauthStore = mcpOauth.createStore();
219
- const pendingOauthAuthorizeRequests = new Map();
220
- const OAUTH_AUTHORIZE_REQUEST_TTL_MS = 10 * 60 * 1000;
221
202
  const resendMailer = require('../../scripts/mailer/resend-mailer');
222
203
  const {
223
204
  buildContextFootprintReport,
@@ -233,24 +214,17 @@ const PRO_PAGE_PATH = path.resolve(__dirname, '../../public/pro.html');
233
214
  const DASHBOARD_PAGE_PATH = path.resolve(__dirname, '../../public/dashboard.html');
234
215
  const LESSONS_PAGE_PATH = path.resolve(__dirname, '../../public/lessons.html');
235
216
  const GUIDE_PAGE_PATH = path.resolve(__dirname, '../../public/guide.html');
236
- const CHATGPT_APP_PAGE_PATH = path.resolve(__dirname, '../../public/chatgpt-app.html');
237
217
  const CODEX_PLUGIN_PAGE_PATH = path.resolve(__dirname, '../../public/codex-plugin.html');
238
218
  const COMPARE_PAGE_PATH = path.resolve(__dirname, '../../public/compare.html');
239
219
  const LEARN_PAGE_PATH = path.resolve(__dirname, '../../public/learn.html');
240
220
  const NUMBERS_PAGE_PATH = path.resolve(__dirname, '../../public/numbers.html');
241
221
  const FEDERAL_PAGE_PATH = path.resolve(__dirname, '../../public/federal.html');
242
- const PRICING_PAGE_PATH = path.resolve(__dirname, '../../public/pricing.html');
243
- const ABOUT_PAGE_PATH = path.resolve(__dirname, '../../public/about.html');
244
222
  const LEARN_DIR = path.resolve(__dirname, '../../public/learn');
245
223
  const GUIDES_DIR = path.resolve(__dirname, '../../public/guides');
246
224
  const COMPARE_DIR = path.resolve(__dirname, '../../public/compare');
247
225
  const USE_CASES_DIR = path.resolve(__dirname, '../../public/use-cases');
248
226
  const PUBLIC_DIR = path.resolve(__dirname, '../../public');
249
227
  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
228
  const BUYER_INTENT_SCRIPT_PATH = path.resolve(__dirname, '../../public/js/buyer-intent.js');
255
229
  const STATIC_MIME_BY_EXT = Object.freeze({
256
230
  '.png': 'image/png',
@@ -369,7 +343,6 @@ function serveStaticFile(res, filePath, { headOnly = false, cacheSeconds = 86400
369
343
  res.setHeader('Content-Type', contentType);
370
344
  res.setHeader('Content-Length', stat.size);
371
345
  res.setHeader('Cache-Control', `public, max-age=${cacheSeconds}, immutable`);
372
- res.setHeader('Referrer-Policy', 'same-origin');
373
346
  if (headOnly) {
374
347
  res.end();
375
348
  return;
@@ -440,75 +413,27 @@ const TRACKED_LINK_TARGETS = Object.freeze({
440
413
  },
441
414
  allowCustomerEmail: true,
442
415
  },
443
- // 2026-06-02: Teams/Aiventyx deprecated. Redirect legacy links to Pro.
416
+ // 2026-05-12: Aiventyx marketplace listing routes its Teams clicks through
417
+ // /go/teams (best-performing listing at ~62% CTR). Without this slug the
418
+ // server returned 404 + "Tracked link not found". Every Aiventyx Teams
419
+ // click between the URL swap and this deploy landed on that error page.
420
+ // Destination: 3-seat Team self-serve Stripe checkout (the path I shipped
421
+ // in PR #1877 — plan_id=team + seat_count=3 = $147/mo entry).
444
422
  teams: {
445
- path: '/go/pro?utm_source=legacy_teams&utm_medium=redirect',
446
- ctaId: 'go_pro',
447
- ctaPlacement: 'link_router',
448
- eventType: 'cta_click',
449
- },
450
- team: {
451
- path: '/go/pro?utm_source=legacy_teams&utm_medium=redirect',
452
- ctaId: 'go_pro',
453
- ctaPlacement: 'link_router',
454
- eventType: 'cta_click',
455
- },
456
- checkout: {
457
423
  path: '/checkout/pro',
458
- ctaId: 'go_checkout',
424
+ ctaId: 'go_teams',
459
425
  ctaPlacement: 'link_router',
460
426
  eventType: 'cta_click',
461
427
  defaults: {
462
428
  utm_source: 'website',
463
429
  utm_medium: 'link_router',
464
- utm_campaign: 'pro_upgrade',
465
- plan_id: 'pro',
430
+ utm_campaign: 'team_self_serve',
431
+ plan_id: 'team',
432
+ seat_count: '3',
466
433
  billing_cycle: 'monthly',
467
434
  },
468
435
  allowCustomerEmail: true,
469
436
  },
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
- trial: {
501
- path: '/guide',
502
- ctaId: 'go_trial',
503
- ctaPlacement: 'link_router',
504
- eventType: 'trial_start_click',
505
- defaults: {
506
- utm_source: 'website',
507
- utm_medium: 'link_router',
508
- utm_campaign: 'trial_start',
509
- plan_id: 'free_trial',
510
- },
511
- },
512
437
  install: {
513
438
  path: '/guide',
514
439
  ctaId: 'go_install',
@@ -723,51 +648,6 @@ function resolveRequestProjectDir(req, parsed) {
723
648
  });
724
649
  }
725
650
 
726
- function debugApiFallback(label, error) {
727
- if (process.env.THUMBGATE_DEBUG_API !== '1') return;
728
- console.warn(`[api] ${label}: ${error?.message || String(error)}`);
729
- }
730
-
731
- function stripEnvQuotes(value) {
732
- return String(value || '').trim().replace(/^["']|["']$/g, '');
733
- }
734
-
735
- function extractEnvValue(content, names) {
736
- const pattern = new RegExp(`^(?:${names.join('|')})=(.*)$`, 'm');
737
- const match = pattern.exec(content);
738
- return match ? stripEnvQuotes(match[1]) : '';
739
- }
740
-
741
- function readProjectChatSettings(req, parsed) {
742
- const settings = {
743
- geminiKey: '',
744
- perplexityKey: '',
745
- localEndpoint: '',
746
- localModel: '',
747
- geminiValidatedAt: null,
748
- };
749
- try {
750
- const projectDir = resolveRequestProjectDir(req, parsed);
751
- const envPath = path.join(projectDir, '.env');
752
- if (fs.existsSync(envPath)) {
753
- const content = fs.readFileSync(envPath, 'utf8');
754
- settings.geminiKey = extractEnvValue(content, ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'THUMBGATE_GEMINI_API_KEY']);
755
- settings.perplexityKey = extractEnvValue(content, ['PERPLEXITY_API_KEY', 'THUMBGATE_PERPLEXITY_API_KEY']);
756
- settings.localEndpoint = extractEnvValue(content, ['THUMBGATE_LOCAL_LLM_ENDPOINT']);
757
- settings.localModel = extractEnvValue(content, ['THUMBGATE_LOCAL_LLM_MODEL']);
758
- }
759
-
760
- const statusPath = path.join(projectDir, '.gemini-validated.json');
761
- if (fs.existsSync(statusPath)) {
762
- const status = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
763
- settings.geminiValidatedAt = status.validatedAt || null;
764
- }
765
- } catch (error) {
766
- debugApiFallback('project chat settings unavailable', error);
767
- }
768
- return settings;
769
- }
770
-
771
651
  function shouldPreferProjectScopedFeedback(req, parsed) {
772
652
  const explicitProject = getEffectiveRequestedProjectSelection(req, parsed);
773
653
  if (explicitProject) return true;
@@ -833,23 +713,16 @@ function updateLessonRecord(feedbackDir, lessonId, updater) {
833
713
  function getPublicMcpTools() {
834
714
  return MCP_TOOLS.map((tool) => ({
835
715
  name: tool.name,
836
- ...(tool.title ? { title: tool.title } : {}),
837
716
  description: tool.description,
838
717
  inputSchema: tool.inputSchema,
839
- // Serve the tool-registry annotations (readOnlyHint/destructiveHint). Required
840
- // by the Claude Connectors Directory (missing annotations = the #1 rejection
841
- // cause) and used by MCP clients for permission prompts. Was being dropped here.
842
- ...(tool.annotations ? { annotations: tool.annotations } : {}),
843
718
  }));
844
719
  }
845
720
 
846
721
  function getServerCardTools() {
847
722
  return MCP_TOOLS.map((tool) => ({
848
723
  name: tool.name,
849
- ...(tool.title ? { title: tool.title } : {}),
850
724
  description: tool.description,
851
725
  inputSchema: tool.inputSchema,
852
- ...(tool.annotations ? { annotations: tool.annotations } : {}),
853
726
  }));
854
727
  }
855
728
 
@@ -889,7 +762,7 @@ function getMcpSkillManifests(hostedConfig) {
889
762
  'Inspect prevention_rules after repeats.',
890
763
  ],
891
764
  installCommand: 'npx thumbgate init',
892
- contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
765
+ contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
893
766
  proofUrl: VERIFICATION_EVIDENCE_URL,
894
767
  },
895
768
  {
@@ -916,7 +789,7 @@ function getMcpSkillManifests(hostedConfig) {
916
789
  'Evaluate NDCG@10 on visual hard negatives.',
917
790
  'Require artifact links before using retrieved evidence in claims.',
918
791
  ],
919
- contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
792
+ contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
920
793
  proofUrl: VERIFICATION_EVIDENCE_URL,
921
794
  },
922
795
  {
@@ -930,7 +803,7 @@ function getMcpSkillManifests(hostedConfig) {
930
803
  'Compact feedback context with anchors for proof-critical lessons.',
931
804
  'Record estimated token savings next to the workflow evidence.',
932
805
  ],
933
- contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
806
+ contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
934
807
  footprintUrl: buildPublicUrl(hostedConfig, '/.well-known/mcp/footprint.json'),
935
808
  proofUrl: VERIFICATION_EVIDENCE_URL,
936
809
  },
@@ -945,7 +818,7 @@ function getMcpSkillManifests(hostedConfig) {
945
818
  'Require baseline evals before adding autonomy or subagents.',
946
819
  'Classify tool risk before allowing writes, money movement, production changes, or outbound actions.',
947
820
  ],
948
- contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
821
+ contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
949
822
  proofUrl: VERIFICATION_EVIDENCE_URL,
950
823
  },
951
824
  {
@@ -959,7 +832,7 @@ function getMcpSkillManifests(hostedConfig) {
959
832
  'Grade goal inference separately from intervention timing.',
960
833
  'Block multi-app proactive writes until rollback and orchestration evidence exists.',
961
834
  ],
962
- contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
835
+ contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
963
836
  proofUrl: VERIFICATION_EVIDENCE_URL,
964
837
  },
965
838
  {
@@ -973,7 +846,7 @@ function getMcpSkillManifests(hostedConfig) {
973
846
  'Map every proxy metric to the real user objective.',
974
847
  'Require holdout or regression proof before treating benchmark gains as product gains.',
975
848
  ],
976
- contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
849
+ contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
977
850
  proofUrl: VERIFICATION_EVIDENCE_URL,
978
851
  },
979
852
  {
@@ -987,7 +860,7 @@ function getMcpSkillManifests(hostedConfig) {
987
860
  'Reproduce locally before claiming a fix.',
988
861
  'Open one focused PR with tests, proof, and transparent ThumbGate context only when relevant.',
989
862
  ],
990
- contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
863
+ contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
991
864
  proofUrl: VERIFICATION_EVIDENCE_URL,
992
865
  },
993
866
  {
@@ -1001,7 +874,7 @@ function getMcpSkillManifests(hostedConfig) {
1001
874
  'Route self-serve intent to the guide and team pain to Workflow Hardening Sprint intake.',
1002
875
  'Block unsupported ad and landing-page claims before spend scales.',
1003
876
  ],
1004
- contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
877
+ contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
1005
878
  proofUrl: VERIFICATION_EVIDENCE_URL,
1006
879
  },
1007
880
  ];
@@ -1135,7 +1008,7 @@ function getMcpDiscoveryManifest(hostedConfig) {
1135
1008
  footprint: getContextFootprintReport(hostedConfig),
1136
1009
  proof: {
1137
1010
  verificationEvidenceUrl: VERIFICATION_EVIDENCE_URL,
1138
- llmContextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
1011
+ llmContextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
1139
1012
  },
1140
1013
  };
1141
1014
  }
@@ -1545,450 +1418,8 @@ async function loadLiveDashboardDataOrRespondProblem(res, parsed, feedbackDir, i
1545
1418
  }
1546
1419
  }
1547
1420
 
1548
- function buildEnterpriseDataChatStatus(env = process.env) {
1549
- const vertexProject = normalizeNullableText(env.VERTEX_PROJECT_ID)
1550
- || normalizeNullableText(env.GOOGLE_VERTEX_PROJECT);
1551
- const vertexLocation = normalizeNullableText(env.GOOGLE_VERTEX_LOCATION)
1552
- || normalizeNullableText(env.VERTEX_LOCATION)
1553
- || 'us-central1';
1554
- const dfcxFulfillmentUrl = normalizeNullableText(env.THUMBGATE_DFCX_FULFILLMENT_URL);
1555
- const dfcxAgentId = normalizeNullableText(env.THUMBGATE_DFCX_AGENT_ID);
1556
- const dfcxLocation = normalizeNullableText(env.THUMBGATE_DFCX_LOCATION);
1557
-
1558
- return {
1559
- mode: 'local-dashboard',
1560
- vertex: {
1561
- configured: Boolean(vertexProject),
1562
- projectId: vertexProject,
1563
- location: vertexLocation,
1564
- providerMode: normalizeNullableText(env.THUMBGATE_PROVIDER_MODE) || null,
1565
- },
1566
- dfcx: {
1567
- apiSurface: 'Dialogflow CX REST API: projects.locations.agents',
1568
- liveAgentConfigured: Boolean(dfcxAgentId && dfcxLocation),
1569
- agentId: dfcxAgentId,
1570
- location: dfcxLocation,
1571
- fulfillmentProxyConfigured: Boolean(dfcxFulfillmentUrl),
1572
- fulfillmentUrlConfigured: Boolean(dfcxFulfillmentUrl),
1573
- gcloudCxCommandSupported: false,
1574
- verification: dfcxAgentId && dfcxLocation
1575
- ? 'Agent metadata is present in env; verify via REST/console before production claims.'
1576
- : 'No live DFCX agent env configured. Use REST/console/deployed webhook evidence before claiming a live agent.',
1577
- },
1578
- chat: {
1579
- available: true,
1580
- source: 'local ThumbGate dashboard data with LanceDB-backed retrieval',
1581
- guard: 'local data-access guard; DFCX adapter optional for customer Dialogflow deployments',
1582
- providerRequired: false,
1583
- localLlmEndpointConfigured: Boolean(normalizeNullableText(env.THUMBGATE_LOCAL_LLM_ENDPOINT)),
1584
- },
1585
- };
1586
- }
1587
-
1588
- const buildEnterpriseDialogflowStatus = buildEnterpriseDataChatStatus;
1589
-
1590
- function normalizeEnterpriseChatPrompt(value) {
1591
- const text = normalizeNullableText(value);
1592
- if (!text) return null;
1593
- return text.slice(0, 800);
1594
- }
1595
-
1596
- function classifyEnterpriseChatTopic(prompt) {
1597
- const lower = String(prompt || '').toLowerCase();
1598
- // Feedback-specific words run FIRST: "what mistakes were blocked today" is a
1599
- // feedback question, not a gates question — must not be hijacked by /block/.
1600
- if (/mistake|lesson|memory|feedback|thumb|negative|positive|what (?:went )?wrong|fail|win|success|worked|good/.test(lower)) return 'feedback';
1601
- if (/gate|block|deny|prevent|guard|enforce/.test(lower)) return 'gates';
1602
- if (/team|agent|org|enterprise|rollout/.test(lower)) return 'team';
1603
- if (/token|cost|saving|budget|spend/.test(lower)) return 'cost';
1604
- if (/vertex|gcp|google|dialogflow|dfcx|cloud/.test(lower)) return 'cloud';
1605
- return 'overview';
1606
- }
1607
-
1608
- // Parse intent: LIST vs COUNT, and time window. Lets us answer "what mistakes
1609
- // today?" with an actual filtered list instead of a canned total.
1610
- function parseChatIntent(prompt) {
1611
- const lower = String(prompt || '').toLowerCase();
1612
- const terms = new Set(lower.split(/[^a-z0-9]+/).filter(Boolean));
1613
- const hasTerm = (term) => terms.has(term);
1614
- const wantsList = lower.includes('tell me about') ||
1615
- ['what', 'which', 'list', 'show', 'example', 'examples'].some(hasTerm);
1616
- let windowMs = null;
1617
- let windowLabel = 'across all time';
1618
- if (/\btoday\b/.test(lower)) { windowMs = 24 * 60 * 60 * 1000; windowLabel = 'today'; }
1619
- else if (/\byesterday\b/.test(lower)) { windowMs = 48 * 60 * 60 * 1000; windowLabel = 'yesterday'; }
1620
- else if (/\bthis week\b|\b7 ?d(ay)?s?\b|\blast week\b/.test(lower)) { windowMs = 7 * 24 * 60 * 60 * 1000; windowLabel = 'the last 7 days'; }
1621
- else if (/\bthis month\b|\b30 ?d(ay)?s?\b|\blast month\b/.test(lower)) { windowMs = 30 * 24 * 60 * 60 * 1000; windowLabel = 'the last 30 days'; }
1622
- return { wantsList, windowMs, windowLabel };
1623
- }
1624
-
1625
- // Read recent feedback entries (signal-filtered, time-filtered) directly from
1626
- // the feedback log. Bounded + best-effort — never throws into the chat handler.
1627
- // Treat short/placeholder context values as "no real description" so we don't
1628
- // surface `"thumbs down"` × 3 as a useful list. Real feedback always has a
1629
- // concrete sentence somewhere (whatWentWrong, distillation, whatToChange) —
1630
- // pick the longest informative one.
1631
- // Short tokens that mean "no real description" — kept as a plain Set, not a
1632
- // big regex, to keep complexity low and easy to extend.
1633
- const PLACEHOLDER_TOKENS = new Set([
1634
- 'thumbs down', 'thumbs up', 'thumb down', 'thumb up',
1635
- 'good', 'bad', 'ok', 'nice', 'verify', 'verifies', 'verification', 'test', 'testing',
1636
- ]);
1637
-
1638
- function isPlaceholder(text) {
1639
- const t = String(text || '').trim();
1640
- if (!t || t.length < 20) return true;
1641
- return PLACEHOLDER_TOKENS.has(t.toLowerCase().replace(/\.$/, ''));
1642
- }
1643
-
1644
- function bestFeedbackDescription(row) {
1645
- const candidates = [row.whatWentWrong, row.distillation, row.context, row.whatToChange, row.whatWorked, row.reasoning]
1646
- .map((c) => String(c || '').trim());
1647
- // Prefer the longest non-placeholder candidate; fall back to any non-empty.
1648
- const informative = candidates.filter((c) => c && !isPlaceholder(c));
1649
- const fallback = candidates.find(Boolean) || '';
1650
- const best = informative.reduce((a, b) => (b.length > a.length ? b : a), '') || fallback;
1651
- return best.slice(0, 220);
1652
- }
1653
-
1654
- function readRecentFeedbackEntries(feedbackDir, signal, windowMs, limit = 5, opts = {}) {
1655
- try {
1656
- if (!feedbackDir) return [];
1657
- const rows = shouldAggregateFeedback()
1658
- ? collectAggregateLogEntries('feedback-log.jsonl', { feedbackDir }).entries
1659
- : (() => {
1660
- const fsLocal = require('node:fs');
1661
- const pathLocal = require('node:path');
1662
- const logPath = pathLocal.join(feedbackDir, 'feedback-log.jsonl');
1663
- if (!fsLocal.existsSync(logPath)) return [];
1664
- const { readJsonl } = require('../../scripts/fs-utils');
1665
- return readJsonl(logPath) || [];
1666
- })();
1667
- const cutoff = windowMs ? Date.now() - windowMs : 0;
1668
- const filtered = rows
1669
- .filter((r) => !signal || r.signal === signal)
1670
- .filter((r) => {
1671
- if (!cutoff) return true;
1672
- const t = r.timestamp ? Date.parse(r.timestamp) : Number.NaN;
1673
- return Number.isFinite(t) && t >= cutoff;
1674
- })
1675
- .reverse()
1676
- .map((r) => {
1677
- const out = {
1678
- timestamp: r.timestamp,
1679
- context: bestFeedbackDescription(r),
1680
- tags: Array.isArray(r.tags) ? r.tags : []
1681
- };
1682
- if (opts.includeSignal) out.signal = r.signal;
1683
- return out;
1684
- });
1685
- // For list display, drop entries with no real description so the list is useful,
1686
- // not three "thumbs down" placeholders. For count-only (high limit), keep all.
1687
- const useful = opts.skipPlaceholders === false || limit > 50
1688
- ? filtered
1689
- : filtered.filter((e) => e.context && !isPlaceholder(e.context));
1690
- return useful.slice(0, limit);
1691
- } catch {
1692
- return [];
1693
- }
1694
- }
1695
-
1696
- function containsUnsafeEnterpriseChatInput(prompt) {
1697
- return /[;&|`$<>\\]/.test(String(prompt || ''));
1698
- }
1699
-
1700
- function compactNumber(value) {
1701
- const n = Number(value || 0);
1702
- return Number.isFinite(n) ? n : 0;
1703
- }
1704
-
1705
- // Pick a signal preference from the prompt. Returns 'negative' | 'positive' | null.
1706
- function detectFeedbackSignalFromPrompt(prompt) {
1707
- const lower = String(prompt || '').toLowerCase();
1708
- const wantsNeg = /mistake|wrong|fail|negative|thumbs? *down|block/.test(lower);
1709
- const wantsPos = /positive|thumbs? *up|worked|success|wins?\b|good/.test(lower);
1710
- if (wantsNeg && !wantsPos) return 'negative';
1711
- if (wantsPos && !wantsNeg) return 'positive';
1712
- return null;
1713
- }
1714
-
1715
- const FEEDBACK_LIST_LABELS = Object.freeze({
1716
- negative: 'Recent mistakes',
1717
- positive: 'Recent wins',
1718
- });
1719
-
1720
- const FEEDBACK_OMITTED_TAGS = Object.freeze(['audit-trail', 'auto-capture']);
1721
-
1722
- function formatChatTimestamp(isoString) {
1723
- if (!isoString) return 'unknown time';
1724
- try {
1725
- const d = new Date(isoString);
1726
- if (Number.isNaN(d.getTime())) return isoString;
1727
- const pad = (n) => String(n).padStart(2, '0');
1728
- const yyyy = d.getFullYear();
1729
- const mm = pad(d.getMonth() + 1);
1730
- const dd = pad(d.getDate());
1731
- const hh = pad(d.getHours());
1732
- const min = pad(d.getMinutes());
1733
- const ss = pad(d.getSeconds());
1734
- return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}`;
1735
- } catch {
1736
- return isoString;
1737
- }
1738
- }
1739
-
1740
- function buildFeedbackEntries(windowed, signal) {
1741
- return (signal ? windowed.filter((r) => r.signal === signal) : windowed)
1742
- .filter((r) => r.context && !isPlaceholder(r.context))
1743
- .slice(0, 5);
1744
- }
1745
-
1746
- function formatFeedbackEntry(entry) {
1747
- const tsFormatted = formatChatTimestamp(entry.timestamp);
1748
- const signalLabel = entry.signal ? ` [${entry.signal}]` : '';
1749
- const tagsList = Array.isArray(entry.tags)
1750
- ? entry.tags.filter((tag) => !FEEDBACK_OMITTED_TAGS.includes(tag))
1751
- : [];
1752
- const tagsStr = tagsList.length ? ` (${tagsList.join(', ')})` : '';
1753
- return ` • ${tsFormatted}${signalLabel}${tagsStr} — ${entry.context}`;
1754
- }
1755
-
1756
- function appendFeedbackListLines(lines, { entries, signal, intent }) {
1757
- if (!entries.length) {
1758
- lines.push(`No ${signal || 'feedback'} entries found ${intent.windowLabel}.`);
1759
- return;
1760
- }
1761
- lines.push(`${FEEDBACK_LIST_LABELS[signal] || 'Recent feedback'} (${intent.windowLabel}):`);
1762
- lines.push(...entries.map(formatFeedbackEntry));
1763
- }
1764
-
1765
- function buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeline }) {
1766
- const signal = detectFeedbackSignalFromPrompt(ctx.prompt);
1767
- // One read of the time-windowed log, then in-memory counts + (signal-filtered,
1768
- // placeholder-stripped) list. Counts include ALL entries (so "Feedback today: 5"
1769
- // matches the dashboard tile); the list drops vague entries like literal "thumbs
1770
- // down" / "good" so it surfaces only entries with real, actionable description.
1771
- const windowed = readRecentFeedbackEntries(feedbackDir, null, intent.windowMs, 10000, { includeSignal: true });
1772
- const windowPos = windowed.filter((r) => r.signal === 'positive').length;
1773
- const windowNeg = windowed.filter((r) => r.signal === 'negative').length;
1774
- const entries = buildFeedbackEntries(windowed, signal);
1775
-
1776
- const lines = [
1777
- intent.windowMs
1778
- ? `Feedback ${intent.windowLabel}: ${windowed.length} (${windowPos} positive, ${windowNeg} negative).`
1779
- : `Feedback total: ${compactNumber(approval.total)} (${compactNumber(approval.positive)} positive, ${compactNumber(approval.negative)} negative).`,
1780
- ];
1781
-
1782
- if (intent.wantsList) {
1783
- appendFeedbackListLines(lines, { entries, signal, intent });
1784
- } else {
1785
- lines.push(`Lesson pipeline: ${compactNumber(lessonPipeline.lessons || lessonPipeline.generated || 0)} lessons visible in the current dashboard snapshot.`);
1786
- }
1787
- return { lines, sources: ['feedback log', intent.wantsList ? 'feedback contexts' : 'lesson pipeline'] };
1788
- }
1789
-
1790
- const GATE_EVENTS_REGEX = /activat|fired|trigger|block|denied|prevent|enforce|hit/;
1791
-
1792
- function describeGate(g) {
1793
- return `${g.name || g.id || 'unnamed'}${g.severity ? ' [' + g.severity + ']' : ''}`;
1794
- }
1795
-
1796
- function buildGatesSection({ ctx, intent, gates, gateStats }) {
1797
- const asksEvents = GATE_EVENTS_REGEX.test(String(ctx.prompt || '').toLowerCase());
1798
- const totalActive = gates.length || compactNumber(gateStats.totalGates);
1799
- const totalBlocked = compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked);
1800
-
1801
- const lines = [];
1802
- if (asksEvents) {
1803
- lines.push(`Blocked actions recorded (all time): ${totalBlocked}.`);
1804
- if (intent.windowMs) {
1805
- lines.push(`(Per-${intent.windowLabel} block-event breakdown isn't tracked in the local dashboard snapshot — only the running total. Filter the gate-events log directly for a precise window.)`);
1806
- }
1807
- } else {
1808
- lines.push(`Active gates: ${totalActive}.`);
1809
- }
1810
- if (intent.wantsList && gates.length) {
1811
- lines.push('Active gates:');
1812
- for (const g of gates.slice(0, 8)) lines.push(` • ${describeGate(g)}`);
1813
- } else if (gates[0] && !intent.wantsList) {
1814
- lines.push(`Example gate: ${describeGate(gates[0])}.`);
1815
- }
1816
- return { lines, sources: ['gate stats'] };
1817
- }
1818
-
1819
- function buildEnterpriseChatSection(topic, dashboardData, status, ctx = {}) {
1820
- const approval = dashboardData.approval || {};
1821
- const gates = Array.isArray(dashboardData.gates) ? dashboardData.gates : [];
1822
- const gateStats = dashboardData.gateStats || {};
1823
- const team = dashboardData.team || {};
1824
- const tokenSavings = dashboardData.tokenSavings || {};
1825
- const lessonPipeline = dashboardData.lessonPipeline || {};
1826
- const intent = ctx.intent || { wantsList: false, windowMs: null, windowLabel: 'across all time' };
1827
- const feedbackDir = ctx.feedbackDir || null;
1828
-
1829
- if (topic === 'feedback') {
1830
- return buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeline });
1831
- }
1832
- if (topic === 'gates') {
1833
- return buildGatesSection({ ctx, intent, gates, gateStats });
1834
- }
1835
- if (topic === 'team') {
1836
- return {
1837
- lines: [
1838
- 'Team dashboard is available in this local Enterprise view.',
1839
- `Tracked agents: ${compactNumber(team.totalAgents || team.agentCount || 0)}; risky agents: ${compactNumber(team.riskyAgents || team.highRiskAgents || 0)}.`,
1840
- ],
1841
- sources: ['team dashboard'],
1842
- };
1843
- }
1844
- if (topic === 'cost') {
1845
- return {
1846
- lines: [
1847
- `Estimated token savings: ${tokenSavings.dollarsSavedDisplay || '$0.00'} from ${compactNumber(tokenSavings.blockedCalls)} blocked calls.`,
1848
- 'Google Cloud budget alerts are evidence for spend visibility; ThumbGate-side stop conditions must be verified separately before calling them a hard cap.',
1849
- ],
1850
- sources: ['token savings', 'budget posture'],
1851
- };
1852
- }
1853
- if (topic === 'cloud') {
1854
- const vertexLine = status.vertex.configured
1855
- ? `Vertex routing config is present for project ${status.vertex.projectId} (${status.vertex.location}).`
1856
- : 'Vertex routing config is not present in this server environment.';
1857
- const dfcxLine = status.dfcx.liveAgentConfigured
1858
- ? `DFCX env has agent ${status.dfcx.agentId} in ${status.dfcx.location}; verify it with REST/console before production claims.`
1859
- : 'No live DFCX agent is configured in env. Do not use the old alpha gcloud CX command group; verify agents with the Dialogflow CX REST API or console.';
1860
- return {
1861
- lines: [vertexLine, dfcxLine],
1862
- sources: ['enterprise cloud status'],
1863
- };
1864
- }
1865
- return {
1866
- lines: [
1867
- 'Ask about feedback, lessons, active gates, team rollout, token savings, or Vertex/DFCX readiness.',
1868
- `Current local snapshot: ${compactNumber(approval.total)} feedback events and ${gates.length || compactNumber(gateStats.totalGates)} active gates.`,
1869
- ],
1870
- sources: [],
1871
- };
1872
- }
1873
-
1874
- function buildEnterpriseChatAnswer(prompt, dashboardData, status, opts = {}) {
1875
- const topic = classifyEnterpriseChatTopic(prompt);
1876
- const intent = parseChatIntent(prompt);
1877
- const section = buildEnterpriseChatSection(topic, dashboardData, status, {
1878
- intent,
1879
- feedbackDir: opts.feedbackDir,
1880
- prompt,
1881
- });
1882
-
1883
- // List-style answers want newlines; single-line answers join with space.
1884
- const hasList = section.lines.some((l) => /^\s*•/.test(l));
1885
- const answer = hasList ? section.lines.join('\n') : section.lines.join(' ');
1886
-
1887
- return {
1888
- topic,
1889
- answer,
1890
- sources: ['local dashboard data', ...section.sources],
1891
- };
1892
- }
1893
-
1894
- // Answer the dashboard "Chat with your data" panel LOCALLY (deterministic, no
1895
- // cloud/LLM) from this install's own per-project dashboard data. Returns true if
1896
- // it sent a response; false if the local snapshot was unavailable (caller then
1897
- // falls through). Shared by the local-first path and the no-model fallback.
1898
- async function trySendLocalDashboardChat(res, parsed, feedbackDir, prompt, suffix) {
1899
- try {
1900
- const dashboardResult = await buildLiveDashboardData(parsed, feedbackDir);
1901
- const localChat = buildEnterpriseChatAnswer(
1902
- prompt,
1903
- dashboardResult.data,
1904
- buildEnterpriseDataChatStatus(),
1905
- { feedbackDir },
1906
- );
1907
- sendJson(res, 200, {
1908
- ok: true,
1909
- answer: suffix ? `${localChat.answer} ${suffix}` : localChat.answer,
1910
- sources: (localChat.sources || []).map((title) => ({ title })),
1911
- topic: localChat.topic,
1912
- provider: 'local-data',
1913
- llm: 'none',
1914
- grounded: true,
1915
- });
1916
- return true;
1917
- } catch {
1918
- return false;
1919
- }
1920
- }
1921
-
1922
- async function answerEnterpriseDataChat({ prompt, feedbackDir, parsed }) {
1923
- const normalizedPrompt = normalizeEnterpriseChatPrompt(prompt);
1924
- if (!normalizedPrompt) {
1925
- throw createHttpError(400, 'prompt is required');
1926
- }
1927
- const status = buildEnterpriseDataChatStatus();
1928
- if (containsUnsafeEnterpriseChatInput(normalizedPrompt)) {
1929
- return {
1930
- ok: false,
1931
- blocked: true,
1932
- answer: 'This prompt contains unsafe control characters and was blocked before data access.',
1933
- status,
1934
- dfcx: {
1935
- blocked: true,
1936
- evaluation: {
1937
- allowed: false,
1938
- gate: 'enterprise-chat-unsafe-input',
1939
- severity: 'critical',
1940
- },
1941
- },
1942
- sources: ['enterprise input guard'],
1943
- };
1944
- }
1945
-
1946
- const dashboardResult = await buildLiveDashboardData(parsed, feedbackDir);
1947
- const dashboardData = dashboardResult.data;
1948
-
1949
- // This guarded stats endpoint stays deterministic for API compatibility.
1950
- // The dashboard's real local/open-source chatbot turn goes through /v1/chat,
1951
- // which uses lesson retrieval + optional LanceDB vector search + the user's
1952
- // configured local or BYO model.
1953
- const chat = buildEnterpriseChatAnswer(normalizedPrompt, dashboardData, status, { feedbackDir });
1954
- const dfcxRequest = {
1955
- fulfillmentInfo: { tag: 'chat-with-data' },
1956
- sessionInfo: {
1957
- session: 'local-dashboard/enterprise-chat',
1958
- parameters: {
1959
- topic: chat.topic,
1960
- prompt_key: normalizedPrompt.toLowerCase().replace(/[^a-z0-9._ -]/g, '').slice(0, 64),
1961
- },
1962
- },
1963
- languageCode: 'en',
1964
- };
1965
- const guarded = await guardDfcxWebhook(
1966
- dfcxRequest,
1967
- async () => ({
1968
- fulfillment_response: { messages: [{ text: { text: [chat.answer] } }] },
1969
- session_info: { parameters: { thumbgate_topic: chat.topic } },
1970
- }),
1971
- { blockOnRepeat: false },
1972
- );
1973
-
1974
- return {
1975
- ok: !guarded.blocked,
1976
- blocked: Boolean(guarded.blocked),
1977
- answer: guarded.blocked ? 'ThumbGate blocked this enterprise chat turn before data access.' : chat.answer,
1978
- status,
1979
- dfcx: {
1980
- blocked: Boolean(guarded.blocked),
1981
- evaluation: guarded.evaluation,
1982
- response: guarded.response,
1983
- },
1984
- sources: chat.sources,
1985
- };
1986
- }
1987
-
1988
- const answerEnterpriseDialogflowChat = answerEnterpriseDataChat;
1989
-
1990
1421
  function buildLossAnalyticsResponse(data, summaryOptions) {
1991
- return sanitizeHtmlUnsafeJsonValue({
1422
+ return {
1992
1423
  window: data.analytics.window || summaryOptions,
1993
1424
  lossAnalysis: data.analytics.lossAnalysis || null,
1994
1425
  buyerLoss: data.analytics.buyerLoss || null,
@@ -2000,50 +1431,13 @@ function buildLossAnalyticsResponse(data, summaryOptions) {
2000
1431
  ctas: data.analytics.telemetry && data.analytics.telemetry.ctas,
2001
1432
  visitors: data.analytics.telemetry && data.analytics.telemetry.visitors,
2002
1433
  },
2003
- });
1434
+ };
2004
1435
  }
2005
1436
 
2006
1437
  function createJourneyId(prefix) {
2007
1438
  return createTraceId(prefix).replace(/^trace_/, `${prefix}_`);
2008
1439
  }
2009
1440
 
2010
- function normalizeCheckoutInterstitialSampleRate(value) {
2011
- const parsed = Number.parseFloat(String(value || '').trim());
2012
- if (!Number.isFinite(parsed) || parsed <= 0) {
2013
- return 0;
2014
- }
2015
- if (parsed > 1) {
2016
- return Math.min(parsed / 100, 1);
2017
- }
2018
- return Math.min(parsed, 1);
2019
- }
2020
-
2021
- function stableUnitInterval(value) {
2022
- const text = String(value || '');
2023
- let hash = 2166136261;
2024
- for (const character of text) {
2025
- hash ^= character.codePointAt(0);
2026
- hash = Math.imul(hash, 16777619);
2027
- }
2028
- return (hash >>> 0) / 4294967296;
2029
- }
2030
-
2031
- function shouldSampleCheckoutInterstitial({ sampleRate, traceId, analyticsMetadata }) {
2032
- if (sampleRate <= 0) {
2033
- return false;
2034
- }
2035
- if (sampleRate >= 1) {
2036
- return true;
2037
- }
2038
- const seed = [
2039
- analyticsMetadata?.visitorId,
2040
- analyticsMetadata?.sessionId,
2041
- analyticsMetadata?.acquisitionId,
2042
- traceId,
2043
- ].filter(Boolean).join(':');
2044
- return stableUnitInterval(seed || traceId) < sampleRate;
2045
- }
2046
-
2047
1441
  function appendQueryParam(url, key, value) {
2048
1442
  const normalized = normalizeNullableText(value);
2049
1443
  if (normalized) {
@@ -2103,287 +1497,55 @@ function buildCheckoutIntentHref(baseUrl, metadata = {}, overrides = {}) {
2103
1497
  });
2104
1498
  }
2105
1499
 
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('');
1500
+ function renderCheckoutIntentPage({
1501
+ confirmHref,
1502
+ workflowIntakeHref,
1503
+ }) {
1504
+ const safeConfirmHref = escapeHtmlAttribute(confirmHref);
1505
+ const safeWorkflowIntakeHref = escapeHtmlAttribute(workflowIntakeHref);
1506
+ return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Confirm — ThumbGate Pro</title><style>body{background:#0a0a0a;color:#eee;font-family:system-ui,-apple-system,sans-serif;line-height:1.5}main{max-width:520px;margin:8vh auto;padding:0 20px}.brand{display:flex;align-items:center;gap:10px;margin-bottom:24px;font-size:14px;color:#94a3b8}.brand-mark{width:24px;height:24px;background:#22d3ee;border-radius:6px;display:inline-block}h1{font-size:24px;margin:0 0 8px;color:#fff}.price{font-size:32px;font-weight:700;color:#22d3ee;margin:8px 0 4px}.price small{font-size:14px;color:#94a3b8;font-weight:400}p{color:#cbd5e1;margin:8px 0}a{display:block;text-decoration:none}a.primary{background:#22d3ee;color:#000;padding:16px;text-align:center;border-radius:8px;font-weight:700;font-size:16px;margin:20px 0 10px}a.secondary{border:1px solid #374151;color:#cbd5e1;padding:12px;text-align:center;border-radius:8px;margin:8px 0 0;font-size:14px}.trust{margin:24px 0;padding:16px;border:1px solid #1f2937;border-radius:8px;background:#0f172a}.trust-item{font-size:13px;color:#cbd5e1;padding:4px 0;display:flex;gap:8px}.trust-item::before{content:"✓";color:#22d3ee;font-weight:700}.choice-note{font-size:13px;color:#94a3b8;margin-top:14px}.back{text-align:center;color:#64748b;font-size:12px;margin-top:24px}.back a{color:#64748b;display:inline}</style><main><div class="brand"><span class="brand-mark"></span><span>ThumbGate</span></div><h1>Start ThumbGate Pro</h1><div class="price">$19<small>/mo</small></div><p>The npm package runs your gates locally. <strong>Pro</strong> is what keeps them working across every machine, every agent runtime, and every breaking-change week.</p><a class="primary" data-i="pro_checkout_confirmed" rel="nofollow noindex" href="${safeConfirmHref}">Pay $19/mo with Stripe →</a><a class="secondary" data-i="workflow_sprint_intake" href="${safeWorkflowIntakeHref}">Not sure yet? Send the workflow first</a><p class="choice-note">Cancel anytime. 7-day refund, no questions. Diagnostics and sprints have their own pages.</p><div class="trust"><div class="trust-item">Lessons synced across all your machines — no local SQLite to babysit</div><div class="trust-item">Adapter matrix kept current for Claude Code, Cursor, Codex, Gemini, Amp, Cline, OpenCode — version drift is our problem, not yours</div><div class="trust-item">Hosted dashboard: gate stats, DPO export, org-wide rule library</div><div class="trust-item">24×7 ops on the rule engine — SonarCloud regressions fixed in &lt;24h</div></div><p class="back"><a href="/">← Back to thumbgate.ai</a></p></main><script>addEventListener('click',e=>{let a=e.target.closest('[data-i]');if(a&&navigator.sendBeacon)navigator.sendBeacon('/v1/telemetry/ping',new Blob([JSON.stringify({eventType:'checkout_interstitial_cta_clicked',clientType:'web',page:'/checkout/pro',ctaId:a.dataset.i,ctaPlacement:'checkout_interstitial'})],{type:'application/json'}))})</script></html>`;
2150
1507
  }
2151
1508
 
2152
- function prunePendingOauthAuthorizeRequests(now = Date.now()) {
2153
- for (const [token, entry] of pendingOauthAuthorizeRequests.entries()) {
2154
- if (!entry || entry.expiresAt <= now) {
2155
- pendingOauthAuthorizeRequests.delete(token);
2156
- }
2157
- }
2158
- }
2159
-
2160
- function createPendingOauthAuthorizeRequest(params, now = Date.now()) {
2161
- prunePendingOauthAuthorizeRequests(now);
2162
- const token = crypto.randomBytes(32).toString('base64url');
2163
- pendingOauthAuthorizeRequests.set(token, {
2164
- params,
2165
- expiresAt: now + OAUTH_AUTHORIZE_REQUEST_TTL_MS,
2166
- });
2167
- return token;
2168
- }
2169
-
2170
- function consumePendingOauthAuthorizeRequest(token, now = Date.now()) {
2171
- if (!token) return null;
2172
- prunePendingOauthAuthorizeRequests(now);
2173
- const entry = pendingOauthAuthorizeRequests.get(token);
2174
- if (!entry) return null;
2175
- pendingOauthAuthorizeRequests.delete(token);
2176
- return entry.params;
2177
- }
2178
-
2179
- function getOauthAuthorizeParamsFromQuery(searchParams) {
2180
- return {
2181
- clientId: searchParams.get('client_id') || '',
2182
- redirectUri: searchParams.get('redirect_uri') || '',
2183
- codeChallenge: searchParams.get('code_challenge') || '',
2184
- codeChallengeMethod: searchParams.get('code_challenge_method') || '',
2185
- scope: searchParams.get('scope') || undefined,
2186
- state: searchParams.get('state') || '',
2187
- resource: searchParams.get('resource') || '',
2188
- };
2189
- }
2190
-
2191
- function getOauthAuthorizeParamsFromForm(form, hostedConfig) {
2192
- const pending = consumePendingOauthAuthorizeRequest(form.get('auth_request_token') || '');
2193
- if (pending) return pending;
2194
- return {
2195
- clientId: form.get('client_id') || '',
2196
- redirectUri: form.get('redirect_uri') || '',
2197
- codeChallenge: form.get('code_challenge') || '',
2198
- codeChallengeMethod: form.get('code_challenge_method') || '',
2199
- scope: form.get('scope') || undefined,
2200
- state: form.get('state') || '',
2201
- resource: form.get('resource') || buildPublicUrl(hostedConfig, '/mcp'),
2202
- };
2203
- }
2204
-
2205
- function renderCheckoutIntentPage(prefilledEmail = '', parsed = null, options = {}) {
2206
- const plausibleDomain = escapeHtmlAttribute(resolvePlausibleDataDomain({ host: 'thumbgate.ai' }));
2207
- const includeHiddenAttribution = options.includeHiddenAttribution === true;
2208
- const hiddenInputs = includeHiddenAttribution
2209
- ? buildCheckoutHiddenAttributionInputs(parsed)
2210
- : '';
2211
- return `<!doctype html>
2212
- <html lang="en">
2213
- <head>
2214
- <meta charset="utf-8">
2215
- <meta name="viewport" content="width=device-width,initial-scale=1">
2216
- <title>Confirm - ThumbGate Pro</title>
2217
- <script defer data-domain="${plausibleDomain}" src="https://plausible.io/js/script.tagged-events.js"></script>
2218
- <script>window.plausible=window.plausible||function(){(window.plausible.q=window.plausible.q||[]).push(arguments)};</script>
2219
- <style>
2220
- body{background:#0a0a0a;color:#eee;font-family:system-ui,-apple-system,sans-serif;line-height:1.5}
2221
- main{max-width:520px;margin:8vh auto;padding:0 20px}
2222
- .brand{display:flex;align-items:center;gap:10px;margin-bottom:24px;font-size:14px;color:#94a3b8}
2223
- .brand-mark{width:36px;height:36px;display:block;flex:0 0 auto;border-radius:9px}
2224
- h1{font-size:24px;margin:0 0 8px;color:#fff}.price{font-size:32px;font-weight:700;color:#22d3ee;margin:8px 0 4px}.price small{font-size:14px;color:#94a3b8;font-weight:400}
2225
- p{color:#cbd5e1;margin:8px 0}form{margin:0}
2226
- input[type=email]{width:100%;box-sizing:border-box;padding:14px 16px;border:1px solid #374151;border-radius:8px;background:#111827;color:#fff;font-size:15px;margin:16px 0 0;outline:none}
2227
- input[type=email]:focus{border-color:#22d3ee}input[type=email]::placeholder{color:#64748b}
2228
- button.primary{background:#22d3ee;color:#000;padding:16px;text-align:center;border-radius:8px;font-weight:700;font-size:16px;margin:10px 0;border:none;cursor:pointer;width:100%}
2229
- a{display:block;text-decoration:none}a.secondary{border:1px solid #374151;color:#cbd5e1;padding:12px;text-align:center;border-radius:8px;margin:8px 0 0;font-size:14px}
2230
- .trust,.objection-box{margin:24px 0;padding:16px;border:1px solid #1f2937;border-radius:8px;background:#0f172a}
2231
- .trust-item{font-size:13px;color:#cbd5e1;padding:4px 0;display:flex;gap:8px}.trust-item::before{content:"✓";color:#22d3ee;font-weight:700}
2232
- .choice-note,.email-note,.objection-note{font-size:13px;color:#94a3b8;margin-top:10px}
2233
- .objection-grid{display:grid;gap:8px;margin-top:12px}
2234
- .objection-grid button{border:1px solid #374151;background:#111827;color:#e5e7eb;border-radius:8px;padding:10px 12px;text-align:left;cursor:pointer}
2235
- .objection-grid button:hover,.objection-grid button:focus{border-color:#22d3ee;outline:none}
2236
- .feedback-saved{display:none;color:#22d3ee;font-size:13px;margin-top:10px}
2237
- .back{text-align:center;color:#64748b;font-size:12px;margin-top:24px}.back a{color:#64748b;display:inline}
2238
- </style>
2239
- </head>
2240
- <body>
2241
- <main>
2242
- <div class="brand"><img src="/thumbgate-icon.png?v=20260623-stripe-match" alt="" class="brand-mark" width="36" height="36"><span>ThumbGate</span></div>
2243
- <h1>Start ThumbGate Pro</h1>
2244
- <div class="price">$19<small>/mo</small></div>
2245
- <p>The npm package runs your gates locally. <strong>Pro</strong> is what keeps them working across every machine, every agent runtime, and every breaking-change week.</p>
2246
- <form action="/checkout/pro" method="GET" data-i="pro_checkout_confirmed">
2247
- ${hiddenInputs}
2248
- <input type="hidden" name="confirm" value="1">
2249
- <input type="email" name="customer_email" value="${escapeHtmlAttribute(prefilledEmail)}" placeholder="you@company.com" autocomplete="email">
2250
- <p class="email-note">Optional. Stripe can collect your email on the secure checkout page.</p>
2251
- <button type="submit" class="primary">Pay $19/mo with Stripe →</button>
2252
- </form>
2253
- <a class="secondary" data-i="workflow_sprint_intake" href="/#workflow-sprint-intake">Not sure yet? Send the workflow first</a>
2254
- <p class="choice-note">Cancel anytime. 7-day refund, no questions. Diagnostics and sprints have their own pages.</p>
2255
- <div class="objection-box" aria-label="Checkout feedback">
2256
- <strong>Not buying today?</strong>
2257
- <p class="objection-note">Tap one reason so we know what to fix. This does not sign you up.</p>
2258
- <div class="objection-grid">
2259
- <button type="button" data-reason="price_unclear">Price or scope is unclear</button>
2260
- <button type="button" data-reason="need_more_proof">Need more proof first</button>
2261
- <button type="button" data-reason="need_team_plan">Need a team/workflow plan instead</button>
2262
- <button type="button" data-reason="not_urgent">Not urgent right now</button>
2263
- </div>
2264
- <div class="feedback-saved" id="feedback-saved">Feedback saved.</div>
2265
- </div>
2266
- <div class="trust"><div class="trust-item">Lessons synced across all your machines — no local SQLite to babysit</div><div class="trust-item">Adapter matrix kept current for Claude Code, Cursor, Codex, Gemini, Amp, Cline, OpenCode — version drift is our problem, not yours</div><div class="trust-item">Hosted dashboard: gate stats, DPO export, org-wide rule library</div><div class="trust-item">24×7 ops on the rule engine — SonarCloud regressions fixed in &lt;24h</div></div>
2267
- <p class="back"><a href="/">← Back to thumbgate.ai</a></p>
2268
- </main>
2269
- <script>
2270
- (function(){
2271
- var params = new URLSearchParams(window.location.search);
2272
- var submitted = false;
2273
- var feedbackSent = false;
2274
- function pick(key, fallback) { return params.get(key) || fallback || null; }
2275
- function sendTelemetry(eventType, extra) {
2276
- var payload = Object.assign({
2277
- eventType: eventType,
2278
- clientType: 'web',
2279
- page: '/checkout/pro',
2280
- traceId: pick('trace_id'),
2281
- acquisitionId: pick('acquisition_id'),
2282
- visitorId: pick('visitor_id'),
2283
- sessionId: pick('visitor_session_id') || pick('session_id'),
2284
- installId: pick('install_id'),
2285
- source: pick('utm_source') || pick('source') || 'website',
2286
- utmSource: pick('utm_source') || pick('source') || 'website',
2287
- utmMedium: pick('utm_medium') || 'checkout_interstitial',
2288
- utmCampaign: pick('utm_campaign') || 'pro_pack',
2289
- utmContent: pick('utm_content'),
2290
- utmTerm: pick('utm_term'),
2291
- creator: pick('creator') || pick('creator_handle'),
2292
- community: pick('community') || pick('subreddit'),
2293
- postId: pick('post_id'),
2294
- commentId: pick('comment_id'),
2295
- campaignVariant: pick('campaign_variant'),
2296
- offerCode: pick('offer_code'),
2297
- ctaId: pick('cta_id') || 'pricing_pro',
2298
- ctaPlacement: pick('cta_placement') || 'checkout_interstitial',
2299
- planId: pick('plan_id') || 'pro',
2300
- billingCycle: pick('billing_cycle') || 'monthly',
2301
- landingPath: pick('landing_path') || '/',
2302
- referrerHost: pick('referrer_host'),
2303
- referrer: document.referrer || null
2304
- }, extra || {});
2305
- var body = JSON.stringify(payload);
2306
- if (navigator.sendBeacon) {
2307
- navigator.sendBeacon('/v1/telemetry/ping', new Blob([body], { type: 'application/json' }));
2308
- return;
2309
- }
2310
- fetch('/v1/telemetry/ping', { method:'POST', headers:{ 'content-type':'application/json' }, body: body, keepalive: true }).catch(function(){});
2311
- }
2312
- document.querySelector('form').addEventListener('submit', function(){
2313
- submitted = true;
2314
- var emailInput = document.querySelector('input[name=customer_email]');
2315
- sendTelemetry('checkout_interstitial_cta_clicked', {
2316
- ctaId: 'pro_checkout_confirmed',
2317
- ctaPlacement: 'checkout_interstitial',
2318
- customerEmail: emailInput ? emailInput.value : null
2319
- });
2320
- try { window.plausible && window.plausible('Checkout Pro Email Submitted', { props: { page:'/checkout/pro', source:'interstitial' } }); } catch(_) {}
2321
- });
2322
- document.querySelectorAll('[data-reason]').forEach(function(button) {
2323
- button.addEventListener('click', function(){
2324
- var reason = button.getAttribute('data-reason');
2325
- feedbackSent = true;
2326
- sendTelemetry('reason_not_buying', {
2327
- reasonCode: reason,
2328
- ctaId: 'checkout_interstitial_reason_' + reason,
2329
- ctaPlacement: 'checkout_interstitial_feedback'
2330
- });
2331
- try { window.plausible && window.plausible('Checkout Pro Reason Not Buying', { props: { page:'/checkout/pro', reasonCode: reason } }); } catch(_) {}
2332
- var saved = document.getElementById('feedback-saved');
2333
- if (saved) saved.style.display = 'block';
2334
- });
2335
- });
2336
- window.addEventListener('pagehide', function(){
2337
- if (!submitted && !feedbackSent) {
2338
- sendTelemetry('checkout_interstitial_abandoned', { reasonCode: 'left_without_confirming' });
2339
- }
2340
- });
2341
- })();
2342
- </script>
2343
- </body>
2344
- </html>`;
2345
- }
2346
-
2347
- function buildCheckoutBootstrapBody(parsed, req, journeyState = resolveJourneyState(req, parsed)) {
2348
- const params = parsed.searchParams;
2349
- const traceId = pickFirstText(params.get('trace_id')) || createJourneyId('checkout');
2350
- const planId = normalizePlanId(pickFirstText(params.get('plan_id'), 'pro'));
2351
- const billingCycle = normalizeBillingCycle(pickFirstText(params.get('billing_cycle'), 'monthly'));
2352
- const seatCount = planId === 'team'
2353
- ? normalizeSeatCount(pickFirstText(params.get('seat_count')))
2354
- : 1;
2355
- return {
2356
- traceId,
2357
- installId: pickFirstText(params.get('install_id')),
2358
- acquisitionId: journeyState.acquisitionId,
2359
- visitorId: journeyState.visitorId,
2360
- sessionId: journeyState.sessionId,
2361
- customerEmail: pickFirstText(params.get('customer_email')),
2362
- source: pickFirstText(params.get('source'), params.get('utm_source'), 'website'),
2363
- utmSource: pickFirstText(params.get('utm_source'), params.get('source'), 'website'),
2364
- utmMedium: pickFirstText(params.get('utm_medium'), 'cta_button'),
2365
- utmCampaign: pickFirstText(params.get('utm_campaign'), 'pro_pack'),
2366
- utmContent: pickFirstText(params.get('utm_content')),
2367
- utmTerm: pickFirstText(params.get('utm_term')),
2368
- creator: pickFirstText(params.get('creator'), params.get('creator_handle')),
2369
- community: pickFirstText(params.get('community'), params.get('subreddit')),
2370
- postId: pickFirstText(params.get('post_id')),
2371
- commentId: pickFirstText(params.get('comment_id')),
2372
- campaignVariant: pickFirstText(params.get('campaign_variant')),
2373
- offerCode: pickFirstText(params.get('offer_code')),
2374
- landingPath: pickFirstText(params.get('landing_path'), req.headers.referer ? '/' : '/'),
2375
- referrerHost: pickFirstText(params.get('referrer_host')),
2376
- ctaId: pickFirstText(params.get('cta_id'), 'pricing_pro'),
2377
- ctaPlacement: pickFirstText(params.get('cta_placement'), 'pricing'),
2378
- planId,
2379
- billingCycle,
2380
- seatCount,
2381
- metadata: {
2382
- referrer: pickFirstText(params.get('referrer'), req.headers.referer, req.headers.referrer),
2383
- landingPath: pickFirstText(params.get('landing_path'), '/'),
2384
- referrerHost: pickFirstText(params.get('referrer_host')),
2385
- },
2386
- };
1509
+ function buildCheckoutBootstrapBody(parsed, req, journeyState = resolveJourneyState(req, parsed)) {
1510
+ const params = parsed.searchParams;
1511
+ const traceId = pickFirstText(params.get('trace_id')) || createJourneyId('checkout');
1512
+ const planId = normalizePlanId(pickFirstText(params.get('plan_id'), 'pro'));
1513
+ const billingCycle = normalizeBillingCycle(pickFirstText(params.get('billing_cycle'), 'monthly'));
1514
+ const seatCount = planId === 'team'
1515
+ ? normalizeSeatCount(pickFirstText(params.get('seat_count')))
1516
+ : 1;
1517
+ return {
1518
+ traceId,
1519
+ installId: pickFirstText(params.get('install_id')),
1520
+ acquisitionId: journeyState.acquisitionId,
1521
+ visitorId: journeyState.visitorId,
1522
+ sessionId: journeyState.sessionId,
1523
+ customerEmail: pickFirstText(params.get('customer_email')),
1524
+ source: pickFirstText(params.get('source'), params.get('utm_source'), 'website'),
1525
+ utmSource: pickFirstText(params.get('utm_source'), params.get('source'), 'website'),
1526
+ utmMedium: pickFirstText(params.get('utm_medium'), 'cta_button'),
1527
+ utmCampaign: pickFirstText(params.get('utm_campaign'), 'pro_pack'),
1528
+ utmContent: pickFirstText(params.get('utm_content')),
1529
+ utmTerm: pickFirstText(params.get('utm_term')),
1530
+ creator: pickFirstText(params.get('creator'), params.get('creator_handle')),
1531
+ community: pickFirstText(params.get('community'), params.get('subreddit')),
1532
+ postId: pickFirstText(params.get('post_id')),
1533
+ commentId: pickFirstText(params.get('comment_id')),
1534
+ campaignVariant: pickFirstText(params.get('campaign_variant')),
1535
+ offerCode: pickFirstText(params.get('offer_code')),
1536
+ landingPath: pickFirstText(params.get('landing_path'), req.headers.referer ? '/' : '/'),
1537
+ referrerHost: pickFirstText(params.get('referrer_host')),
1538
+ ctaId: pickFirstText(params.get('cta_id'), 'pricing_pro'),
1539
+ ctaPlacement: pickFirstText(params.get('cta_placement'), 'pricing'),
1540
+ planId,
1541
+ billingCycle,
1542
+ seatCount,
1543
+ metadata: {
1544
+ referrer: pickFirstText(params.get('referrer'), req.headers.referer, req.headers.referrer),
1545
+ landingPath: pickFirstText(params.get('landing_path'), '/'),
1546
+ referrerHost: pickFirstText(params.get('referrer_host')),
1547
+ },
1548
+ };
2387
1549
  }
2388
1550
 
2389
1551
  function buildCheckoutConfirmHref(parsed) {
@@ -2405,6 +1567,17 @@ function normalizeCheckoutCustomerEmail(value) {
2405
1567
  return email;
2406
1568
  }
2407
1569
 
1570
+ function renderCheckoutIntentGate(parsed, responseHeaders = {}) {
1571
+ let hiddenInputs = '';
1572
+ for (const [key, value] of parsed.searchParams.entries()) {
1573
+ if (key !== 'confirm' && key !== 'customer_email') hiddenInputs += `<input type=hidden name=${escapeHtmlAttribute(key)} value=${escapeHtmlAttribute(value)}>`;
1574
+ }
1575
+ return {
1576
+ 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>`,
1577
+ headers: responseHeaders,
1578
+ };
1579
+ }
1580
+
2408
1581
  function normalizeTrackedLinkSlug(value) {
2409
1582
  return String(value || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
2410
1583
  }
@@ -2415,27 +1588,6 @@ function normalizePublicPageSlug(value) {
2415
1588
  .replace(/[^a-z0-9-]/g, '');
2416
1589
  }
2417
1590
 
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
1591
  function getTrackedLinkTarget(slug) {
2440
1592
  const normalizedSlug = normalizeTrackedLinkSlug(slug);
2441
1593
  return TRACKED_LINK_TARGETS[normalizedSlug]
@@ -2471,10 +1623,8 @@ function appendTrackedLinkQueryParams(destinationUrl, parsed, target) {
2471
1623
  }
2472
1624
 
2473
1625
  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)
1626
+ const destinationUrl = target.href
1627
+ ? new URL(target.href)
2478
1628
  : new URL(target.path || '/', hostedConfig.appOrigin);
2479
1629
  appendTrackedLinkQueryParams(destinationUrl, parsed, target);
2480
1630
  return destinationUrl;
@@ -2601,7 +1751,6 @@ function sendJson(res, statusCode, payload, extraHeaders = {}, options = {}) {
2601
1751
  const body = JSON.stringify(payload);
2602
1752
  res.writeHead(statusCode, {
2603
1753
  'Content-Type': 'application/json; charset=utf-8',
2604
- 'X-Content-Type-Options': 'nosniff',
2605
1754
  'Content-Length': Buffer.byteLength(body),
2606
1755
  ...extraHeaders,
2607
1756
  });
@@ -2676,28 +1825,14 @@ function appendBestEffortTelemetry(feedbackDir, payload, headers, context) {
2676
1825
  evidence: [err?.message ? err.message : 'unknown_error'],
2677
1826
  },
2678
1827
  });
2679
- } catch {}
1828
+ } catch (_) {}
2680
1829
  return false;
2681
1830
  }
2682
1831
  }
2683
1832
 
2684
- function inferActionIntegration(body = {}, headers = {}) {
2685
- const explicit = pickFirstText(body.integration, body.source, body.actionIntegration, body.provider);
2686
- const userAgent = String(headers['user-agent'] || '').toLowerCase();
2687
- if (/chatgpt|gpt[_-]?actions?|openai/.test(String(explicit || '').toLowerCase()) || /chatgpt|openai/.test(userAgent)) {
2688
- return 'chatgpt_gpt';
2689
- }
2690
- return explicit || 'api';
2691
- }
2692
-
2693
- function chatgptActionEventType(integration, suffix) {
2694
- return integration === 'chatgpt_gpt' ? `chatgpt_action_${suffix}` : `api_action_${suffix}`;
2695
- }
2696
-
2697
1833
  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';
1834
+ const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() || 'http';
1835
+ const host = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim() || 'localhost';
2701
1836
  return `${proto}://${host}`;
2702
1837
  }
2703
1838
 
@@ -2716,47 +1851,6 @@ function getRequestHostHeader(req) {
2716
1851
  return forwardedHost || req.headers.host || '';
2717
1852
  }
2718
1853
 
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
1854
  function isLoopbackHost(hostValue) {
2761
1855
  const rawHost = String(hostValue || '').split(',')[0].trim();
2762
1856
  if (!rawHost) {
@@ -2802,7 +1896,7 @@ function stripTrailingSlashes(value) {
2802
1896
  return input.slice(0, end);
2803
1897
  }
2804
1898
 
2805
- function normalizePublicMarketingHtml(html, runtimeConfig, requestHost) {
1899
+ function normalizePublicMarketingHtml(html, runtimeConfig) {
2806
1900
  const appOrigin = runtimeConfig?.appOrigin
2807
1901
  ? stripTrailingSlashes(runtimeConfig.appOrigin)
2808
1902
  : '';
@@ -2811,14 +1905,10 @@ function normalizePublicMarketingHtml(html, runtimeConfig, requestHost) {
2811
1905
  let output = String(html);
2812
1906
  output = output.replaceAll(DEFAULT_PUBLIC_APP_ORIGIN, appOrigin);
2813
1907
  try {
2814
- const host = normalizePublicRequestHost(requestHost) || new URL(appOrigin).host;
2815
- const plausibleDomain = resolvePlausibleDataDomain({ host });
1908
+ const host = new URL(appOrigin).host;
2816
1909
  output = output.replaceAll(
2817
1910
  'data-domain="thumbgate-production.up.railway.app"',
2818
- `data-domain="${escapeHtmlAttribute(plausibleDomain)}"`
2819
- ).replaceAll(
2820
- 'data-domain="thumbgate.ai"',
2821
- `data-domain="${escapeHtmlAttribute(plausibleDomain)}"`
1911
+ `data-domain="${escapeHtmlAttribute(host)}"`
2822
1912
  );
2823
1913
  } catch {
2824
1914
  // appOrigin is normalized by hosted-config; leave static analytics domains
@@ -2867,7 +1957,7 @@ function loadPublicMarketingTemplateHtml(templatePath, runtimeConfig, pageContex
2867
1957
  '__GTM_PLAN_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/GO_TO_MARKET_REVENUE_WEDGE_2026-03.md',
2868
1958
  '__GITHUB_URL__': 'https://github.com/IgorGanapolsky/ThumbGate',
2869
1959
  '__POSTHOG_API_KEY__': runtimeConfig.posthogApiKey || '',
2870
- }), runtimeConfig, pageContext.requestHost);
1960
+ }), runtimeConfig);
2871
1961
  }
2872
1962
 
2873
1963
  function loadLandingPageHtml(runtimeConfig, pageContext = {}) {
@@ -2878,22 +1968,6 @@ function loadProPageHtml(runtimeConfig, pageContext = {}) {
2878
1968
  return loadPublicMarketingTemplateHtml(PRO_PAGE_PATH, runtimeConfig, pageContext);
2879
1969
  }
2880
1970
 
2881
- function loadPricingPageHtml(runtimeConfig, pageContext = {}) {
2882
- return loadPublicMarketingTemplateHtml(PRICING_PAGE_PATH, runtimeConfig, pageContext);
2883
- }
2884
-
2885
- function loadAboutPageHtml(runtimeConfig, pageContext = {}) {
2886
- return loadPublicMarketingTemplateHtml(ABOUT_PAGE_PATH, runtimeConfig, pageContext);
2887
- }
2888
-
2889
- function loadGuidePageHtml(runtimeConfig, pageContext = {}) {
2890
- return loadPublicMarketingTemplateHtml(GUIDE_PAGE_PATH, runtimeConfig, pageContext);
2891
- }
2892
-
2893
- function loadLearnPageHtml(runtimeConfig, pageContext = {}) {
2894
- return loadPublicMarketingTemplateHtml(LEARN_PAGE_PATH, runtimeConfig, pageContext);
2895
- }
2896
-
2897
1971
  function readOptionalPublicTemplate(filePath) {
2898
1972
  try {
2899
1973
  return fs.readFileSync(filePath, 'utf-8');
@@ -2903,10 +1977,6 @@ function readOptionalPublicTemplate(filePath) {
2903
1977
  }
2904
1978
  }
2905
1979
 
2906
- function escapeJsonForInlineScript(value) {
2907
- return JSON.stringify(value).replaceAll('<', String.raw`\u003c`);
2908
- }
2909
-
2910
1980
  function resolveLocalPageBootstrap(req, expectedApiKey) {
2911
1981
  const forwardedHost = req.headers['x-forwarded-host'];
2912
1982
  const hostHeader = Array.isArray(forwardedHost)
@@ -2915,13 +1985,7 @@ function resolveLocalPageBootstrap(req, expectedApiKey) {
2915
1985
  const localProBootstrap = process.env.THUMBGATE_PRO_MODE === '1' && Boolean(expectedApiKey) && isLoopbackHost(hostHeader);
2916
1986
  const devOverride = expectedApiKey === null && isLoopbackHost(hostHeader);
2917
1987
  const bootstrapActive = localProBootstrap || devOverride;
2918
- let bootstrapKey = '';
2919
- if (localProBootstrap) {
2920
- bootstrapKey = expectedApiKey;
2921
- } else if (devOverride) {
2922
- bootstrapKey = process.env.THUMBGATE_API_KEY || 'dev-override';
2923
- }
2924
- const serializedBootstrapKey = escapeJsonForInlineScript(bootstrapKey);
1988
+ const serializedBootstrapKey = JSON.stringify(localProBootstrap ? expectedApiKey : devOverride ? 'dev-override' : '').replace(/</g, '\\u003c');
2925
1989
 
2926
1990
  return {
2927
1991
  bootstrapActive,
@@ -2962,7 +2026,6 @@ window.THUMBGATE_DASHBOARD_BOOTSTRAP = { enabled: ${bootstrapActive ? 'true' : '
2962
2026
  <p>This lightweight npm dashboard is bundled without marketing assets, so installs stay small while core feedback, lessons, and API routes remain available.</p>
2963
2027
  <div class="grid">
2964
2028
  <a class="card" href="/v1/dashboard"><strong>Dashboard JSON</strong><span>Inspect feedback totals, lesson counts, and Reliability Gateway health.</span></a>
2965
- <a class="card" href="/v1/enterprise/data-chat/status"><strong>Governed Data Chat</strong><span>Local RAG (LanceDB vectors + lessons) over your data plus your LLM. Guard simulation is available; Dialogflow/Vertex are optional adapters for customer-owned agent deployments.</span></a>
2966
2029
  <a class="card" href="/lessons"><strong>Lessons</strong><span>Review remembered thumbs-up/down lessons and enforcement context.</span></a>
2967
2030
  <a class="card" href="/health"><strong>Health</strong><span>Verify the installed package version and runtime status.</span></a>
2968
2031
  </div>
@@ -3042,53 +2105,6 @@ function normalizeLessonSignal(signal) {
3042
2105
  return 'down';
3043
2106
  }
3044
2107
 
3045
- function splitLessonTitlePrefix(titleText) {
3046
- const prefixMatch = /^(MISTAKE|SUCCESS|LEARNING|PREFERENCE):\s*(.*)/i.exec(titleText);
3047
- if (!prefixMatch) return { prefix: '', rest: titleText };
3048
- return {
3049
- prefix: `${prefixMatch[1].toUpperCase()}: `,
3050
- rest: prefixMatch[2],
3051
- };
3052
- }
3053
-
3054
- function maybeParseJsonObject(value) {
3055
- const trimmed = String(value || '').trim();
3056
- if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null;
3057
- try {
3058
- return JSON.parse(trimmed);
3059
- } catch (error) {
3060
- debugApiFallback('lesson JSON parse skipped', error);
3061
- return null;
3062
- }
3063
- }
3064
-
3065
- function formatLessonJsonTitle(prefix, parsed) {
3066
- const dirName = parsed.cwd ? parsed.cwd.split('/').pop() : '';
3067
- const suffix = dirName ? ` inside ${dirName}` : '';
3068
- if (parsed.prompt) return `${prefix}Prompt "${parsed.prompt}"${suffix}`;
3069
- const hookVal = parsed.hook_event_name || parsed.hookEventName;
3070
- if (hookVal) return `${prefix}Hook event ${hookVal}${suffix}`;
3071
- if (parsed.signal) return `${prefix}${parsed.signal === 'up' ? 'Thumbs Up' : 'Thumbs Down'}${suffix}`;
3072
- return '';
3073
- }
3074
-
3075
- function cleanLessonTitle(titleText) {
3076
- if (!titleText) return 'Untitled Lesson';
3077
- const { prefix, rest } = splitLessonTitlePrefix(titleText);
3078
- const parsed = maybeParseJsonObject(rest);
3079
- if (parsed) {
3080
- const jsonTitle = formatLessonJsonTitle(prefix, parsed);
3081
- if (jsonTitle) return jsonTitle;
3082
- }
3083
- return titleText;
3084
- }
3085
-
3086
- function formatLessonTextValue(value) {
3087
- if (!value) return '';
3088
- const parsed = maybeParseJsonObject(value);
3089
- return parsed ? JSON.stringify(parsed, null, 2) : value;
3090
- }
3091
-
3092
2108
  function renderLessonDetailHtml(record, lessonId) {
3093
2109
  if (!record) {
3094
2110
  return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Lesson Not Found</title>
@@ -3107,15 +2123,10 @@ a{color:#22d3ee;text-decoration:none}</style></head><body>
3107
2123
  const signal = normalizeLessonSignal(merged.signal);
3108
2124
  const emoji = signal === 'up' ? '👍' : '👎';
3109
2125
  const signalColor = signal === 'up' ? '#4ade80' : '#f87171';
3110
- const rawTitle = merged.title || merged.context || 'Untitled Lesson';
3111
- const rawContext = merged.context || '';
3112
- const rawWhatWentWrong = merged.whatWentWrong || '';
3113
- const rawWhatWorked = merged.whatWorked || '';
3114
-
3115
- const title = cleanLessonTitle(rawTitle);
3116
- const context = formatLessonTextValue(rawContext);
3117
- const whatWentWrong = formatLessonTextValue(rawWhatWentWrong);
3118
- const whatWorked = formatLessonTextValue(rawWhatWorked);
2126
+ const title = merged.title || merged.context || 'Untitled Lesson';
2127
+ const context = merged.context || '';
2128
+ const whatWentWrong = merged.whatWentWrong || '';
2129
+ const whatWorked = merged.whatWorked || '';
3119
2130
  const whatToChange = merged.whatToChange || '';
3120
2131
  const tags = Array.isArray(merged.tags) ? merged.tags.join(', ') : (merged.tags || '');
3121
2132
  const timestamp = merged.timestamp ? new Date(merged.timestamp).toLocaleString() : '';
@@ -3333,7 +2344,7 @@ nav .container { display: flex; justify-content: space-between; align-items: cen
3333
2344
  </head>
3334
2345
  <body>
3335
2346
  <nav><div class="container">
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>
2347
+ <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>
3337
2348
  <div class="nav-links">
3338
2349
  <a href="/dashboard">Dashboard</a>
3339
2350
  <a href="/lessons">Lessons</a>
@@ -3457,16 +2468,6 @@ function renderRobotsTxt(runtimeConfig) {
3457
2468
  'Disallow: /v1/billing/',
3458
2469
  '',
3459
2470
  '# AI crawler access — allow all major LLM crawlers',
3460
- 'User-agent: OAI-SearchBot',
3461
- 'Allow: /',
3462
- 'Disallow: /checkout/',
3463
- 'Disallow: /v1/billing/',
3464
- '',
3465
- 'User-agent: ChatGPT-User',
3466
- 'Allow: /',
3467
- 'Disallow: /checkout/',
3468
- 'Disallow: /v1/billing/',
3469
- '',
3470
2471
  'User-agent: GPTBot',
3471
2472
  'Allow: /',
3472
2473
  'Disallow: /checkout/',
@@ -3475,18 +2476,9 @@ function renderRobotsTxt(runtimeConfig) {
3475
2476
  'User-agent: ClaudeBot',
3476
2477
  'Allow: /',
3477
2478
  '',
3478
- 'User-agent: Claude-SearchBot',
3479
- 'Allow: /',
3480
- '',
3481
- 'User-agent: Claude-User',
3482
- 'Allow: /',
3483
- '',
3484
2479
  'User-agent: PerplexityBot',
3485
2480
  'Allow: /',
3486
2481
  '',
3487
- 'User-agent: Perplexity-User',
3488
- 'Allow: /',
3489
- '',
3490
2482
  'User-agent: Googlebot',
3491
2483
  'Allow: /',
3492
2484
  '',
@@ -3501,7 +2493,6 @@ function renderRobotsTxt(runtimeConfig) {
3501
2493
  '',
3502
2494
  '# LLM context document — clean declarative content for AI retrieval',
3503
2495
  `# ${runtimeConfig.appOrigin}/llm-context.md`,
3504
- `# ${runtimeConfig.appOrigin}/llms.txt`,
3505
2496
  '',
3506
2497
  `Sitemap: ${runtimeConfig.appOrigin}/sitemap.xml`,
3507
2498
  ].join('\n');
@@ -3511,54 +2502,10 @@ function renderSitemapXml(runtimeConfig) {
3511
2502
  const entries = [
3512
2503
  { path: '/', changefreq: 'weekly', priority: '1.0' },
3513
2504
  { path: '/pro', changefreq: 'weekly', priority: '0.9' },
3514
- { path: '/agent-manager', changefreq: 'weekly', priority: '0.9' },
3515
2505
  { path: '/llm-context.md', changefreq: 'weekly', priority: '0.8' },
3516
- { path: '/chatgpt-app', changefreq: 'weekly', priority: '0.85' },
3517
2506
  { path: '/codex-plugin', changefreq: 'weekly', priority: '0.75' },
3518
- { path: '/codex-enterprise', changefreq: 'weekly', priority: '0.85' },
3519
- { path: '/agents-cost-savings', changefreq: 'weekly', priority: '0.85' },
3520
- { path: '/ai-malpractice-prevention', changefreq: 'weekly', priority: '0.9' },
3521
- { path: '/learn/background-agent-control-layer', changefreq: 'weekly', priority: '0.85' },
3522
- { path: '/learn/ac-dc-runtime-enforcement', changefreq: 'weekly', priority: '0.85' },
3523
- { path: '/learn/feedback-loop-vs-decision-layer', changefreq: 'weekly', priority: '0.9' },
3524
- { path: '/learn/agentic-enterprise-context-brain', changefreq: 'weekly', priority: '0.85' },
3525
- { path: '/learn/deterministic-agent-workflows', changefreq: 'weekly', priority: '0.85' },
3526
- { path: '/learn/codex-role-plugins-need-governance', changefreq: 'weekly', priority: '0.85' },
3527
- { path: '/learn/agentic-os-team-governance', changefreq: 'weekly', priority: '0.85' },
3528
- { path: '/learn/cost-aware-agent-gate-routing', changefreq: 'weekly', priority: '0.85' },
3529
- { path: '/learn/pretix-stripe-connect-marketplaces', changefreq: 'weekly', priority: '0.9' },
3530
- { path: '/compare/claude-code-hooks', changefreq: 'weekly', priority: '0.85' },
3531
- { path: '/compare/bumblebee', changefreq: 'weekly', priority: '0.85' },
3532
- { path: '/compare/anthropic-containment', changefreq: 'weekly', priority: '0.85' },
3533
- { path: '/compare/oak-and-sparrow-gatekeeper', changefreq: 'weekly', priority: '0.85' },
3534
- { path: '/compare/arcjet', changefreq: 'weekly', priority: '0.85' },
3535
- { path: '/compare/anthropic-claude-for-legal', changefreq: 'weekly', priority: '0.9' },
3536
2507
  ...THUMBGATE_SEO_SITEMAP_ENTRIES,
3537
2508
  ];
3538
- // Auto-include every hand-written SEO page so /sitemap.xml can never drift out
3539
- // of sync with public/compare/*.html or public/guides/*.html. Crawlers and AI
3540
- // answer engines only surface pages they can discover, so a buyer-intent page
3541
- // missing from the sitemap is invisible on its query. De-duped against entries
3542
- // already declared above (e.g. seo-gsd specs), which keep explicit priorities.
3543
- const declaredPaths = new Set(entries.map((entry) => entry.path));
3544
- try {
3545
- const seoDirectories = [
3546
- { dir: 'compare', route: '/compare', priority: '0.85' },
3547
- { dir: 'guides', route: '/guides', priority: '0.85' },
3548
- ];
3549
- for (const catalog of seoDirectories) {
3550
- const files = fs.readdirSync(path.join(PUBLIC_DIR, catalog.dir)).sort((a, b) => a.localeCompare(b));
3551
- for (const file of files) {
3552
- if (!file.endsWith('.html')) continue;
3553
- const publicPath = `${catalog.route}/${file.replace(/\.html$/, '')}`;
3554
- if (declaredPaths.has(publicPath)) continue;
3555
- declaredPaths.add(publicPath);
3556
- entries.push({ path: publicPath, changefreq: 'weekly', priority: catalog.priority });
3557
- }
3558
- }
3559
- } catch {
3560
- // SEO directories absent in a stripped bundle — fall back to static entries.
3561
- }
3562
2509
  return [
3563
2510
  '<?xml version="1.0" encoding="UTF-8"?>',
3564
2511
  '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
@@ -3684,13 +2631,11 @@ function servePublicMarketingPage({
3684
2631
  }, req.headers, 'seo_landing_view');
3685
2632
  }
3686
2633
 
3687
- const requestHost = getSafePublicRequestHost(req);
3688
2634
  const html = renderHtml(hostedConfig, {
3689
2635
  serverVisitorId: journeyState.visitorId,
3690
2636
  serverSessionId: journeyState.sessionId,
3691
2637
  serverAcquisitionId: journeyState.acquisitionId,
3692
2638
  serverTelemetryCaptured: landingTelemetryCaptured,
3693
- requestHost,
3694
2639
  });
3695
2640
 
3696
2641
  sendHtml(
@@ -3830,12 +2775,11 @@ function renderCheckoutSuccessPage(runtimeConfig) {
3830
2775
  </style>
3831
2776
  <link rel="icon" type="image/png" href="/thumbgate-icon.png">
3832
2777
  <link rel="apple-touch-icon" href="/assets/brand/thumbgate-mark.svg">
3833
- <script defer data-domain="thumbgate-production.up.railway.app" src="https://plausible.io/js/script.tagged-events.js"></script>
3834
- <script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments); };</script>
2778
+ <script defer data-domain="thumbgate-production.up.railway.app" src="https://plausible.io/js/script.js"></script>
3835
2779
  </head>
3836
2780
  <body>
3837
2781
  <main>
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>
2782
+ <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>
3839
2783
  <span class="eyebrow">ThumbGate Pro</span>
3840
2784
  <h1>Your local Pro dashboard is ready.</h1>
3841
2785
  <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>
@@ -3972,7 +2916,7 @@ function renderCheckoutSuccessPage(runtimeConfig) {
3972
2916
  if (window.sessionStorage) {
3973
2917
  window.sessionStorage.setItem(marker, '1');
3974
2918
  }
3975
- } catch {
2919
+ } catch (_) {
3976
2920
  sendTelemetry(eventType, extra);
3977
2921
  }
3978
2922
  }
@@ -4009,7 +2953,6 @@ function renderCheckoutSuccessPage(runtimeConfig) {
4009
2953
  }
4010
2954
 
4011
2955
  sendTelemetryOnce('checkout_paid_confirmed');
4012
- try { window.plausible && window.plausible('Checkout Pro Success Page Confirmed', { props: { sessionId: sessionId || '', traceId: traceId || '', source: 'success_page' } }); } catch (_) {}
4013
2956
  statusEl.textContent = 'ThumbGate Pro activated.';
4014
2957
  const resolvedTraceId = body.traceId || traceId || '';
4015
2958
  const emailStatus = body.trialEmail || {};
@@ -4409,39 +3352,6 @@ function renderWorkflowSprintIntakeResultPage(runtimeConfig, { title, detail, le
4409
3352
  </html>`;
4410
3353
  }
4411
3354
 
4412
- function renderBrokerAuditIntakeResultPage(runtimeConfig, { title, detail, leadId = null }) {
4413
- const safeTitle = escapeHtmlAttribute(title || 'Broker audit request received');
4414
- const safeDetail = escapeHtmlAttribute(detail || 'Your broker lead-flow audit request is in the queue.');
4415
- const safeLeadId = leadId ? `<p><strong>Lead ID:</strong> ${escapeHtmlAttribute(leadId)}</p>` : '';
4416
- return `<!DOCTYPE html>
4417
- <html lang="en">
4418
- <head>
4419
- <meta charset="UTF-8" />
4420
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
4421
- <title>${safeTitle}</title>
4422
- <meta name="robots" content="noindex,nofollow" />
4423
- <style>
4424
- body { margin: 0; font-family: system-ui, -apple-system, sans-serif; background: #0b1220; color: #e5edf8; line-height: 1.6; }
4425
- main { max-width: 680px; margin: 0 auto; padding: 72px 20px; }
4426
- .card { border: 1px solid #26354f; border-radius: 14px; padding: 24px; background: #111a2b; }
4427
- h1 { margin: 0 0 10px; font-size: 30px; }
4428
- p { color: #b8c5d8; }
4429
- a { color: #7dd3fc; font-weight: 700; text-decoration: none; }
4430
- </style>
4431
- </head>
4432
- <body>
4433
- <main>
4434
- <div class="card">
4435
- <h1>${safeTitle}</h1>
4436
- <p>${safeDetail}</p>
4437
- ${safeLeadId}
4438
- <p><a href="${escapeHtmlAttribute(runtimeConfig.appOrigin)}/broker-audit">Back to broker audit page</a></p>
4439
- </div>
4440
- </main>
4441
- </body>
4442
- </html>`;
4443
- }
4444
-
4445
3355
  function readBodyBuffer(req, maxBytes = 1024 * 1024) {
4446
3356
  return new Promise((resolve, reject) => {
4447
3357
  let total = 0;
@@ -4486,72 +3396,6 @@ async function parseFormBody(req, maxBytes = 1024 * 1024) {
4486
3396
  return Object.fromEntries(params.entries());
4487
3397
  }
4488
3398
 
4489
- function normalizeLeadEmail(value) {
4490
- const email = normalizeNullableText(value);
4491
- if (!email || !/^[^\s@]{1,64}@[^\s@]{1,255}\.[^\s@]{1,63}$/.test(email)) {
4492
- throw createHttpError(400, 'A valid email is required');
4493
- }
4494
- return email.toLowerCase();
4495
- }
4496
-
4497
- function normalizeHttpUrl(value, fieldName) {
4498
- const raw = normalizeNullableText(value);
4499
- if (!raw) throw createHttpError(400, `${fieldName} is required`);
4500
- try {
4501
- const parsedUrl = new URL(raw);
4502
- if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
4503
- throw new Error('unsupported protocol');
4504
- }
4505
- return parsedUrl.toString();
4506
- } catch {
4507
- throw createHttpError(400, `${fieldName} must be an http(s) URL`);
4508
- }
4509
- }
4510
-
4511
- function appendBrokerAuditLead(feedbackDir, payload) {
4512
- const lead = {
4513
- leadId: createTraceId('broker_audit_lead'),
4514
- status: 'new',
4515
- offer: 'broker_lead_flow_audit_free',
4516
- requestedAt: new Date().toISOString(),
4517
- contact: {
4518
- name: normalizeNullableText(payload.name),
4519
- email: normalizeLeadEmail(payload.email),
4520
- brokerage: normalizeNullableText(payload.brokerage),
4521
- website: normalizeHttpUrl(payload.website, 'website'),
4522
- },
4523
- qualification: {
4524
- suspectedLeak: normalizeNullableText(payload.suspected_leak || payload.suspectedLeak),
4525
- },
4526
- attribution: {
4527
- traceId: payload.traceId,
4528
- acquisitionId: payload.acquisitionId,
4529
- visitorId: payload.visitorId,
4530
- sessionId: payload.sessionId,
4531
- source: payload.source,
4532
- utmSource: payload.utmSource,
4533
- utmMedium: payload.utmMedium,
4534
- utmCampaign: payload.utmCampaign,
4535
- utmContent: payload.utmContent,
4536
- utmTerm: payload.utmTerm,
4537
- ctaId: payload.ctaId,
4538
- ctaPlacement: payload.ctaPlacement,
4539
- page: payload.page,
4540
- landingPath: payload.landingPath,
4541
- referrer: payload.referrer,
4542
- referrerHost: payload.referrerHost,
4543
- },
4544
- };
4545
-
4546
- if (!lead.contact.name) throw createHttpError(400, 'name is required');
4547
- if (!lead.contact.brokerage) throw createHttpError(400, 'brokerage is required');
4548
-
4549
- const leadsPath = path.join(feedbackDir, 'broker-audit-leads.jsonl');
4550
- fs.mkdirSync(path.dirname(leadsPath), { recursive: true });
4551
- fs.appendFileSync(leadsPath, `${JSON.stringify(lead)}\n`, 'utf8');
4552
- return lead;
4553
- }
4554
-
4555
3399
  function parseOptionalObject(input, name) {
4556
3400
  if (input == null) return {};
4557
3401
  if (typeof input === 'object' && !Array.isArray(input)) return input;
@@ -4626,49 +3470,6 @@ function extractApiKey(req) {
4626
3470
  return '';
4627
3471
  }
4628
3472
 
4629
- /**
4630
- * Map a ThumbGate key presented at the OAuth consent screen to a role.
4631
- *
4632
- * - When any key is configured (production), the presented key MUST match a
4633
- * configured admin/operator/reviewer key; otherwise returns null (reject) so
4634
- * the OAuth flow actually authenticates the holder.
4635
- * - When NO keys are configured (insecure/dev mode, e.g. local tests), any
4636
- * non-empty key is accepted as role 'dev' to preserve local development.
4637
- *
4638
- * 'reviewer' (THUMBGATE_REVIEWER_KEY) is a read-only, independently-revocable
4639
- * credential safe to share with a directory reviewer — tool execution enforces
4640
- * read-only for it (see the tools/call handler).
4641
- */
4642
- // Constant-time comparison of two API/OAuth bearer keys. We compare the raw
4643
- // bytes with crypto.timingSafeEqual after an explicit equal-length guard
4644
- // (timingSafeEqual throws on a length mismatch). No digest is taken: these are
4645
- // high-entropy random credentials compared transiently at request time, not
4646
- // user passwords stored at rest, so neither a KDF (bcrypt/scrypt/argon2) nor a
4647
- // length-normalizing hash is appropriate — and hashing the credential here is
4648
- // exactly what static analysis (correctly, for *stored* passwords) flags. The
4649
- // only side-channel is whether the two keys share a length, which reveals
4650
- // nothing useful about a random secret. Once lengths match, the byte compare
4651
- // is constant-time and leaks no information about where they differ.
4652
- function safeKeyEqual(a, b) {
4653
- const x = Buffer.from(String(a || ''), 'utf8');
4654
- const y = Buffer.from(String(b || ''), 'utf8');
4655
- if (x.length === 0 || y.length === 0 || x.length !== y.length) return false;
4656
- return require('crypto').timingSafeEqual(x, y);
4657
- }
4658
-
4659
- function resolveKeyRole(key) {
4660
- const k = String(key || '').trim();
4661
- const adminKey = String(process.env.THUMBGATE_API_KEY || '').trim();
4662
- const operatorKey = String(process.env.THUMBGATE_OPERATOR_KEY || '').trim();
4663
- const reviewerKey = String(process.env.THUMBGATE_REVIEWER_KEY || '').trim();
4664
- const configured = [adminKey, operatorKey, reviewerKey].filter(Boolean);
4665
- if (configured.length === 0) return k ? 'dev' : null;
4666
- if (safeKeyEqual(k, adminKey)) return 'admin';
4667
- if (safeKeyEqual(k, operatorKey)) return 'operator';
4668
- if (safeKeyEqual(k, reviewerKey)) return 'reviewer';
4669
- return null;
4670
- }
4671
-
4672
3473
  /**
4673
3474
  * Admin-only guard for static THUMBGATE_API_KEY.
4674
3475
  * Billing keys are intentionally excluded from admin actions.
@@ -4769,42 +3570,6 @@ function normalizeJobIdFromPath(pathname, suffix = '') {
4769
3570
  return match ? decodeURIComponent(match[1]) : null;
4770
3571
  }
4771
3572
 
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
3573
  function normalizeDocumentIdFromPath(pathname) {
4809
3574
  const match = pathname.match(/^\/v1\/documents\/([^/]+)$/);
4810
3575
  return match ? decodeURIComponent(match[1]) : null;
@@ -4914,75 +3679,12 @@ function createApiServer() {
4914
3679
  tools: getPublicMcpTools(),
4915
3680
  },
4916
3681
  });
4917
- } else if (msg.method === 'tools/call') {
4918
- // Authenticated tool execution. Accept either an OAuth 2.1 access
4919
- // token (audience-bound to this MCP server, RFC 8707) or a raw
4920
- // ThumbGate API key, both via the Bearer header.
4921
- const bearer = extractBearerToken(req);
4922
- const resourceUrl = buildPublicUrl(hostedConfig, '/mcp');
4923
- const oauthSession = mcpOauth.resolveAccessToken(oauthStore, bearer);
4924
- // OAuth path: token must resolve AND be audience-bound to this server
4925
- // (RFC 8707). Raw-key path: only an exact match to a configured
4926
- // operator/admin key — never "any non-empty bearer".
4927
- const adminKey = String(process.env.THUMBGATE_API_KEY || '').trim();
4928
- const operatorKey = String(process.env.THUMBGATE_OPERATOR_KEY || '').trim();
4929
- const reviewerKey = String(process.env.THUMBGATE_REVIEWER_KEY || '').trim();
4930
- const rawKeyValid = Boolean(bearer) && (safeKeyEqual(bearer, adminKey) || safeKeyEqual(bearer, operatorKey) || safeKeyEqual(bearer, reviewerKey));
4931
- const authed = oauthSession
4932
- ? mcpOauth.tokenAudienceValid(oauthSession, resourceUrl)
4933
- : rawKeyValid;
4934
- if (!authed) {
4935
- res.writeHead(401, {
4936
- 'Content-Type': 'application/json',
4937
- // RFC 9728: point unauthenticated clients at the resource metadata.
4938
- 'WWW-Authenticate': `Bearer resource_metadata="${buildPublicUrl(hostedConfig, '/.well-known/oauth-protected-resource')}"`,
4939
- });
4940
- res.end(JSON.stringify({
4941
- jsonrpc: '2.0', id: msg.id,
4942
- error: { code: -32001, message: 'Authentication required. Use OAuth 2.1 (see /.well-known/oauth-protected-resource) or a ThumbGate API key.' },
4943
- }));
4944
- return;
4945
- }
4946
- // The reviewer credential (THUMBGATE_REVIEWER_KEY) is read-only: it may
4947
- // only invoke tools annotated readOnlyHint:true. This makes a credential
4948
- // safe to share (e.g. with a directory reviewer) without granting the
4949
- // ability to mutate shared server state.
4950
- const effectiveKey = oauthSession ? String(oauthSession.boundKey || '') : bearer;
4951
- const isReviewer = Boolean(reviewerKey) && safeKeyEqual(effectiveKey, reviewerKey);
4952
- if (isReviewer) {
4953
- const name = msg.params && msg.params.name;
4954
- const tool = MCP_TOOLS.find((t) => t.name === name);
4955
- const readOnly = Boolean(tool && tool.annotations && tool.annotations.readOnlyHint === true);
4956
- if (!readOnly) {
4957
- sendJson(res, 200, {
4958
- jsonrpc: '2.0', id: msg.id,
4959
- error: { code: -32002, message: `Tool "${name}" requires write access; the reviewer credential is read-only.` },
4960
- });
4961
- return;
4962
- }
4963
- }
4964
- (async () => {
4965
- try {
4966
- const { callTool } = require('../../adapters/mcp/server-stdio');
4967
- const name = msg.params && msg.params.name;
4968
- const args = (msg.params && msg.params.arguments) || {};
4969
- const result = await callTool(name, args);
4970
- sendJson(res, 200, {
4971
- jsonrpc: '2.0', id: msg.id,
4972
- result: { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }] },
4973
- });
4974
- } catch (err) {
4975
- sendJson(res, 200, {
4976
- jsonrpc: '2.0', id: msg.id,
4977
- result: { isError: true, content: [{ type: 'text', text: String(err && err.message || err) }] },
4978
- });
4979
- }
4980
- })();
4981
3682
  } else {
3683
+ // All other tool calls require auth — return method not found for unauthenticated
4982
3684
  sendJson(res, 200, {
4983
3685
  jsonrpc: '2.0',
4984
3686
  id: msg.id,
4985
- error: { code: -32601, message: `Method not found: ${msg.method}` },
3687
+ error: { code: -32601, message: 'Method requires authentication. Provide Bearer token.' },
4986
3688
  });
4987
3689
  }
4988
3690
  } catch (_e) {
@@ -5070,10 +3772,7 @@ function createApiServer() {
5070
3772
  });
5071
3773
  sendJson(res, 200, result);
5072
3774
  } catch (e) {
5073
- sendJson(res, 500, {
5074
- error: 'feedback submission failed',
5075
- message: e?.message || 'Unable to submit dashboard feedback.',
5076
- });
3775
+ sendJson(res, 500, { error: 'feedback submission failed' });
5077
3776
  }
5078
3777
  });
5079
3778
  return;
@@ -5262,7 +3961,7 @@ function createApiServer() {
5262
3961
  return;
5263
3962
  }
5264
3963
 
5265
- if (isGetLikeRequest && (pathname === '/.well-known/llms.txt' || pathname === '/llms.txt')) {
3964
+ if (isGetLikeRequest && pathname === '/.well-known/llms.txt') {
5266
3965
  const llmsTxtPath = path.join(__dirname, '..', '..', '.well-known', 'llms.txt');
5267
3966
  try {
5268
3967
  const content = fs.readFileSync(llmsTxtPath, 'utf8');
@@ -5273,17 +3972,6 @@ function createApiServer() {
5273
3972
  return;
5274
3973
  }
5275
3974
 
5276
- if (isGetLikeRequest && pathname === '/.well-known/agentic-verify.txt') {
5277
- const agenticVerifyPath = path.join(__dirname, '..', '..', '.well-known', 'agentic-verify.txt');
5278
- try {
5279
- const content = fs.readFileSync(agenticVerifyPath, 'utf8');
5280
- sendText(res, 200, content, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'public, max-age=3600' }, { headOnly: isHeadRequest });
5281
- } catch {
5282
- sendJson(res, 404, { error: 'agentic verification file not found' });
5283
- }
5284
- return;
5285
- }
5286
-
5287
3975
  if (isGetLikeRequest && pathname === '/sitemap.xml') {
5288
3976
  sendText(res, 200, renderSitemapXml(hostedConfig), {
5289
3977
  'Content-Type': 'application/xml; charset=utf-8',
@@ -5576,40 +4264,12 @@ async function addContext(){
5576
4264
  return;
5577
4265
  }
5578
4266
 
5579
- if (isGetLikeRequest && (pathname === '/about' || pathname === '/about.html')) {
5580
- try {
5581
- servePublicMarketingPage({
5582
- req,
5583
- res,
5584
- parsed,
5585
- hostedConfig,
5586
- isHeadRequest,
5587
- renderHtml: loadAboutPageHtml,
5588
- extraTelemetry: {
5589
- pageType: 'about',
5590
- },
5591
- });
5592
- } catch (err) {
5593
- sendText(res, 500, err.message || 'About page unavailable');
5594
- }
5595
- return;
5596
- }
5597
-
5598
4267
  if (isGetLikeRequest && (pathname === '/guide' || pathname === '/guide.html')) {
5599
4268
  try {
5600
- servePublicMarketingPage({
5601
- req,
5602
- res,
5603
- parsed,
5604
- hostedConfig,
5605
- isHeadRequest,
5606
- renderHtml: loadGuidePageHtml,
5607
- extraTelemetry: {
5608
- pageType: 'guide',
5609
- },
5610
- });
5611
- } catch (err) {
5612
- sendText(res, 500, err.message || 'Guide page unavailable');
4269
+ const html = fs.readFileSync(GUIDE_PAGE_PATH, 'utf-8');
4270
+ sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
4271
+ } catch {
4272
+ sendJson(res, 404, { error: 'Guide page not found' });
5613
4273
  }
5614
4274
  return;
5615
4275
  }
@@ -5624,28 +4284,6 @@ async function addContext(){
5624
4284
  return;
5625
4285
  }
5626
4286
 
5627
- if (isGetLikeRequest && (
5628
- pathname === '/chatgpt-app'
5629
- || pathname === '/chatgpt-app.html'
5630
- || pathname === '/chatgpt-plugin'
5631
- || pathname === '/chatgpt-plugin.html'
5632
- )) {
5633
- try {
5634
- servePublicMarketingPage({
5635
- req,
5636
- res,
5637
- parsed,
5638
- hostedConfig,
5639
- isHeadRequest,
5640
- renderHtml: () => fs.readFileSync(CHATGPT_APP_PAGE_PATH, 'utf-8'),
5641
- extraTelemetry: { pageType: 'chatgpt_app' },
5642
- });
5643
- } catch {
5644
- sendJson(res, 404, { error: 'ChatGPT app page not found' });
5645
- }
5646
- return;
5647
- }
5648
-
5649
4287
  if (isGetLikeRequest && (pathname === '/compare' || pathname === '/compare.html')) {
5650
4288
  try {
5651
4289
  const html = fs.readFileSync(COMPARE_PAGE_PATH, 'utf-8');
@@ -5669,19 +4307,10 @@ async function addContext(){
5669
4307
 
5670
4308
  if (isGetLikeRequest && (pathname === '/learn' || pathname === '/learn.html')) {
5671
4309
  try {
5672
- servePublicMarketingPage({
5673
- req,
5674
- res,
5675
- parsed,
5676
- hostedConfig,
5677
- isHeadRequest,
5678
- renderHtml: loadLearnPageHtml,
5679
- extraTelemetry: {
5680
- pageType: 'learn',
5681
- },
5682
- });
5683
- } catch (err) {
5684
- sendText(res, 500, err.message || 'Learn page unavailable');
4310
+ const html = fs.readFileSync(LEARN_PAGE_PATH, 'utf-8');
4311
+ sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
4312
+ } catch {
4313
+ sendJson(res, 404, { error: 'Learn page not found' });
5685
4314
  }
5686
4315
  return;
5687
4316
  }
@@ -5730,7 +4359,7 @@ async function addContext(){
5730
4359
  if (isGetLikeRequest && (pathname === '/numbers' || pathname === '/numbers.html')) {
5731
4360
  // Route through servePublicMarketingPage so landing_page_view telemetry
5732
4361
  // + funnel-events.jsonl `discovery/landing_view` get captured with UTM
5733
- // attribution — critical for DirectSocial social CTAs that target /numbers.
4362
+ // attribution — critical for Zernio social CTAs that target /numbers.
5734
4363
  try {
5735
4364
  servePublicMarketingPage({
5736
4365
  req,
@@ -5769,94 +4398,6 @@ async function addContext(){
5769
4398
  return;
5770
4399
  }
5771
4400
 
5772
- if (isGetLikeRequest && (pathname === '/agent-manager' || pathname === '/agent-manager.html')) {
5773
- // ICP landing page for the role Anthropic named (Agent Manager —
5774
- // hybrid PM/engineer DRI who owns CLAUDE.md hierarchy, plugin
5775
- // marketplace, permissions policy, and which skills ship). Routed
5776
- // through servePublicMarketingPage so arrivals via X/Bluesky/LinkedIn
5777
- // threads about the role capture UTM attribution and
5778
- // landing_page_view telemetry for downstream pilot-pipeline analysis.
5779
- try {
5780
- servePublicMarketingPage({
5781
- req,
5782
- res,
5783
- parsed,
5784
- hostedConfig,
5785
- isHeadRequest,
5786
- renderHtml: () => fs.readFileSync(path.join(PUBLIC_DIR, 'agent-manager.html'), 'utf-8'),
5787
- extraTelemetry: { pageType: 'agent_manager' },
5788
- });
5789
- } catch {
5790
- sendJson(res, 404, { error: 'Agent Manager page not found' });
5791
- }
5792
- return;
5793
- }
5794
-
5795
- if (isGetLikeRequest && (pathname === '/codex-enterprise' || pathname === '/codex-enterprise.html')) {
5796
- // Landing page riding the 2026-05-20 OpenAI×Dell Codex Enterprise
5797
- // partnership announcement. Dell-distributed Codex expands the TAM
5798
- // for ThumbGate's governance layer — capture every agent decision,
5799
- // promote repeat failures to PreToolUse gates, ship the audit trail
5800
- // procurement requires. Routed through servePublicMarketingPage so
5801
- // arrivals via the partnership news cycle capture UTM attribution
5802
- // and landing_page_view telemetry with pageType: 'codex_enterprise'.
5803
- try {
5804
- servePublicMarketingPage({
5805
- req,
5806
- res,
5807
- parsed,
5808
- hostedConfig,
5809
- isHeadRequest,
5810
- renderHtml: () => fs.readFileSync(path.join(PUBLIC_DIR, 'codex-enterprise.html'), 'utf-8'),
5811
- extraTelemetry: { pageType: 'codex_enterprise' },
5812
- });
5813
- } catch {
5814
- sendJson(res, 404, { error: 'Codex Enterprise page not found' });
5815
- }
5816
- return;
5817
- }
5818
-
5819
- if (isGetLikeRequest && (pathname === '/agents-cost-savings' || pathname === '/agents-cost-savings.html')) {
5820
- // FinOps-for-AI positioning page. Pairs with the `thumbgate cost` CLI
5821
- // shipped in #2281: the CLI prints the dollar amount, this page is
5822
- // the public-facing explanation of why "prevention" (ThumbGate's
5823
- // PreToolUse gates) is a distinct category from "reporting" (Finout,
5824
- // Helicone, Vantage, AgentOps). Reply-to-pitch surface for buyers
5825
- // who get a FinOps-for-AI marketing email and need a frame.
5826
- try {
5827
- servePublicMarketingPage({
5828
- req,
5829
- res,
5830
- parsed,
5831
- hostedConfig,
5832
- isHeadRequest,
5833
- renderHtml: () => fs.readFileSync(path.join(PUBLIC_DIR, 'agents-cost-savings.html'), 'utf-8'),
5834
- extraTelemetry: { pageType: 'agents_cost_savings' },
5835
- });
5836
- } catch {
5837
- sendJson(res, 404, { error: 'Agents Cost Savings page not found' });
5838
- }
5839
- return;
5840
- }
5841
-
5842
- if (isGetLikeRequest && (pathname === '/ai-malpractice-prevention' || pathname === '/ai-malpractice-prevention.html')) {
5843
- // Legal-vertical landing page (2026-05-21).
5844
- try {
5845
- servePublicMarketingPage({
5846
- req,
5847
- res,
5848
- parsed,
5849
- hostedConfig,
5850
- isHeadRequest,
5851
- renderHtml: () => fs.readFileSync(path.join(PUBLIC_DIR, 'ai-malpractice-prevention.html'), 'utf-8'),
5852
- extraTelemetry: { pageType: 'ai_malpractice_prevention' },
5853
- });
5854
- } catch {
5855
- sendJson(res, 404, { error: 'AI Malpractice Prevention page not found' });
5856
- }
5857
- return;
5858
- }
5859
-
5860
4401
  if (isGetLikeRequest && pathname === '/learn/learn.css') {
5861
4402
  try {
5862
4403
  const cssPath = path.join(LEARN_DIR, 'learn.css');
@@ -5872,13 +4413,13 @@ async function addContext(){
5872
4413
 
5873
4414
  if (isGetLikeRequest && pathname.startsWith('/learn/')) {
5874
4415
  try {
5875
- const articlePath = resolvePublicHtmlFile(LEARN_PAGE_PATHS_BY_SLUG, pathname.replace('/learn/', ''));
5876
- if (!articlePath) {
5877
- sendJson(res, 404, { error: 'Article not found' });
4416
+ const slug = normalizePublicPageSlug(pathname.replace('/learn/', ''));
4417
+ const articlePath = path.join(LEARN_DIR, `${slug}.html`);
4418
+ if (!articlePath.startsWith(LEARN_DIR)) {
4419
+ sendJson(res, 403, { error: 'Forbidden' });
5878
4420
  return;
5879
4421
  }
5880
- const requestHost = getSafePublicRequestHost(req);
5881
- const html = normalizePublicMarketingHtml(fs.readFileSync(articlePath, 'utf-8'), hostedConfig, requestHost);
4422
+ const html = fs.readFileSync(articlePath, 'utf-8');
5882
4423
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5883
4424
  } catch {
5884
4425
  sendJson(res, 404, { error: 'Article not found' });
@@ -5888,10 +4429,10 @@ async function addContext(){
5888
4429
 
5889
4430
  if (isGetLikeRequest && pathname.startsWith('/guides/')) {
5890
4431
  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);
5894
- const html = normalizePublicMarketingHtml(fs.readFileSync(guidePath, 'utf-8'), hostedConfig, requestHost);
4432
+ const slug = normalizePublicPageSlug(pathname.replace('/guides/', ''));
4433
+ const guidePath = path.join(GUIDES_DIR, `${slug}.html`);
4434
+ if (!guidePath.startsWith(GUIDES_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
4435
+ const html = fs.readFileSync(guidePath, 'utf-8');
5895
4436
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5896
4437
  } catch { sendJson(res, 404, { error: 'Guide not found' }); }
5897
4438
  return;
@@ -5899,10 +4440,10 @@ async function addContext(){
5899
4440
 
5900
4441
  if (isGetLikeRequest && pathname.startsWith('/compare/') && pathname !== '/compare') {
5901
4442
  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);
5905
- const html = normalizePublicMarketingHtml(fs.readFileSync(comparePath, 'utf-8'), hostedConfig, requestHost);
4443
+ const slug = normalizePublicPageSlug(pathname.replace('/compare/', ''));
4444
+ const comparePath = path.join(COMPARE_DIR, `${slug}.html`);
4445
+ if (!comparePath.startsWith(COMPARE_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
4446
+ const html = fs.readFileSync(comparePath, 'utf-8');
5906
4447
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5907
4448
  } catch { sendJson(res, 404, { error: 'Comparison not found' }); }
5908
4449
  return;
@@ -5910,10 +4451,10 @@ async function addContext(){
5910
4451
 
5911
4452
  if (isGetLikeRequest && pathname.startsWith('/use-cases/')) {
5912
4453
  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);
5916
- const html = normalizePublicMarketingHtml(fs.readFileSync(useCasePath, 'utf-8'), hostedConfig, requestHost);
4454
+ const slug = normalizePublicPageSlug(pathname.replace('/use-cases/', ''));
4455
+ const useCasePath = path.join(USE_CASES_DIR, `${slug}.html`);
4456
+ if (!useCasePath.startsWith(USE_CASES_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
4457
+ const html = fs.readFileSync(useCasePath, 'utf-8');
5917
4458
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
5918
4459
  } catch { sendJson(res, 404, { error: 'Use case not found' }); }
5919
4460
  return;
@@ -5930,18 +4471,6 @@ async function addContext(){
5930
4471
  return;
5931
4472
  }
5932
4473
 
5933
- if (isGetLikeRequest && pathname.startsWith('/media/')) {
5934
- const rel = pathname.slice('/media/'.length);
5935
- const mediaDir = path.join(PUBLIC_DIR, 'media');
5936
- const resolved = path.resolve(mediaDir, rel);
5937
- if (!resolved.startsWith(mediaDir + path.sep) && resolved !== mediaDir) {
5938
- sendJson(res, 403, { error: 'Forbidden' });
5939
- return;
5940
- }
5941
- serveStaticFile(res, resolved, { headOnly: isHeadRequest });
5942
- return;
5943
- }
5944
-
5945
4474
  if (isGetLikeRequest && (
5946
4475
  pathname === '/favicon.ico'
5947
4476
  || pathname === '/thumbgate-logo.png'
@@ -5960,7 +4489,7 @@ async function addContext(){
5960
4489
  version: pkg.version,
5961
4490
  status: 'ok',
5962
4491
  docs: 'https://github.com/IgorGanapolsky/ThumbGate',
5963
- endpoints: ['/health', '/dashboard', '/guide', '/chatgpt-app', '/codex-plugin', '/compare', '/learn', '/pricing', '/v1/feedback/capture', '/v1/feedback/stats', '/v1/feedback/summary', '/v1/lessons/search', '/v1/search', '/v1/documents', '/v1/documents/import', '/v1/documents/{documentId}', '/v1/dashboard', '/v1/dashboard/ai-inventory', '/v1/dashboard/render-spec', '/v1/decisions/evaluate', '/v1/decisions/outcome', '/v1/decisions/metrics', '/v1/settings/status', '/v1/dpo/export', '/v1/jobs', '/v1/jobs/harness', '/v1/analytics/databricks/export'],
4492
+ endpoints: ['/health', '/dashboard', '/guide', '/codex-plugin', '/compare', '/learn', '/v1/feedback/capture', '/v1/feedback/stats', '/v1/feedback/summary', '/v1/lessons/search', '/v1/search', '/v1/documents', '/v1/documents/import', '/v1/documents/{documentId}', '/v1/dashboard', '/v1/dashboard/render-spec', '/v1/decisions/evaluate', '/v1/decisions/outcome', '/v1/decisions/metrics', '/v1/settings/status', '/v1/dpo/export', '/v1/jobs', '/v1/jobs/harness', '/v1/analytics/databricks/export'],
5964
4493
  }, {}, {
5965
4494
  headOnly: isHeadRequest,
5966
4495
  });
@@ -5985,14 +4514,9 @@ async function addContext(){
5985
4514
  return;
5986
4515
  }
5987
4516
 
5988
- // HOTFIX 2026-06-04 accept ANY method (GET/HEAD/POST) on /checkout/pro
5989
- // to prevent the API-key guard from 401'ing real prospective customers
5990
- // whose forms or fetch() calls land via POST. Audit: 69 emails submitted
5991
- // → 0 paid because POST hit the auth gate. Query params still drive the
5992
- // Stripe session creation; POST bodies are ignored harmlessly.
5993
- if ((isGetLikeRequest || req.method === 'POST') && pathname === '/checkout/pro') {
4517
+ if (isGetLikeRequest && pathname === '/checkout/pro') {
5994
4518
  if (isHeadRequest) {
5995
- sendHtml(res, 200, '', {}, {
4519
+ sendText(res, 200, '', {}, {
5996
4520
  headOnly: true,
5997
4521
  });
5998
4522
  return;
@@ -6023,70 +4547,8 @@ async function addContext(){
6023
4547
  // because no real crawler appends customer_email to discovered URLs.
6024
4548
  const hasCustomerEmailHint = !!parsed?.searchParams?.has('customer_email');
6025
4549
  const botShouldBypass = !botClassification.isBot || hasCustomerEmailHint;
6026
- // 2026-06-04 audit: after the POST-401 fix, 3 of 4 fresh /checkout/pro
6027
- // sessions had customer_email=null in Stripe (zombie sessions with no
6028
- // recovery surface). Root cause: POSTs were auto-confirmed regardless of
6029
- // whether the email query param was present. Require email on POSTs too
6030
- // so emails-less POSTs fall through to the interstitial form instead of
6031
- // creating an un-recoverable Stripe session.
6032
- const isConfirmedCheckout = (req.method === 'POST' && hasCustomerEmailHint)
4550
+ const isConfirmedCheckout = req.method === 'POST'
6033
4551
  || (hasConfirmFlag && botShouldBypass);
6034
- // 2026-06-05 revenue bypass: env-gated direct-to-Stripe redirect.
6035
- // Live 30d billing showed 254 interstitial views → 1 Stripe click-through
6036
- // → 0 paid. When THUMBGATE_CHECKOUT_INTERSTITIAL_BYPASS=1 is set we
6037
- // route raw /checkout/pro GETs (no confirm=1, no POST) straight to the
6038
- // pro Stripe Payment Link, preserving UTM + attribution metadata via
6039
- // buildCheckoutFallbackUrl. Default-off; bot-deflection still applies
6040
- // (bot + no email hint still falls through to the existing interstitial).
6041
- const interstitialBypassEnabled = process.env.THUMBGATE_CHECKOUT_INTERSTITIAL_BYPASS === '1';
6042
- const interstitialSampleRate = normalizeCheckoutInterstitialSampleRate(
6043
- process.env.THUMBGATE_CHECKOUT_INTERSTITIAL_SAMPLE_RATE
6044
- );
6045
- const interstitialSampled = shouldSampleCheckoutInterstitial({
6046
- sampleRate: interstitialSampleRate,
6047
- traceId,
6048
- analyticsMetadata,
6049
- });
6050
- if (
6051
- !isConfirmedCheckout
6052
- && interstitialBypassEnabled
6053
- && req.method !== 'POST'
6054
- && botShouldBypass
6055
- && !interstitialSampled
6056
- ) {
6057
- // Always target the pro Stripe Payment Link directly. The
6058
- // hostedConfig.checkoutFallbackUrl (e.g. https://thumbgate.ai/go/pro)
6059
- // is a router that 302s back to /checkout/pro, which would create a
6060
- // redirect loop when bypass is on. Env override via
6061
- // THUMBGATE_CHECKOUT_PRO_STRIPE_URL is supported for future
6062
- // price-link rotation without a redeploy.
6063
- const bypassTarget = process.env.THUMBGATE_CHECKOUT_PRO_STRIPE_URL
6064
- || FIRST_FAILURE_RULE_CHECKOUT_URL;
6065
- appendBestEffortTelemetry(FEEDBACK_DIR, {
6066
- eventType: 'checkout_interstitial_bypass_redirect',
6067
- clientType: 'web',
6068
- traceId,
6069
- acquisitionId: analyticsMetadata.acquisitionId,
6070
- visitorId: analyticsMetadata.visitorId,
6071
- sessionId: analyticsMetadata.sessionId,
6072
- utmSource: analyticsMetadata.utmSource,
6073
- utmMedium: analyticsMetadata.utmMedium,
6074
- utmCampaign: analyticsMetadata.utmCampaign,
6075
- utmContent: analyticsMetadata.utmContent,
6076
- utmTerm: analyticsMetadata.utmTerm,
6077
- referrer: analyticsMetadata.referrer,
6078
- referrerHost: analyticsMetadata.referrerHost,
6079
- page: '/checkout/pro',
6080
- planId: analyticsMetadata.planId,
6081
- interstitialSampleRate,
6082
- }, req.headers, 'checkout_interstitial_bypass_redirect');
6083
- res.writeHead(302, {
6084
- ...responseHeaders,
6085
- Location: buildCheckoutFallbackUrl(bypassTarget, analyticsMetadata),
6086
- });
6087
- res.end();
6088
- return;
6089
- }
6090
4552
  // Plausible funnel event #1 of 3: page view. Fired before interstitial
6091
4553
  // deflection so we get the full top-of-funnel count, with isBot as a
6092
4554
  // prop so the dashboard can filter human vs. crawler traffic. Fire-and-forget.
@@ -6142,16 +4604,20 @@ async function addContext(){
6142
4604
  ctaId: analyticsMetadata.ctaId,
6143
4605
  ctaPlacement: analyticsMetadata.ctaPlacement,
6144
4606
  planId: analyticsMetadata.planId,
6145
- billingCycle: analyticsMetadata.billingCycle,
6146
- landingPath: analyticsMetadata.landingPath,
6147
4607
  isBot: botClassification.isBot ? 'true' : 'false',
6148
- interstitialSampled: interstitialSampled ? 'true' : 'false',
6149
- interstitialSampleRate,
6150
4608
  reason: botClassification.reason,
6151
4609
  }, req.headers, eventType);
6152
- const prefilledEmail = parsed?.searchParams?.get('customer_email') || '';
6153
- const html = renderCheckoutIntentPage(prefilledEmail, parsed, {
6154
- includeHiddenAttribution: !botClassification.isBot,
4610
+ const workflowIntakeHref = buildCheckoutIntentHref(`${hostedConfig.appOrigin}/#workflow-sprint-intake`, analyticsMetadata, {
4611
+ utmMedium: 'checkout_interstitial_recovery',
4612
+ utmCampaign: analyticsMetadata.utmCampaign || 'checkout_interstitial_workflow_sprint',
4613
+ ctaId: 'checkout_interstitial_workflow_sprint_intake',
4614
+ ctaPlacement: 'checkout_interstitial',
4615
+ planId: 'team',
4616
+ });
4617
+ const html = renderCheckoutIntentPage({
4618
+ confirmHref: buildCheckoutConfirmHref(parsed),
4619
+ workflowIntakeHref,
4620
+ botClassification,
6155
4621
  });
6156
4622
  sendHtml(res, 200, html, responseHeaders);
6157
4623
  return;
@@ -6415,13 +4881,10 @@ async function addContext(){
6415
4881
  // Public-facing broker lead-flow audit landing page. Wedge for the
6416
4882
  // real-estate broker outreach. Static HTML served from src/api/static.
6417
4883
  try {
6418
- const host = getSafePublicRequestHost(req);
6419
- const html = normalizePublicMarketingHtml(fillTemplate(fs.readFileSync(
4884
+ const html = fs.readFileSync(
6420
4885
  path.resolve(__dirname, '../../assets/static/broker-audit.html'),
6421
4886
  'utf8'
6422
- ), {
6423
- '__POSTHOG_API_KEY__': hostedConfig.posthogApiKey || '',
6424
- }), hostedConfig, host);
4887
+ );
6425
4888
  if (isHeadRequest) {
6426
4889
  sendHtml(res, 200, html, {}, { headOnly: true });
6427
4890
  return;
@@ -6441,117 +4904,6 @@ async function addContext(){
6441
4904
  return;
6442
4905
  }
6443
4906
 
6444
- // OAuth 2.1 discovery metadata for the remote MCP connector (RFC 9728 / RFC 8414).
6445
- // Lets Claude discover how to authenticate before calling authenticated tools.
6446
- if (isGetLikeRequest && pathname === '/.well-known/oauth-protected-resource') {
6447
- sendJson(res, 200, mcpOauth.buildProtectedResourceMetadata(buildPublicUrl(hostedConfig, '')), {}, {
6448
- headOnly: isHeadRequest,
6449
- });
6450
- return;
6451
- }
6452
- if (isGetLikeRequest && (pathname === '/.well-known/oauth-authorization-server' || pathname === '/.well-known/openid-configuration')) {
6453
- sendJson(res, 200, mcpOauth.buildAuthServerMetadata(buildPublicUrl(hostedConfig, '')), {}, {
6454
- headOnly: isHeadRequest,
6455
- });
6456
- return;
6457
- }
6458
-
6459
- // --- OAuth 2.1 (PKCE) endpoints for the remote MCP connector ---
6460
- // RFC 7591 Dynamic Client Registration.
6461
- if (req.method === 'POST' && pathname === '/oauth/register') {
6462
- let body = '';
6463
- req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
6464
- req.on('end', () => {
6465
- let parsed = {};
6466
- try { parsed = body ? JSON.parse(body) : {}; } catch { /* ignore */ }
6467
- const reg = mcpOauth.registerClient(oauthStore, parsed);
6468
- if (reg.error) { sendJson(res, 400, reg); return; }
6469
- sendJson(res, 201, reg);
6470
- });
6471
- return;
6472
- }
6473
- // Authorization endpoint: GET renders consent, POST issues the code.
6474
- if (pathname === '/oauth/authorize') {
6475
- if (isGetLikeRequest) {
6476
- const authRequestToken = createPendingOauthAuthorizeRequest(
6477
- getOauthAuthorizeParamsFromQuery(parsed.searchParams)
6478
- );
6479
- const html = `<!doctype html><html><head><meta charset="utf-8"><title>Authorize ThumbGate</title>
6480
- <style>body{font:15px system-ui;margin:0;background:#0b0b0c;color:#eee;display:flex;min-height:100vh;align-items:center;justify-content:center}
6481
- .card{background:#161618;border:1px solid #2a2a2e;border-radius:12px;padding:28px;max-width:420px}
6482
- input[type=password]{width:100%;padding:10px;margin:8px 0 16px;border-radius:8px;border:1px solid #2a2a2e;background:#0b0b0c;color:#eee}
6483
- button{width:100%;padding:11px;border-radius:8px;border:0;background:#10b981;color:#04120c;font-weight:600;cursor:pointer}
6484
- a{color:#8b9}</style></head><body><form class="card" method="post" action="/oauth/authorize">
6485
- <h2>Authorize Claude → ThumbGate</h2>
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>
6487
- <input type="hidden" name="auth_request_token" value="${escapeHtmlAttribute(authRequestToken)}">
6488
- <input type="password" name="api_key" placeholder="ThumbGate API key" autocomplete="off" required>
6489
- <button type="submit" name="approve" value="yes">Approve</button>
6490
- </form></body></html>`;
6491
- sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
6492
- return;
6493
- }
6494
- if (req.method === 'POST') {
6495
- let body = '';
6496
- req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
6497
- req.on('end', () => {
6498
- const form = new URLSearchParams(body);
6499
- const authorizationParams = getOauthAuthorizeParamsFromForm(form, hostedConfig);
6500
- const redirectUri = authorizationParams.redirectUri;
6501
- const state = authorizationParams.state;
6502
- // Validate the presented ThumbGate key before issuing a code. When keys
6503
- // are configured (production) the key MUST match a configured admin /
6504
- // operator / reviewer key — otherwise OAuth would authenticate nobody.
6505
- // In insecure/dev mode (no keys configured) any non-empty key is accepted.
6506
- if (!resolveKeyRole(form.get('api_key') || '')) {
6507
- sendJson(res, 400, { error: 'access_denied', error_description: 'invalid ThumbGate API key' });
6508
- return;
6509
- }
6510
- const issued = mcpOauth.createAuthorizationCode(oauthStore, {
6511
- clientId: authorizationParams.clientId,
6512
- redirectUri,
6513
- codeChallenge: authorizationParams.codeChallenge,
6514
- codeChallengeMethod: authorizationParams.codeChallengeMethod,
6515
- scope: authorizationParams.scope,
6516
- resource: authorizationParams.resource || buildPublicUrl(hostedConfig, '/mcp'),
6517
- boundKey: form.get('api_key') || '',
6518
- state,
6519
- });
6520
- if (issued.error) {
6521
- sendJson(res, 400, { error: issued.error, error_description: issued.error_description });
6522
- return;
6523
- }
6524
- const sep = redirectUri.includes('?') ? '&' : '?';
6525
- const loc = `${redirectUri}${sep}code=${encodeURIComponent(issued.code)}${state ? `&state=${encodeURIComponent(state)}` : ''}`;
6526
- res.writeHead(302, { Location: loc });
6527
- res.end();
6528
- });
6529
- return;
6530
- }
6531
- }
6532
- // Token endpoint (authorization_code + PKCE).
6533
- if (req.method === 'POST' && pathname === '/oauth/token') {
6534
- let body = '';
6535
- req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
6536
- req.on('end', () => {
6537
- const form = new URLSearchParams(body);
6538
- if (form.get('grant_type') !== 'authorization_code') {
6539
- sendJson(res, 400, { error: 'unsupported_grant_type' });
6540
- return;
6541
- }
6542
- const tok = mcpOauth.exchangeCode(oauthStore, {
6543
- code: form.get('code') || '',
6544
- codeVerifier: form.get('code_verifier') || '',
6545
- clientId: form.get('client_id') || '',
6546
- redirectUri: form.get('redirect_uri') || '',
6547
- resource: form.get('resource') || undefined,
6548
- });
6549
- if (tok.error) { sendJson(res, 400, tok); return; }
6550
- sendJson(res, 200, tok, { 'Cache-Control': 'no-store' });
6551
- });
6552
- return;
6553
- }
6554
-
6555
4907
  if (isGetLikeRequest && pathname === '/.well-known/mcp/tools.json') {
6556
4908
  sendJson(res, 200, {
6557
4909
  name: 'thumbgate',
@@ -6664,57 +5016,40 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
6664
5016
  // was down, when feedback-dir was unwritable, when env was misconfigured.
6665
5017
  // The fix is shallow but meaningful: probe each critical subsystem and
6666
5018
  // surface failures with HTTP 503 + a per-check breakdown.
6667
- // Tiered failure classification — not all degradations should kill the
6668
- // container. A *service-failing* check (feedback dir unwritable,
6669
- // appOrigin missing) returns 503 → Railway's healthcheck fails → SIGTERM
6670
- // → restart loop → outage (this exact failure mode took prod down
6671
- // 2026-05-21 18:21Z → 19:30Z when BUILD_METADATA.buildSha came up empty
6672
- // after a Railway env-var cleanup). A *telemetry-degraded* check (missing
6673
- // buildSha) returns 200 with `degraded: true` so observability stays
6674
- // visible but Railway doesn't kill an otherwise-healthy container over
6675
- // a SHA gap.
6676
5019
  const checks = {};
6677
- let failing = false; // any service-failing check → 503
6678
- let degraded = false; // any telemetry-degraded check → 200 + flag
5020
+ let allOk = true;
6679
5021
 
6680
5022
  // Check 1: feedback dir exists and is writable.
6681
- // SERVICE-FAILING — if we can't write feedback, the API can't function.
6682
5023
  try {
6683
5024
  const { FEEDBACK_DIR } = getFeedbackPaths();
6684
5025
  fs.accessSync(FEEDBACK_DIR, fs.constants.W_OK);
6685
5026
  checks.feedbackDir = { ok: true };
6686
5027
  } catch (err) {
6687
- checks.feedbackDir = { ok: false, error: err?.code || 'inaccessible', severity: 'failing' };
6688
- failing = true;
5028
+ checks.feedbackDir = { ok: false, error: err?.code || 'inaccessible' };
5029
+ allOk = false;
6689
5030
  }
6690
5031
 
6691
5032
  // Check 2: hosted config resolves the canonical app origin.
6692
- // SERVICE-FAILING — if appOrigin is missing/empty, redirects + checkout
6693
- // flow break silently.
5033
+ // If appOrigin is missing/empty, redirects + checkout flow break silently.
6694
5034
  if (hostedConfig?.appOrigin) {
6695
5035
  checks.hostedConfig = { ok: true };
6696
5036
  } else {
6697
- checks.hostedConfig = { ok: false, error: 'missing_appOrigin', severity: 'failing' };
6698
- failing = true;
5037
+ checks.hostedConfig = { ok: false, error: 'missing_appOrigin' };
5038
+ allOk = false;
6699
5039
  }
6700
5040
 
6701
- // Check 3: build metadata loaded.
6702
- // TELEMETRY-DEGRADED observability gap, not a runtime outage. The
6703
- // container still serves requests fine; we just can't tag responses with
6704
- // the deployed SHA. Surfaces the gap to monitors via `degraded: true`
6705
- // without triggering Railway's SIGTERM-on-503 loop.
5041
+ // Check 3: build metadata loaded. If BUILD_METADATA.buildSha is empty,
5042
+ // Railway didn't inject the deploy SHA observability is degraded.
6706
5043
  if (BUILD_METADATA?.buildSha) {
6707
5044
  checks.buildMetadata = { ok: true };
6708
5045
  } else {
6709
- checks.buildMetadata = { ok: false, error: 'missing_buildSha', severity: 'degraded' };
6710
- degraded = true;
5046
+ checks.buildMetadata = { ok: false, error: 'missing_buildSha' };
5047
+ allOk = false;
6711
5048
  }
6712
5049
 
6713
- const statusCode = failing ? 503 : 200;
6714
- const status = failing ? 'failing' : (degraded ? 'degraded' : 'ok');
5050
+ const statusCode = allOk ? 200 : 503;
6715
5051
  sendJson(res, statusCode, {
6716
- status,
6717
- degraded: degraded || failing,
5052
+ status: allOk ? 'ok' : 'degraded',
6718
5053
  version: pkg.version,
6719
5054
  buildSha: BUILD_METADATA.buildSha,
6720
5055
  uptime: process.uptime(),
@@ -6784,7 +5119,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
6784
5119
  evidence: [err?.message ? err.message : 'unknown_error'],
6785
5120
  },
6786
5121
  });
6787
- } catch {
5122
+ } catch (_) {
6788
5123
  // Telemetry is best-effort and must never fail the caller.
6789
5124
  }
6790
5125
  }
@@ -6812,25 +5147,15 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
6812
5147
  const bd = require('../../scripts/bot-detector');
6813
5148
  const { FEEDBACK_DIR: metricsDir } = getFeedbackPaths();
6814
5149
  const telemetryPath = path.join(metricsDir, 'telemetry-pings.jsonl');
6815
-
6816
- // Cache parsed entries for 60s to avoid re-parsing 72K+ JSON lines per request
6817
- const cacheKey = '_metricsRealCache';
6818
- const cacheTTL = 60_000;
6819
- let entries;
6820
- if (global[cacheKey] && (Date.now() - global[cacheKey].ts) < cacheTTL) {
6821
- entries = global[cacheKey].entries;
6822
- } else {
6823
- entries = [];
6824
- try {
6825
- if (fs.existsSync(telemetryPath)) {
6826
- entries = fs.readFileSync(telemetryPath, 'utf8')
6827
- .split('\n').filter(Boolean)
6828
- .map(l => { try { return JSON.parse(l); } catch(_e) { return null; } })
6829
- .filter(Boolean);
6830
- }
6831
- } catch { entries = []; }
6832
- global[cacheKey] = { entries, ts: Date.now() };
6833
- }
5150
+ let entries = [];
5151
+ try {
5152
+ if (fs.existsSync(telemetryPath)) {
5153
+ entries = fs.readFileSync(telemetryPath, 'utf8')
5154
+ .split('\n').filter(Boolean)
5155
+ .map(l => { try { return JSON.parse(l); } catch(_e) { return null; } })
5156
+ .filter(Boolean);
5157
+ }
5158
+ } catch { entries = []; }
6834
5159
 
6835
5160
  const now = Date.now();
6836
5161
  const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000;
@@ -6859,52 +5184,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
6859
5184
  byVisitorType[e.visitorType] = (byVisitorType[e.visitorType] || 0) + 1;
6860
5185
  });
6861
5186
 
6862
- // ---------------------------------------------------------------
6863
- // Active user analytics: group by installId to distinguish real
6864
- // users from bots/mirrors. "Active" = performed a meaningful CLI
6865
- // action (capture, recall, search, gate-check, stats) — not just init.
6866
- // ---------------------------------------------------------------
6867
- // Only events that prove a human used the product beyond init.
6868
- // cli_pro_view excluded: browsing the upgrade page is not "using" the product.
6869
- // cli_gate_check excluded: runs as subprocess, never fires trackEvent().
6870
- // cli_search excluded: event name doesn't exist in production telemetry.
6871
- const ACTIVE_EVENTS = new Set([
6872
- 'cli_capture', 'cli_recall', 'cli_stats',
6873
- 'activation_first_rule_promoted',
6874
- ]);
6875
- const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
6876
- const last30 = classified.filter(e => {
6877
- const ts = e.timestamp || e.receivedAt;
6878
- return ts && new Date(ts).getTime() > thirtyDaysAgo;
6879
- });
6880
-
6881
- // Per-install activity (all-time and recent windows)
6882
- const activeInstalls7d = new Set();
6883
- const activeInstalls30d = new Set();
6884
- const allTimeActiveInstalls = new Set();
6885
- const uniqueSessions7d = new Set();
6886
- const uniqueSessions30d = new Set();
6887
-
6888
- for (const e of classified) {
6889
- if (!e.installId) continue;
6890
- const et = e.eventType || '';
6891
- if (!ACTIVE_EVENTS.has(et)) continue;
6892
- if (e.visitorType === 'bot' || e.visitorType === 'ci') continue;
6893
- allTimeActiveInstalls.add(e.installId);
6894
- }
6895
- for (const e of recent) {
6896
- if (!e.installId) continue;
6897
- if (e.visitorType === 'bot' || e.visitorType === 'ci') continue;
6898
- if (ACTIVE_EVENTS.has(e.eventType || '')) activeInstalls7d.add(e.installId);
6899
- if (e.sessionId) uniqueSessions7d.add(e.sessionId);
6900
- }
6901
- for (const e of last30) {
6902
- if (!e.installId) continue;
6903
- if (e.visitorType === 'bot' || e.visitorType === 'ci') continue;
6904
- if (ACTIVE_EVENTS.has(e.eventType || '')) activeInstalls30d.add(e.installId);
6905
- if (e.sessionId) uniqueSessions30d.add(e.sessionId);
6906
- }
6907
-
6908
5187
  sendJson(res, 200, {
6909
5188
  allTime: {
6910
5189
  total: classified.length,
@@ -6913,12 +5192,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
6913
5192
  owner: classified.filter(e => e.visitorType === 'owner').length,
6914
5193
  ci: classified.filter(e => e.visitorType === 'ci').length,
6915
5194
  uniqueInstalls: uniqueInstallIds.size,
6916
- activeInstalls: allTimeActiveInstalls.size,
6917
- },
6918
- last30Days: {
6919
- uniqueInstalls: new Set(last30.filter(e => e.installId).map(e => e.installId)).size,
6920
- activeInstalls: activeInstalls30d.size,
6921
- uniqueSessions: uniqueSessions30d.size,
6922
5195
  },
6923
5196
  last7Days: {
6924
5197
  total: recent.length,
@@ -6927,8 +5200,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
6927
5200
  owner: recent.filter(e => e.visitorType === 'owner').length,
6928
5201
  ci: recent.filter(e => e.visitorType === 'ci').length,
6929
5202
  uniqueInstalls: recentInstallIds.size,
6930
- activeInstalls: activeInstalls7d.size,
6931
- uniqueSessions: uniqueSessions7d.size,
6932
5203
  },
6933
5204
  byEventType,
6934
5205
  byVisitorType,
@@ -6993,7 +5264,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
6993
5264
  source,
6994
5265
  telemetry: { rows: [], truncated: false, totalAfterSince: 0 },
6995
5266
  funnel: { rows: [], truncated: false, totalAfterSince: 0 },
6996
- journeySummary: null,
6997
5267
  };
6998
5268
 
6999
5269
  function readJsonlSince(p) {
@@ -7019,177 +5289,29 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
7019
5289
  const tp = path.join(exportDir, 'telemetry-pings.jsonl');
7020
5290
  const all = readJsonlSince(tp);
7021
5291
  result.telemetry.totalAfterSince = all.length;
7022
- result.telemetry.rows = all.slice(-limit);
7023
- result.telemetry.truncated = all.length > limit;
7024
- }
7025
-
7026
- if (wantFunnel) {
7027
- // Use the canonical funnel ledger path from scripts/billing.js so
7028
- // any test override (THUMBGATE_FUNNEL_LEDGER_PATH / _TEST_FUNNEL_LEDGER_PATH)
7029
- // resolves correctly. Falls back to the feedback dir's funnel-events.jsonl.
7030
- let funnelPath;
7031
- try {
7032
- const billing = require('../../scripts/billing');
7033
- funnelPath = (billing._FUNNEL_LEDGER_PATH && billing._FUNNEL_LEDGER_PATH())
7034
- || path.join(exportDir, 'funnel-events.jsonl');
7035
- } catch {
7036
- funnelPath = path.join(exportDir, 'funnel-events.jsonl');
7037
- }
7038
- const all = readJsonlSince(funnelPath);
7039
- result.funnel.totalAfterSince = all.length;
7040
- result.funnel.rows = all.slice(-limit);
7041
- result.funnel.truncated = all.length > limit;
7042
- }
7043
-
7044
- try {
7045
- const { buildVisitorJourneySummary } = require('../../scripts/visitor-journey');
7046
- result.journeySummary = buildVisitorJourneySummary({
7047
- telemetryRows: wantTelemetry ? result.telemetry.rows : [],
7048
- funnelRows: wantFunnel ? result.funnel.rows : [],
7049
- limit: Math.min(limit, 500),
7050
- });
7051
- } catch (err) {
7052
- result.journeySummary = {
7053
- error: err && err.message ? err.message : 'journey_summary_unavailable',
7054
- };
7055
- }
7056
-
7057
- sendJson(res, 200, result);
7058
- return;
7059
- }
7060
-
7061
- if (req.method === 'OPTIONS' && pathname === '/v1/marketing/install-email') {
7062
- // CORS preflight for the npm postinstall email-capture endpoint.
7063
- // The endpoint is called from `npx thumbgate subscribe <email>` so
7064
- // the CLI gets the right CORS headers if it ever hits the proxy
7065
- // path; in practice CLI hits the server directly so this is mostly
7066
- // defense-in-depth.
7067
- sendPublicBillingPreflight(res);
7068
- return;
7069
- }
7070
-
7071
- if (req.method === 'POST' && pathname === '/v1/marketing/install-email') {
7072
- // Email-capture wedge for npm installers. The `subscribe` CLI
7073
- // subcommand POSTs { email, source, installId, cliVersion } here;
7074
- // we validate the email, persist it to a dedicated capture ledger
7075
- // (telemetry sanitizer would strip the email as PII), emit a
7076
- // privacy-clean telemetry ping for funnel attribution, and fire
7077
- // a Resend newsletter welcome (if RESEND_API_KEY is configured).
7078
- // No auth required — this is a public opt-in surface.
7079
- const { FEEDBACK_DIR } = getFeedbackPaths();
7080
- let body = '';
7081
- let total = 0;
7082
- let oversize = false;
7083
- const MAX_BODY = 2048;
7084
- req.on('data', (chunk) => {
7085
- total += chunk.length;
7086
- if (total > MAX_BODY) {
7087
- oversize = true;
7088
- return; // stop appending; let 'end' fire naturally to send 413
7089
- }
7090
- body += chunk;
7091
- });
7092
- req.on('end', async () => {
7093
- if (oversize) {
7094
- sendJson(res, 413, { error: 'payload_too_large' });
7095
- return;
7096
- }
7097
- let parsed;
7098
- try {
7099
- parsed = body ? JSON.parse(body) : {};
7100
- } catch {
7101
- sendJson(res, 400, { error: 'invalid_json' });
7102
- return;
7103
- }
7104
- const rawEmail = typeof parsed.email === 'string' ? parsed.email.trim() : '';
7105
- // RFC 5321-bounded email shape — same regex shape as the CLI side
7106
- // to keep the contract honest.
7107
- const emailValid = /^[^\s@]{1,64}@[^\s@]{1,255}\.[^\s@]{1,64}$/.test(rawEmail);
7108
- if (!emailValid) {
7109
- sendJson(res, 400, { error: 'invalid_email' });
7110
- return;
7111
- }
7112
- const source = typeof parsed.source === 'string' && parsed.source.length <= 64
7113
- ? parsed.source
7114
- : 'cli_subscribe';
7115
- const installId = typeof parsed.installId === 'string' && parsed.installId.length <= 128
7116
- ? parsed.installId
7117
- : null;
7118
- const cliVersion = typeof parsed.cliVersion === 'string' && parsed.cliVersion.length <= 32
7119
- ? parsed.cliVersion
7120
- : null;
7121
-
7122
- // Persist the capture to a dedicated ledger. The standard
7123
- // telemetry sanitizer in scripts/telemetry-analytics.js
7124
- // intentionally strips arbitrary fields (PII protection), so
7125
- // we cannot rely on appendBestEffortTelemetry to preserve the
7126
- // email. The capture ledger lives alongside other feedback
7127
- // artifacts and is the source of truth for the marketing drip.
7128
- try {
7129
- const fsModule = require('node:fs');
7130
- const pathModule = require('node:path');
7131
- const captureDir = pathModule.resolve(FEEDBACK_DIR);
7132
- fsModule.mkdirSync(captureDir, { recursive: true });
7133
- const capturePath = pathModule.join(captureDir, 'marketing-install-emails.jsonl');
7134
- fsModule.appendFileSync(capturePath, JSON.stringify({
7135
- capturedAt: new Date().toISOString(),
7136
- email: rawEmail,
7137
- source,
7138
- installId,
7139
- cliVersion,
7140
- remoteAddr: req.socket?.remoteAddress || null,
7141
- userAgent: req.headers['user-agent'] || null,
7142
- }) + '\n', 'utf-8');
7143
- } catch (err) {
7144
- // Capture failure is recoverable — we still want to fire the
7145
- // welcome email and surface the failure to ops via diagnostic.
7146
- try {
7147
- const { appendDiagnosticRecord } = require('../../scripts/feedback-loop');
7148
- appendDiagnosticRecord({
7149
- source: 'install_email_capture',
7150
- step: 'capture_persist',
7151
- context: 'failed to persist install-email capture to ledger',
7152
- metadata: { error: err?.message || 'unknown' },
7153
- });
7154
- } catch {}
7155
- }
5292
+ result.telemetry.rows = all.slice(-limit);
5293
+ result.telemetry.truncated = all.length > limit;
5294
+ }
7156
5295
 
7157
- // Privacy-clean telemetry ping for funnel attribution (no email).
7158
- appendBestEffortTelemetry(FEEDBACK_DIR, {
7159
- eventType: 'marketing_install_email_captured',
7160
- clientType: 'cli',
7161
- source,
7162
- installId,
7163
- cliVersion,
7164
- utmSource: source,
7165
- utmMedium: 'npm_postinstall',
7166
- utmCampaign: 'install_email_capture',
7167
- }, req.headers, 'marketing_install_email_captured');
7168
-
7169
- // Fire Resend welcome. If RESEND_API_KEY is unset the mailer
7170
- // returns { sent: false, reason: 'no_api_key' } and the
7171
- // capture still succeeds — the operator can drip later from
7172
- // the captured ledger.
7173
- let mailerResult = { sent: false, reason: 'not_attempted' };
5296
+ if (wantFunnel) {
5297
+ // Use the canonical funnel ledger path from scripts/billing.js so
5298
+ // any test override (THUMBGATE_FUNNEL_LEDGER_PATH / _TEST_FUNNEL_LEDGER_PATH)
5299
+ // resolves correctly. Falls back to the feedback dir's funnel-events.jsonl.
5300
+ let funnelPath;
7174
5301
  try {
7175
- const { sendNewsletterWelcomeEmail } = require('../../scripts/mailer');
7176
- mailerResult = await sendNewsletterWelcomeEmail({
7177
- to: rawEmail,
7178
- source,
7179
- installId,
7180
- cliVersion,
7181
- });
7182
- } catch (err) {
7183
- mailerResult = { sent: false, reason: `mailer_error:${err.message || 'unknown'}` };
5302
+ const billing = require('../../scripts/billing');
5303
+ funnelPath = (billing._FUNNEL_LEDGER_PATH && billing._FUNNEL_LEDGER_PATH())
5304
+ || path.join(exportDir, 'funnel-events.jsonl');
5305
+ } catch {
5306
+ funnelPath = path.join(exportDir, 'funnel-events.jsonl');
7184
5307
  }
5308
+ const all = readJsonlSince(funnelPath);
5309
+ result.funnel.totalAfterSince = all.length;
5310
+ result.funnel.rows = all.slice(-limit);
5311
+ result.funnel.truncated = all.length > limit;
5312
+ }
7185
5313
 
7186
- sendJson(res, 200, {
7187
- ok: true,
7188
- captured: true,
7189
- mailerSent: !!mailerResult.sent,
7190
- mailerReason: mailerResult.reason || null,
7191
- });
7192
- });
5314
+ sendJson(res, 200, result);
7193
5315
  return;
7194
5316
  }
7195
5317
 
@@ -7198,130 +5320,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
7198
5320
  return;
7199
5321
  }
7200
5322
 
7201
- if (req.method === 'OPTIONS' && pathname === '/v1/intake/broker-audit') {
7202
- sendPublicBillingPreflight(res);
7203
- return;
7204
- }
7205
-
7206
- if (req.method === 'POST' && pathname === '/v1/intake/broker-audit') {
7207
- const { FEEDBACK_DIR } = getFeedbackPaths();
7208
- const traceId = createTraceId('broker_audit');
7209
- const journeyState = resolveJourneyState(req, parsed);
7210
- const referrerAttribution = buildReferrerAttribution(req);
7211
- const contentType = String(req.headers['content-type'] || '').toLowerCase();
7212
- const isFormSubmission = contentType.includes('application/x-www-form-urlencoded');
7213
- try {
7214
- const body = isFormSubmission
7215
- ? await parseFormBody(req, 16 * 1024)
7216
- : await parseJsonBody(req, 16 * 1024);
7217
- const lead = appendBrokerAuditLead(FEEDBACK_DIR, {
7218
- ...body,
7219
- traceId: body.traceId || traceId,
7220
- acquisitionId: body.acquisitionId || journeyState.acquisitionId,
7221
- visitorId: body.visitorId || journeyState.visitorId,
7222
- sessionId: body.sessionId || journeyState.sessionId,
7223
- page: body.page || referrerAttribution.page || '/broker-audit',
7224
- landingPath: body.landingPath || referrerAttribution.landingPath || '/broker-audit',
7225
- ctaId: body.ctaId || 'broker_audit_form',
7226
- ctaPlacement: body.ctaPlacement || 'broker_audit',
7227
- source: body.source || body.utmSource || referrerAttribution.source || 'website',
7228
- utmSource: body.utmSource || body.source || referrerAttribution.utmSource || 'website',
7229
- utmMedium: body.utmMedium || referrerAttribution.utmMedium || 'broker_audit',
7230
- utmCampaign: body.utmCampaign || referrerAttribution.utmCampaign || 'broker_audit_free',
7231
- utmContent: body.utmContent || referrerAttribution.utmContent || null,
7232
- utmTerm: body.utmTerm || referrerAttribution.utmTerm || null,
7233
- referrerHost: body.referrerHost || referrerAttribution.referrerHost || null,
7234
- referrer: body.referrer || referrerAttribution.referrer || null,
7235
- });
7236
-
7237
- appendBestEffortTelemetry(FEEDBACK_DIR, {
7238
- eventType: 'broker_audit_lead_submitted',
7239
- clientType: 'web',
7240
- traceId: lead.attribution.traceId,
7241
- acquisitionId: lead.attribution.acquisitionId,
7242
- visitorId: lead.attribution.visitorId,
7243
- sessionId: lead.attribution.sessionId,
7244
- source: lead.attribution.source,
7245
- utmSource: lead.attribution.utmSource,
7246
- utmMedium: lead.attribution.utmMedium,
7247
- utmCampaign: lead.attribution.utmCampaign,
7248
- utmContent: lead.attribution.utmContent,
7249
- utmTerm: lead.attribution.utmTerm,
7250
- ctaId: lead.attribution.ctaId,
7251
- ctaPlacement: lead.attribution.ctaPlacement,
7252
- page: lead.attribution.page,
7253
- landingPath: lead.attribution.landingPath,
7254
- referrerHost: lead.attribution.referrerHost,
7255
- referrer: lead.attribution.referrer,
7256
- }, req.headers, 'broker_audit_lead_submitted');
7257
-
7258
- if (isFormSubmission && !wantsJson(req, parsed)) {
7259
- sendHtml(
7260
- res,
7261
- 201,
7262
- renderBrokerAuditIntakeResultPage(hostedConfig, {
7263
- title: 'Broker audit request received',
7264
- detail: 'Your free broker lead-flow audit request is captured. The next step is the 1-page PDF with the specific lead leaks.',
7265
- leadId: lead.leadId,
7266
- }),
7267
- journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}
7268
- );
7269
- return;
7270
- }
7271
-
7272
- sendJson(res, 201, {
7273
- ok: true,
7274
- leadId: lead.leadId,
7275
- status: lead.status,
7276
- offer: lead.offer,
7277
- nextStep: 'deliver_1_page_pdf',
7278
- }, {
7279
- ...getPublicBillingHeaders(lead.attribution.traceId),
7280
- ...(journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}),
7281
- });
7282
- } catch (err) {
7283
- appendBestEffortTelemetry(FEEDBACK_DIR, {
7284
- eventType: 'broker_audit_lead_failed',
7285
- clientType: 'web',
7286
- traceId,
7287
- acquisitionId: journeyState.acquisitionId,
7288
- visitorId: journeyState.visitorId,
7289
- sessionId: journeyState.sessionId,
7290
- source: referrerAttribution.source || 'website',
7291
- utmSource: referrerAttribution.utmSource || 'website',
7292
- utmMedium: referrerAttribution.utmMedium || 'broker_audit',
7293
- utmCampaign: referrerAttribution.utmCampaign || 'broker_audit_free',
7294
- ctaId: 'broker_audit_form',
7295
- ctaPlacement: 'broker_audit',
7296
- page: referrerAttribution.page || '/broker-audit',
7297
- landingPath: referrerAttribution.landingPath || '/broker-audit',
7298
- referrerHost: referrerAttribution.referrerHost,
7299
- referrer: referrerAttribution.referrer,
7300
- failureCode: err?.message ? err.message : 'broker_audit_lead_failed',
7301
- httpStatus: err && err.statusCode ? err.statusCode : null,
7302
- }, req.headers, 'broker_audit_lead_failed');
7303
- if (isFormSubmission && !wantsJson(req, parsed)) {
7304
- sendHtml(
7305
- res,
7306
- err.statusCode || 500,
7307
- renderBrokerAuditIntakeResultPage(hostedConfig, {
7308
- title: 'Broker audit request failed',
7309
- detail: err.message || 'Unable to capture broker audit request.',
7310
- }),
7311
- journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}
7312
- );
7313
- return;
7314
- }
7315
- sendProblem(res, {
7316
- type: !err.statusCode || err.statusCode >= 500 ? PROBLEM_TYPES.INTERNAL : PROBLEM_TYPES.BAD_REQUEST,
7317
- title: !err.statusCode || err.statusCode >= 500 ? 'Internal Server Error' : 'Request Error',
7318
- status: err.statusCode || 500,
7319
- detail: err.message || 'Unable to capture broker audit request.',
7320
- }, getPublicBillingHeaders(traceId));
7321
- }
7322
- return;
7323
- }
7324
-
7325
5323
  if (req.method === 'POST' && pathname === '/v1/intake/workflow-sprint') {
7326
5324
  const { FEEDBACK_DIR } = getFeedbackPaths();
7327
5325
  const traceId = createTraceId('sprint_intake');
@@ -7469,19 +5467,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
7469
5467
 
7470
5468
  // Public OpenAPI spec — no auth required (needed for ChatGPT GPT Store import)
7471
5469
  if (isGetLikeRequest && (pathname === '/openapi.json' || pathname === '/openapi.yaml')) {
7472
- const specPath = [
7473
- path.join(__dirname, '../../openapi/openapi.yaml'),
7474
- path.join(__dirname, '../../adapters/chatgpt/openapi.yaml'),
7475
- ].find((candidate) => fs.existsSync(candidate));
7476
- if (!specPath) {
7477
- sendProblem(res, {
7478
- type: PROBLEM_TYPES.NOT_FOUND,
7479
- title: 'Not Found',
7480
- status: 404,
7481
- detail: 'OpenAPI spec not found.',
7482
- });
7483
- return;
7484
- }
5470
+ const specPath = path.join(__dirname, '../../adapters/chatgpt/openapi.yaml');
7485
5471
  try {
7486
5472
  const yaml = renderOpenApiYamlForRequest(fs.readFileSync(specPath, 'utf8'), req);
7487
5473
  if (pathname === '/openapi.yaml') {
@@ -7557,14 +5543,38 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
7557
5543
  // surface that was missing: thumbgate.ai had no /case-studies, so visitors
7558
5544
  // landed on CLI install commands without seeing whether anyone actually
7559
5545
  // got value. First entry is the Aiventyx Teams listing integration: real
7560
- // third-party CTR signal (5/8 clicks before the /go/teams fix, now routed
7561
- // through team intake so scope happens before checkout).
5546
+ // third-party CTR signal (5/8 clicks before the /go/teams fix, end-to-end
5547
+ // verified after).
7562
5548
  if (isGetLikeRequest && pathname === '/case-studies') {
7563
5549
  sendHtml(res, 200, `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Case Studies — ThumbGate</title><meta name="description" content="Real integrations of ThumbGate's pre-action checks for AI coding agents. Proof, not promises."><style>body{font-family:system-ui,-apple-system,sans-serif;max-width:780px;margin:0 auto;padding:32px 20px;line-height:1.55;color:#1f2937}h1{font-size:32px;margin:0 0 8px}.lede{color:#6b7280;font-size:18px;margin:0 0 32px}article{border:1px solid #e5e7eb;border-radius:12px;padding:24px;margin-bottom:24px;background:#fff}article h2{margin:0 0 4px;font-size:22px}.meta{color:#6b7280;font-size:13px;margin-bottom:16px}h3{font-size:15px;margin:20px 0 8px;color:#374151;text-transform:uppercase;letter-spacing:0.5px}.metric{display:inline-block;background:#0f172a;color:#fff;padding:4px 10px;border-radius:6px;font-weight:600;font-size:14px;margin:0 4px 4px 0}p{margin:8px 0}a{color:#0066cc}code{background:#f3f4f6;padding:1px 6px;border-radius:4px;font-size:13.5px}footer{margin-top:48px;padding-top:24px;border-top:1px solid #e5e7eb;color:#6b7280;font-size:14px}</style></head><body>
7564
5550
  <h1>Case Studies</h1>
7565
5551
  <p class="lede">Real integrations. No fabricated logos, no aspirational numbers — every claim below is reproducible.</p>
7566
5552
 
7567
- <p><em>New case studies for individual Pro operators coming soon.</em></p>
5553
+ <article>
5554
+ <h2>Aiventyx marketplace — Teams listing CTR recovery</h2>
5555
+ <p class="meta">Integration partner: <a href="https://www.aiventyx.com">Aiventyx</a> · Reported by: Qaiser Mehdi · Verified: 2026-05-13</p>
5556
+
5557
+ <h3>The problem</h3>
5558
+ <p>Aiventyx is a marketplace for AI tools. ThumbGate's Teams listing was their highest-CTR external surface — <span class="metric">62% CTR</span> (5 clicks on 8 views, May 7–9 window). When their integrator rolled out canonical tracked URLs, every Teams click started landing on:</p>
5559
+ <p><code>{"error":"Tracked link not found","allowed":["gpt","pro","install","reddit","linkedin","x","github"]}</code></p>
5560
+ <p>The <code>/go/teams</code> slug wasn't registered in our redirector — a 404 was eating every paid-intent click from their strongest external surface.</p>
5561
+
5562
+ <h3>The fix</h3>
5563
+ <p>Added <code>teams</code> to <code>TRACKED_LINK_TARGETS</code>: HTTP 302 redirect to <code>/checkout/pro?plan_id=team&seat_count=3&billing_cycle=monthly</code> — the 3-seat $147/mo self-serve Stripe Team checkout. Caller-supplied UTMs flow through to Stripe metadata end-to-end.</p>
5564
+
5565
+ <h3>The verification</h3>
5566
+ <p>Qaiser's own incognito test, May 13 6:04 AM (full email on record):</p>
5567
+ <p><code>https://thumbgate.ai/go/teams?utm_source=aiventyx</code><br>
5568
+ → 302 to Stripe checkout<br>
5569
+ → "Subscribe to ThumbGate Team" page loads<br>
5570
+ → $147/mo, 3-seat Team plan confirmed<br>
5571
+ → Aiventyx UTMs intact in URL</p>
5572
+
5573
+ <h3>What this proves</h3>
5574
+ <p>End-to-end attribution from a third-party marketplace through ThumbGate's redirector into Stripe checkout, with the caller's UTM chain preserved. Two regression tests pin the redirect contract so it can't silently break.</p>
5575
+
5576
+ <p><a href="/go/teams?utm_source=case-study">Try the live redirect →</a></p>
5577
+ </article>
7568
5578
 
7569
5579
  <footer>
7570
5580
  <p>Want to be the next case study? The product is real, the integration is 30 seconds: <code>npx thumbgate init</code>. If you ship something with ThumbGate and want it documented here, email <a href="mailto:igor.ganapolsky@gmail.com">igor.ganapolsky@gmail.com</a>.</p>
@@ -7583,66 +5593,87 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
7583
5593
  // Sprint (proof-pack, sales-led) → Pro (self-serve recurring) → Team
7584
5594
  // (after qualification). Every paid CTA across the site should funnel
7585
5595
  // here OR directly into Stripe checkout — never a different price.
7586
- if (isGetLikeRequest && (pathname === '/pricing' || pathname === '/pricing.html')) {
7587
- try {
7588
- servePublicMarketingPage({
7589
- req,
7590
- res,
7591
- parsed,
7592
- hostedConfig,
7593
- isHeadRequest,
7594
- renderHtml: loadPricingPageHtml,
7595
- extraTelemetry: {
7596
- pageType: 'pricing',
7597
- },
7598
- });
7599
- } catch (err) {
7600
- sendText(res, 500, err.message || 'Pricing page unavailable');
7601
- }
7602
- return;
7603
- }
5596
+ if (isGetLikeRequest && pathname === '/pricing') {
5597
+ sendHtml(res, 200, `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Pricing — ThumbGate</title><meta name="description" content="ThumbGate pricing: free CLI, $19/mo Pro, $49/seat Team. One subscription decision, no consulting upsell."><style>body{font-family:system-ui,-apple-system,sans-serif;max-width:980px;margin:0 auto;padding:32px 20px;line-height:1.55;color:#1f2937}h1{font-size:36px;margin:0 0 8px;text-align:center}.lede{color:#6b7280;font-size:18px;margin:0 0 32px;text-align:center;max-width:560px;margin-left:auto;margin-right:auto}.grid{display:grid;grid-template-columns:1fr;gap:20px;margin-bottom:24px}@media(min-width:720px){.grid{grid-template-columns:repeat(3,1fr)}}.card{border:1px solid #e5e7eb;border-radius:12px;padding:24px;background:#fff;display:flex;flex-direction:column}.hero{border:2px solid #3b82f6;background:linear-gradient(135deg,#eff6ff,#fff);position:relative}.hero::before{content:"Most popular";position:absolute;top:-10px;left:50%;transform:translateX(-50%);background:#3b82f6;color:#fff;padding:3px 12px;border-radius:6px;font-size:11px;font-weight:700;letter-spacing:0.5px}.tag{display:inline-block;background:#0f172a;color:#fff;padding:3px 10px;border-radius:6px;font-size:12px;font-weight:600;letter-spacing:0.5px;margin-bottom:12px}.tag-free{background:#10b981}.tag-pro{background:#3b82f6}.tag-team{background:#8b5cf6}h2{margin:0 0 4px;font-size:22px}.price{font-size:30px;font-weight:700;margin:8px 0 12px}.price small{font-size:14px;color:#6b7280;font-weight:400}.tagline{color:#374151;margin:0 0 16px;font-size:15px}ul{margin:0 0 20px;padding-left:18px}li{margin:6px 0;font-size:14px}.cta{display:inline-block;background:#0f172a;color:#fff;padding:12px 24px;border-radius:8px;font-weight:600;text-decoration:none;text-align:center;margin-top:auto}.cta-primary{background:#3b82f6}.cta-secondary{background:#fff;color:#0f172a;border:1px solid #0f172a}.cta-free{background:#10b981}details.consulting{margin-top:32px;border:1px solid #e5e7eb;border-radius:12px;padding:18px 22px;background:#fafafa}details.consulting summary{cursor:pointer;font-size:15px;font-weight:600;color:#475569;list-style:none}details.consulting summary::-webkit-details-marker{display:none}details.consulting[open] summary{margin-bottom:16px}.consulting-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;margin:8px 0}.consulting-card{border:1px solid #e5e7eb;border-radius:8px;padding:14px;background:#fff}.consulting-card .cp{font-size:18px;font-weight:700;color:#0f172a}.consulting-card .cl{font-size:13px;color:#6b7280;margin:4px 0 10px}.consulting-card a{color:#0066cc;font-size:13px;text-decoration:none;font-weight:600}footer{margin-top:32px;padding-top:24px;border-top:1px solid #e5e7eb;color:#6b7280;font-size:14px;text-align:center}footer a{color:#0066cc}</style></head><body>
5598
+ <h1>Pricing</h1>
5599
+ <p class="lede">Three tiers. Pick the one that matches your scale. Self-serve checkout on every subscription plan — no calls.</p>
5600
+
5601
+ <div class="grid">
5602
+
5603
+ <div class="card">
5604
+ <span class="tag tag-free">Free — Solo developer</span>
5605
+ <h2>ThumbGate CLI</h2>
5606
+ <div class="price">$0 <small>forever</small></div>
5607
+ <p class="tagline">The pre-action gate layer for one developer. Block your first repeated AI mistake in 5 minutes.</p>
5608
+ <ul>
5609
+ <li>Unlimited feedback captures</li>
5610
+ <li>Up to 5 active prevention rules</li>
5611
+ <li>All MCP integrations (Claude Code, Cursor, Codex, Gemini, Amp)</li>
5612
+ <li>No account, no signup, no data leaves your machine</li>
5613
+ </ul>
5614
+ <a class="cta cta-free" href="/go/install?utm_source=pricing">Install free →</a>
5615
+ </div>
7604
5616
 
5617
+ <div class="card hero">
5618
+ <span class="tag tag-pro">Pro — Self-serve recurring</span>
5619
+ <h2>ThumbGate Pro</h2>
5620
+ <div class="price">$19 <small>/ month · $149 / year</small></div>
5621
+ <p class="tagline">For developers running multiple agents who hit the 5-rule wall. Unlimited rules, lesson recall, audit-ready evidence.</p>
5622
+ <ul>
5623
+ <li>Unlimited active prevention rules</li>
5624
+ <li>Local dashboard + lesson search across sessions</li>
5625
+ <li>DPO export for offline preference fine-tuning</li>
5626
+ <li>Visual check debugger — see every blocked action</li>
5627
+ <li>7-day refund window. Cancel anytime.</li>
5628
+ </ul>
5629
+ <a class="cta cta-primary" href="/go/pro?utm_source=pricing&utm_medium=hero_card">Start Pro — $19/mo →</a>
5630
+ </div>
7605
5631
 
7606
- // Remote MCP connector documentation — the `resource_documentation` target
7607
- // advertised by /.well-known/oauth-protected-resource. The Claude Connectors
7608
- // Directory requires this URL to resolve (200) for submission review.
7609
- if (isGetLikeRequest && (pathname === '/docs/connectors' || pathname === '/docs/connectors/')) {
7610
- const mcpUrl = buildPublicUrl(hostedConfig, '/mcp');
7611
- const cardUrl = buildPublicUrl(hostedConfig, '/.well-known/mcp/server-card.json');
7612
- const prmUrl = buildPublicUrl(hostedConfig, '/.well-known/oauth-protected-resource');
7613
- sendHtml(res, 200, `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Remote MCP Connector — ThumbGate</title><meta name="description" content="Connect ThumbGate to Claude as a remote MCP server: OAuth 2.1 (PKCE) authorization, available tools, and the read-only reviewer credential."><style>body{font-family:system-ui,-apple-system,sans-serif;max-width:780px;margin:0 auto;padding:32px 20px;line-height:1.55;color:#1f2937}h1{font-size:30px;margin:0 0 8px}.lede{color:#6b7280;font-size:18px;margin:0 0 28px}h2{font-size:20px;margin:28px 0 8px}code{background:#f3f4f6;padding:1px 6px;border-radius:4px;font-size:13.5px}pre{background:#0f172a;color:#e2e8f0;padding:14px 16px;border-radius:8px;overflow:auto;font-size:13px}a{color:#0066cc}ol,ul{padding-left:22px}li{margin:6px 0}.note{border-left:3px solid #22d3ee;background:#ecfeff;padding:10px 14px;border-radius:0 8px 8px 0;margin:16px 0}footer{margin-top:40px;padding-top:20px;border-top:1px solid #e5e7eb;color:#6b7280;font-size:14px}</style></head><body>
7614
- <h1>ThumbGate — Remote MCP Connector</h1>
7615
- <p class="lede">ThumbGate is a remote Model Context Protocol (MCP) server. Add it as a connector in Claude to give an agent governed access to ThumbGate's feedback capture, lesson retrieval, context assembly, and pre-action gate tools — over HTTP, authenticated with OAuth 2.1.</p>
7616
-
7617
- <h2>Connect URL</h2>
7618
- <pre>${esc(mcpUrl)}</pre>
7619
- <p>In Claude, add a custom connector and paste the URL above. Claude discovers the authorization server automatically via the protected-resource metadata at <a href="${esc(prmUrl)}">${esc(prmUrl)}</a>.</p>
7620
-
7621
- <h2>Authorization (OAuth 2.1 + PKCE)</h2>
7622
- <ol>
7623
- <li>Claude reads the <code>resource</code> and <code>authorization_servers</code> from the protected-resource metadata.</li>
7624
- <li>It runs the standard OAuth 2.1 authorization-code flow with PKCE (<code>S256</code> only — <code>plain</code> is rejected).</li>
7625
- <li>The issued access token is audience-bound to the <code>${esc(mcpUrl)}</code> resource (RFC 8707); a token for any other audience is rejected.</li>
7626
- <li>The bearer token is then sent on the <code>Authorization</code> header for every <code>tools/call</code>.</li>
7627
- </ol>
7628
-
7629
- <h2>Available tools</h2>
7630
- <p>The full, machine-readable tool registry — names, input schemas, and the <code>readOnlyHint</code> / <code>destructiveHint</code> annotations — is published at <a href="${esc(cardUrl)}">${esc(cardUrl)}</a>. Tools fall into these groups:</p>
5632
+ <div class="card">
5633
+ <span class="tag tag-team">Team Shared enforcement</span>
5634
+ <h2>ThumbGate Team</h2>
5635
+ <div class="price">$49 <small>/ seat / month · 3-seat min</small></div>
5636
+ <p class="tagline">For engineering teams with shared AI-agent workflows. One engineer's save protects the whole team.</p>
7631
5637
  <ul>
7632
- <li><strong>Feedback &amp; lessons</strong> <code>capture_feedback</code>, <code>feedback_summary</code>, <code>search_lessons</code>, <code>retrieve_lessons</code>, <code>prevention_rules</code>.</li>
7633
- <li><strong>Context engineering</strong> <code>construct_context_pack</code>, <code>evaluate_context_pack</code>, <code>unified_context</code>, <code>recall</code>.</li>
7634
- <li><strong>Pre-action gates &amp; governance</strong> — <code>satisfy_gate</code>, <code>track_action</code>, <code>approve_protected_action</code>, <code>verify_claim</code>, <code>enforcement_matrix</code>.</li>
7635
- <li><strong>Diagnostics &amp; planning</strong> <code>diagnose_failure</code>, <code>suggest_fix</code>, <code>security_scan</code>, and the <code>plan_*</code> advisory tools.</li>
5638
+ <li>Shared lesson database across all seats</li>
5639
+ <li>Org dashboard + audit-ready policy rollout</li>
5640
+ <li>Self-serve checkout starts at $147/mo</li>
5641
+ <li>Email support during pilot rollout</li>
7636
5642
  </ul>
5643
+ <a class="cta cta-secondary" href="/go/teams?utm_source=pricing">Start Team →</a>
5644
+ </div>
7637
5645
 
7638
- <h2>Reviewer credential (read-only)</h2>
7639
- <div class="note">For directory reviewers: ThumbGate issues a dedicated <strong>read-only</strong> reviewer credential. A token bound to that credential may invoke only tools annotated <code>readOnlyHint: true</code>; any write or mutating tool call is rejected. This makes the credential safe to share for review without granting the ability to mutate shared server state. Request it from the contact below.</div>
5646
+ </div>
7640
5647
 
7641
- <h2>Source &amp; contact</h2>
7642
- <p>Open-source CLI and server: <a href="https://github.com/IgorGanapolsky/ThumbGate">github.com/IgorGanapolsky/ThumbGate</a>. Questions or reviewer-credential requests: <a href="mailto:igor.ganapolsky@gmail.com">igor.ganapolsky@gmail.com</a>.</p>
5648
+ <details class="consulting">
5649
+ <summary>▸ Need a one-off diagnostic, hardening sprint, or governance setup? View paid services</summary>
5650
+ <p style="color:#6b7280;font-size:14px;margin:0 0 14px;">For teams that want a stakeholder-visible artifact (PR, briefing, deployed hooks) before subscribing. Each is one-time, fixed-scope, refundable if we can't extract a rule from your failure trace.</p>
5651
+ <div class="consulting-grid">
5652
+ <div class="consulting-card">
5653
+ <div class="cp">$499</div>
5654
+ <div class="cl">Sprint Diagnostic — 2-day triage on one workflow, top-5 rules ranked, PR delivered.</div>
5655
+ <a href="mailto:igor.ganapolsky@gmail.com?subject=ThumbGate%20Sprint%20Diagnostic">Email to start →</a>
5656
+ </div>
5657
+ <div class="consulting-card">
5658
+ <div class="cp">$1,500</div>
5659
+ <div class="cl">Workflow Hardening Sprint — full hardening engagement, hooks deployed, rollout review.</div>
5660
+ <a href="mailto:igor.ganapolsky@gmail.com?subject=ThumbGate%20Workflow%20Hardening%20Sprint">Email to start →</a>
5661
+ </div>
5662
+ <div class="consulting-card">
5663
+ <div class="cp">$97</div>
5664
+ <div class="cl">OpenClaw Agent Governance Kit — self-serve digital kit, prevention-rule starters, proof report template.</div>
5665
+ <a href="https://buy.stripe.com/bJe14naiE9Lo7xT49Z3sI12">Buy kit →</a>
5666
+ </div>
5667
+ </div>
5668
+ </details>
7643
5669
 
7644
- <footer><a href="/">ThumbGate</a> · <a href="/support">Support</a> · <a href="/privacy">Privacy</a> · <a href="/terms">Terms</a></footer>
7645
- </body></html>`, {}, { headOnly: isHeadRequest });
5670
+ <footer>
5671
+ <p>One source of truth for ThumbGate pricing. Numbers here override anything stale elsewhere on the site.</p>
5672
+ <p><a href="/">Home</a> · <a href="/case-studies">Case Studies</a> · <a href="/support">Support</a> · <a href="/privacy">Privacy</a> · <a href="/terms">Terms</a></p>
5673
+ </footer>
5674
+ </body></html>`, {}, {
5675
+ headOnly: isHeadRequest,
5676
+ });
7646
5677
  return;
7647
5678
  }
7648
5679
 
@@ -7885,14 +5916,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
7885
5916
  return;
7886
5917
  }
7887
5918
 
7888
- // Catch non-API GET requests that didn't match any public page route above.
7889
- // Without this, /about, /docs, /demo etc. fall through to the API auth gate
7890
- // and return a raw JSON 401 instead of a user-friendly 404.
7891
- if (isGetLikeRequest && !pathname.startsWith('/v1/') && !pathname.startsWith('/api/')) {
7892
- sendHtml(res, 404, `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Page Not Found — ThumbGate</title><style>body{background:#0a0a0a;color:#e2e8f0;font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}main{text-align:center;padding:2rem}h1{font-size:4rem;margin:0;color:#22d3ee}p{font-size:1.1rem;color:#94a3b8;margin:1rem 0}a{color:#22d3ee;text-decoration:underline}</style></head><body><main><h1>404</h1><p>This page doesn't exist.</p><p><a href="/">Back to ThumbGate</a></p></main></body></html>`);
7893
- return;
7894
- }
7895
-
7896
5919
  // Operator key is allowed to bypass the general admin gate for its dedicated endpoint
7897
5920
  const _reqToken = extractApiKey(req);
7898
5921
  const isOperatorBillingRequest = Boolean(expectedOperatorKey)
@@ -7918,161 +5941,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
7918
5941
 
7919
5942
  try {
7920
5943
  if (req.method === 'GET' && pathname === '/v1/feedback/stats') {
7921
- const stats = shouldAggregateFeedback()
7922
- ? computeAggregateFeedbackStats({ feedbackDir: requestFeedbackPaths.FEEDBACK_DIR })
7923
- : analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH);
7924
- try {
7925
- const { getStatuslineMeta } = require('../../scripts/statusline-meta');
7926
- const meta = getStatuslineMeta({ env: process.env });
7927
- stats.tier = meta.tier;
7928
- } catch (error) {
7929
- debugApiFallback('statusline meta unavailable', error);
7930
- stats.tier = 'Pro';
7931
- }
7932
-
7933
- const projectChatSettings = readProjectChatSettings(req, parsed);
7934
-
7935
- stats.geminiConfigured = Boolean(
7936
- projectChatSettings.geminiKey ||
7937
- process.env.GEMINI_API_KEY ||
7938
- process.env.THUMBGATE_GEMINI_API_KEY ||
7939
- process.env.GOOGLE_API_KEY
7940
- );
7941
- stats.perplexityConfigured = Boolean(
7942
- projectChatSettings.perplexityKey ||
7943
- process.env.PERPLEXITY_API_KEY ||
7944
- process.env.THUMBGATE_PERPLEXITY_API_KEY
7945
- );
7946
- stats.geminiValidatedAt = projectChatSettings.geminiValidatedAt;
7947
- stats.geminiKeyStatus = 'none';
7948
- if (projectChatSettings.geminiKey) {
7949
- stats.geminiKeyStatus = 'present';
7950
- }
7951
- if (projectChatSettings.geminiValidatedAt) {
7952
- stats.geminiKeyStatus = 'validated';
7953
- }
7954
- stats.hybridInferenceAvailable = !!(stats.geminiConfigured || stats.perplexityConfigured);
7955
- stats.localLlmConfigured = Boolean(process.env.THUMBGATE_LOCAL_LLM_ENDPOINT);
7956
- stats.localLlmEndpoint = process.env.THUMBGATE_LOCAL_LLM_ENDPOINT || null;
7957
- stats.localLlmModel = process.env.THUMBGATE_LOCAL_LLM_MODEL || null;
7958
- sendJson(res, 200, stats);
7959
- return;
7960
- }
7961
-
7962
- // Chat with your data — LOCAL-FIRST. Powers the dashboard "Chat with your
7963
- // data" panel. Factual/metric questions (gates, blocks, feedback, token
7964
- // savings, team) are answered DETERMINISTICALLY from this install's own
7965
- // dashboard data — no cloud, no LLM, no API key (the local-first thesis).
7966
- // Only open-ended/qualitative questions fall through to lesson retrieval +
7967
- // the user's configured LOCAL model (a BYO cloud key is optional, not required).
7968
- if (req.method === 'POST' && pathname === '/v1/chat') {
7969
- const body = await parseJsonBody(req);
7970
- const question = body.question || body.q || body.message;
7971
- const normalizedChatPrompt = normalizeEnterpriseChatPrompt(question);
7972
- const chatFeedbackDir = requestFeedbackPaths.FEEDBACK_DIR;
7973
-
7974
- // Local-first: factual/metric questions (gates, blocks, feedback, cost,
7975
- // team) are answered deterministically from local data — no cloud/LLM/key.
7976
- if (
7977
- normalizedChatPrompt
7978
- && !containsUnsafeEnterpriseChatInput(normalizedChatPrompt)
7979
- && classifyEnterpriseChatTopic(normalizedChatPrompt) !== 'overview'
7980
- && await trySendLocalDashboardChat(res, parsed, chatFeedbackDir, normalizedChatPrompt)
7981
- ) {
7982
- return;
7983
- }
7984
-
7985
- const { answerDataQuestion } = require('../../scripts/dashboard-chat');
7986
-
7987
- const projectChatSettings = readProjectChatSettings(req, parsed);
7988
-
7989
- const result = await answerDataQuestion(question, {
7990
- feedbackDir: chatFeedbackDir,
7991
- model: typeof body.model === 'string' ? body.model : undefined,
7992
- apiKey: projectChatSettings.perplexityKey || projectChatSettings.geminiKey || process.env.PERPLEXITY_API_KEY || process.env.THUMBGATE_PERPLEXITY_API_KEY || process.env.GEMINI_API_KEY || process.env.THUMBGATE_GEMINI_API_KEY || process.env.GOOGLE_API_KEY || '',
7993
- localEndpoint: projectChatSettings.localEndpoint || process.env.THUMBGATE_LOCAL_LLM_ENDPOINT || '',
7994
- localModel: projectChatSettings.localModel || process.env.THUMBGATE_LOCAL_LLM_MODEL || '',
7995
- });
7996
-
7997
- // Local-first guarantee: if no model is configured, never hard-fail with
7998
- // "no_api_key" — fall back to a deterministic local answer. No cloud required.
7999
- if (
8000
- !result.ok
8001
- && (result.error === 'no_api_key' || result.error === 'no_model')
8002
- && await trySendLocalDashboardChat(res, parsed, chatFeedbackDir, normalizedChatPrompt || question, '(Connect a local model via THUMBGATE_LOCAL_LLM_ENDPOINT for open-ended analysis over your lessons.)')
8003
- ) {
8004
- return;
8005
- }
8006
-
8007
- sendJson(res, result.ok ? 200 : (result.error === 'no_api_key' ? 503 : 400), result);
8008
- return;
8009
- }
8010
-
8011
- // Save Gemini API key from the dashboard UI
8012
- if (req.method === 'POST' && pathname === '/v1/settings/gemini-key') {
8013
- const body = await parseJsonBody(req);
8014
- const key = String(body.key || '').trim();
8015
- if (!key) {
8016
- sendJson(res, 400, { ok: false, error: 'missing_key', message: 'No API key provided.' });
8017
- return;
8018
- }
8019
-
8020
- // Validate the candidate key using the *exact* same code path as /v1/chat
8021
- // (project-scoped .env read + RAG + Gemini call). This prevents saving a
8022
- // key that will later produce the confusing "API key not valid" error in chat.
8023
- let validation;
8024
- try {
8025
- const { answerDataQuestion } = require('../../scripts/dashboard-chat');
8026
- validation = await answerDataQuestion('Reply with the single word: PONG', {
8027
- feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
8028
- apiKey: key,
8029
- });
8030
- } catch (e) {
8031
- validation = { ok: false, error: 'validation_exception', message: String(e?.message || e) };
8032
- }
8033
-
8034
- if (!validation.ok) {
8035
- const detail = validation.error === 'gemini_error'
8036
- ? (validation.message || 'Gemini rejected the key')
8037
- : (validation.message || validation.error || 'unknown error');
8038
- sendJson(res, 400, {
8039
- ok: false,
8040
- error: 'invalid_key',
8041
- message: 'Key validation failed: ' + detail + '. Get a fresh key from https://aistudio.google.com/app/apikey (or run `npx thumbgate setup-vertex` for Vertex) and try again.'
8042
- });
8043
- return;
8044
- }
8045
-
8046
- try {
8047
- const projectDir = resolveRequestProjectDir(req, parsed);
8048
- const envPath = path.join(projectDir, '.env');
8049
- let content = '';
8050
- if (fs.existsSync(envPath)) {
8051
- content = fs.readFileSync(envPath, 'utf8');
8052
- }
8053
- const regex = /^GEMINI_API_KEY=.*$/m;
8054
- if (regex.test(content)) {
8055
- content = content.replace(regex, `GEMINI_API_KEY=${key}`);
8056
- } else {
8057
- content = content.trim() + `\nGEMINI_API_KEY=${key}\n`;
8058
- }
8059
- fs.writeFileSync(envPath, content, 'utf8');
8060
- // Also set it in the current process so it takes effect immediately without restart
8061
- process.env.GEMINI_API_KEY = key;
8062
- // Persist validation success for reliable "configured" status in stats/hints
8063
- try {
8064
- const statusPath = path.join(projectDir, '.gemini-validated.json');
8065
- fs.writeFileSync(statusPath, JSON.stringify({
8066
- validatedAt: new Date().toISOString(),
8067
- validatedBy: 'dashboard-save'
8068
- }, null, 2));
8069
- } catch (error) {
8070
- debugApiFallback('Gemini validation marker unavailable', error);
8071
- }
8072
- sendJson(res, 200, { ok: true, message: 'Key saved and validated.' });
8073
- } catch (e) {
8074
- sendJson(res, 500, { ok: false, error: 'fs_error', message: 'Failed to write to .env file: ' + e.message });
8075
- }
5944
+ sendJson(res, 200, analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH));
8076
5945
  return;
8077
5946
  }
8078
5947
 
@@ -8131,28 +6000,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8131
6000
  return;
8132
6001
  }
8133
6002
 
8134
- if (req.method === 'GET' && (
8135
- pathname === '/v1/enterprise/data-chat/status'
8136
- || pathname === '/v1/enterprise/dialogflow/status'
8137
- )) {
8138
- sendJson(res, 200, buildEnterpriseDataChatStatus());
8139
- return;
8140
- }
8141
-
8142
- if (req.method === 'POST' && (
8143
- pathname === '/v1/enterprise/data-chat/chat'
8144
- || pathname === '/v1/enterprise/dialogflow/chat'
8145
- )) {
8146
- const body = await parseJsonBody(req, 16 * 1024);
8147
- const result = await answerEnterpriseDataChat({
8148
- prompt: body.prompt || body.message || body.query,
8149
- feedbackDir: requestFeedbackDir,
8150
- parsed,
8151
- });
8152
- sendJson(res, 200, result);
8153
- return;
8154
- }
8155
-
8156
6003
  if (req.method === 'GET' && pathname === '/v1/intents/catalog') {
8157
6004
  const mcpProfile = parsed.searchParams.get('mcpProfile') || undefined;
8158
6005
  const bundleId = parsed.searchParams.get('bundleId') || undefined;
@@ -8178,7 +6025,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8178
6025
  bundleId: body.bundleId,
8179
6026
  partnerProfile: body.partnerProfile,
8180
6027
  delegationMode: body.delegationMode,
8181
- enforcePlanQuality: body.enforcePlanQuality === true,
8182
6028
  approved: body.approved === true,
8183
6029
  repoPath: body.repoPath,
8184
6030
  });
@@ -8437,7 +6283,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8437
6283
  summary: body.summary,
8438
6284
  allowedPaths: body.allowedPaths,
8439
6285
  protectedPaths: body.protectedPaths,
8440
- workflowContract: body.workflowContract,
8441
6286
  repoPath: body.repoPath,
8442
6287
  localOnly: body.localOnly === true,
8443
6288
  clear: body.clear === true,
@@ -8515,11 +6360,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8515
6360
  }
8516
6361
 
8517
6362
  if (req.method === 'GET' && pathname === '/v1/lessons/search') {
8518
- const lessonLimit = checkLimit('search_lessons');
8519
- if (!lessonLimit.allowed) {
8520
- sendJson(res, 429, { error: 'Free tier: lesson search requires Pro. Upgrade at /pricing', code: 'PRO_REQUIRED' });
8521
- return;
8522
- }
8523
6363
  const query = parsed.searchParams.get('q') || parsed.searchParams.get('query') || '';
8524
6364
  const limit = Number(parsed.searchParams.get('limit') || 10);
8525
6365
  const category = parsed.searchParams.get('category') || '';
@@ -8539,24 +6379,17 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8539
6379
  }
8540
6380
 
8541
6381
  if (req.method === 'GET' && pathname === '/v1/search') {
8542
- const searchLimit = checkLimit('search_thumbgate');
8543
- if (!searchLimit.allowed) {
8544
- sendJson(res, 429, { error: 'Free tier: search requires Pro. Upgrade at /pricing', code: 'PRO_REQUIRED' });
8545
- return;
8546
- }
8547
6382
  const query = parsed.searchParams.get('q') || parsed.searchParams.get('query') || '';
8548
6383
  const limit = Number(parsed.searchParams.get('limit') || 10);
8549
6384
  const source = parsed.searchParams.get('source') || 'all';
8550
6385
  const signal = parsed.searchParams.get('signal') || null;
8551
6386
  let results;
8552
6387
  try {
8553
- const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
8554
6388
  results = searchThumbgate({
8555
6389
  query,
8556
6390
  limit: Number.isFinite(limit) ? limit : 10,
8557
6391
  source,
8558
6392
  signal,
8559
- feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
8560
6393
  });
8561
6394
  } catch (err) {
8562
6395
  throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
@@ -8566,21 +6399,14 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8566
6399
  }
8567
6400
 
8568
6401
  if (req.method === 'POST' && pathname === '/v1/search') {
8569
- const searchLimit = checkLimit('search_thumbgate');
8570
- if (!searchLimit.allowed) {
8571
- sendJson(res, 429, { error: 'Free tier: search requires Pro. Upgrade at /pricing', code: 'PRO_REQUIRED' });
8572
- return;
8573
- }
8574
6402
  const body = await parseJsonBody(req);
8575
6403
  let results;
8576
6404
  try {
8577
- const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
8578
6405
  results = searchThumbgate({
8579
6406
  query: body.query || body.q || '',
8580
6407
  limit: body.limit,
8581
6408
  source: body.source,
8582
6409
  signal: body.signal,
8583
- feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
8584
6410
  });
8585
6411
  } catch (err) {
8586
6412
  throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
@@ -8606,14 +6432,11 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8606
6432
  {
8607
6433
  const documentId = normalizeDocumentIdFromPath(pathname);
8608
6434
  if (req.method === 'GET' && documentId) {
8609
- if (!/^[a-zA-Z0-9-_]+$/.test(documentId)) {
8610
- throw createHttpError(400, 'Invalid document ID format');
8611
- }
8612
6435
  const document = readImportedDocument(documentId, {
8613
6436
  feedbackDir: requestFeedbackDir,
8614
6437
  });
8615
6438
  if (!document) {
8616
- throw createHttpError(404, `Imported document not found: ${escapeHtml(documentId)}`);
6439
+ throw createHttpError(404, `Imported document not found: ${documentId}`);
8617
6440
  }
8618
6441
  sendJson(res, 200, { document });
8619
6442
  return;
@@ -8640,7 +6463,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8640
6463
  feedbackDir: getSafeDataDir(),
8641
6464
  limit: 10,
8642
6465
  });
8643
- } catch { /* best-effort — conversation window is optional */ }
6466
+ } catch (_) { /* best-effort — conversation window is optional */ }
8644
6467
  }
8645
6468
  const result = captureFeedback({
8646
6469
  signal: body.signal,
@@ -8659,18 +6482,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8659
6482
  tags: extractTags(body.tags),
8660
6483
  skill: body.skill,
8661
6484
  });
8662
- const actionIntegration = inferActionIntegration(body, req.headers);
8663
- appendBestEffortTelemetry(requestFeedbackDir, {
8664
- eventType: chatgptActionEventType(actionIntegration, 'capture_feedback'),
8665
- clientType: 'api',
8666
- source: actionIntegration,
8667
- integration: actionIntegration,
8668
- actionOperation: 'captureFeedback',
8669
- endpoint: '/v1/feedback/capture',
8670
- actionStatus: result.accepted ? 'accepted' : 'clarification_required',
8671
- accepted: Boolean(result.accepted),
8672
- failureCode: result.accepted ? null : 'clarification_required',
8673
- }, req.headers, 'api_action_capture_feedback');
8674
6485
  if (result?.accepted) {
8675
6486
  // Fan out to any connected dashboard clients so they re-render
8676
6487
  // without polling. Non-sensitive summary only (no chat history,
@@ -8751,11 +6562,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8751
6562
  }
8752
6563
 
8753
6564
  if (req.method === 'POST' && pathname === '/v1/dpo/export') {
8754
- const dpoLimit = checkLimit('export_dpo');
8755
- if (!dpoLimit.allowed) {
8756
- sendJson(res, 429, { error: 'Free tier: DPO export requires Pro. Upgrade at /pricing', code: 'PRO_REQUIRED' });
8757
- return;
8758
- }
8759
6565
  const body = await parseJsonBody(req);
8760
6566
  const paths = resolveDpoExportPaths(body, {
8761
6567
  safeDataDir: requestSafeDataDir,
@@ -9333,40 +7139,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9333
7139
  return;
9334
7140
  }
9335
7141
 
9336
- // GET /v1/dashboard/ai-inventory -- Enterprise AI inventory evidence
9337
- if (req.method === 'GET' && pathname === '/v1/dashboard/ai-inventory') {
9338
- try {
9339
- const {
9340
- scanAiComponents,
9341
- buildCycloneDxMlBom,
9342
- } = require('../../scripts/ai-component-inventory');
9343
- const requestedRoot = parsed.searchParams.get('root');
9344
- const serverRoot = process.cwd();
9345
- const rootDir = requestedRoot ? path.resolve(requestedRoot) : serverRoot;
9346
- const rootRel = path.relative(serverRoot, rootDir);
9347
- if (rootRel.startsWith('..') || path.isAbsolute(rootRel)) {
9348
- sendJson(res, 400, {
9349
- error: 'ai_inventory_root_out_of_scope',
9350
- message: 'Dashboard AI inventory root must stay within the server working directory. Use the CLI for explicit cross-project scans.',
9351
- });
9352
- return;
9353
- }
9354
- const inventory = scanAiComponents({
9355
- rootDir,
9356
- maxFiles: parsed.searchParams.get('maxFiles') ? Number(parsed.searchParams.get('maxFiles')) : undefined,
9357
- includeSnippets: parsed.searchParams.get('snippets') !== '0',
9358
- });
9359
- const format = String(parsed.searchParams.get('format') || 'json').toLowerCase();
9360
- sendJson(res, 200, format === 'cyclonedx' ? buildCycloneDxMlBom(inventory, { version: pkg.version }) : inventory);
9361
- } catch (err) {
9362
- sendJson(res, 500, {
9363
- error: 'ai_inventory_failed',
9364
- message: err?.message || 'Unable to scan AI component inventory.',
9365
- });
9366
- }
9367
- return;
9368
- }
9369
-
9370
7142
  // GET /v1/dashboard/review-state -- incremental review baseline and deltas
9371
7143
  if (req.method === 'GET' && pathname === '/v1/dashboard/review-state') {
9372
7144
  const reviewState = readDashboardReviewState(requestFeedbackDir);
@@ -9383,12 +7155,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9383
7155
 
9384
7156
  // POST /v1/dashboard/review-state -- mark current dashboard state as reviewed
9385
7157
  if (req.method === 'POST' && pathname === '/v1/dashboard/review-state') {
9386
- const body = await parseJsonBody(req);
9387
7158
  const snapshot = buildReviewSnapshot(requestFeedbackDir);
9388
- // Override snapshot timestamp with client-provided one if available
9389
- if (body?.reviewedAt) {
9390
- snapshot.reviewedAt = body.reviewedAt;
9391
- }
9392
7159
  writeDashboardReviewState(requestFeedbackDir, snapshot);
9393
7160
  const data = generateDashboard(requestFeedbackDir, {
9394
7161
  reviewBaseline: snapshot,
@@ -9520,18 +7287,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9520
7287
  });
9521
7288
  report.actionId = evaluation.actionId;
9522
7289
  if (report.decisionControl) report.decisionControl.actionId = evaluation.actionId;
9523
- const actionIntegration = inferActionIntegration(body, req.headers);
9524
- appendBestEffortTelemetry(requestFeedbackDir, {
9525
- eventType: chatgptActionEventType(actionIntegration, 'evaluate_decision'),
9526
- clientType: 'api',
9527
- source: actionIntegration,
9528
- integration: actionIntegration,
9529
- actionOperation: 'evaluateDecision',
9530
- endpoint: '/v1/decisions/evaluate',
9531
- actionStatus: 'accepted',
9532
- accepted: true,
9533
- decisionMode: report.decisionControl && report.decisionControl.executionMode,
9534
- }, req.headers, 'api_action_evaluate_decision');
9535
7290
  sendJson(res, 200, report);
9536
7291
  return;
9537
7292
  }
@@ -9636,7 +7391,6 @@ function startServer({ port, host } = {}) {
9636
7391
  const listenPort = Number(port ?? process.env.PORT ?? 8787);
9637
7392
  const listenHost = String(host ?? process.env.HOST ?? '0.0.0.0').trim() || '0.0.0.0';
9638
7393
  const server = createApiServer();
9639
- registerGracefulShutdown(server);
9640
7394
  return new Promise((resolve) => {
9641
7395
  server.listen(listenPort, listenHost, () => {
9642
7396
  const address = server.address();
@@ -9652,39 +7406,6 @@ function startServer({ port, host } = {}) {
9652
7406
  });
9653
7407
  }
9654
7408
 
9655
- // Railway / Cloud Run / Kubernetes deploy rotations send SIGTERM to swap
9656
- // containers. Without a handler, Node exits immediately — in-flight requests
9657
- // are killed and the orchestrator may mark the container as "crashed" (instead
9658
- // of "gracefully stopped"), wasting its restart-policy budget on a healthy
9659
- // shutdown. Drain HTTP, give a deadline, then force-exit if anything hangs.
9660
- function registerGracefulShutdown(server, { gracePeriodMs = 25_000 } = {}) {
9661
- if (server[GRACEFUL_SHUTDOWN_KEY]) return;
9662
- server[GRACEFUL_SHUTDOWN_KEY] = true;
9663
- let shuttingDown = false;
9664
- const stop = (signal) => {
9665
- if (shuttingDown) return;
9666
- shuttingDown = true;
9667
- console.log(`[shutdown] ${signal} received — draining connections (deadline ${gracePeriodMs}ms)`);
9668
- const forceTimer = setTimeout(() => {
9669
- console.error('[shutdown] grace period elapsed — forcing exit');
9670
- process.exit(1);
9671
- }, gracePeriodMs);
9672
- if (typeof forceTimer.unref === 'function') forceTimer.unref();
9673
- server.close((err) => {
9674
- if (err) {
9675
- console.error('[shutdown] server.close error:', err.message);
9676
- process.exit(1);
9677
- }
9678
- console.log('[shutdown] drained cleanly');
9679
- process.exit(0);
9680
- });
9681
- };
9682
- process.on('SIGTERM', () => stop('SIGTERM'));
9683
- process.on('SIGINT', () => stop('SIGINT'));
9684
- }
9685
-
9686
- const GRACEFUL_SHUTDOWN_KEY = Symbol.for('thumbgate.gracefulShutdownRegistered');
9687
-
9688
7409
  module.exports = {
9689
7410
  createApiServer,
9690
7411
  startServer,
@@ -9702,15 +7423,6 @@ module.exports = {
9702
7423
  renderPackagedLessonsHtml,
9703
7424
  readOptionalPublicTemplate,
9704
7425
  resolveLocalPageBootstrap,
9705
- getPublicMcpTools,
9706
- getServerCardTools,
9707
- buildEnterpriseDataChatStatus,
9708
- buildEnterpriseDialogflowStatus,
9709
- buildEnterpriseChatAnswer,
9710
- answerEnterpriseDataChat,
9711
- answerEnterpriseDialogflowChat,
9712
- buildLossAnalyticsResponse,
9713
- sanitizeHtmlUnsafeJsonValue,
9714
7426
  },
9715
7427
  };
9716
7428