thumbgate 1.27.18 → 1.27.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/.claude-plugin/marketplace.json +6 -6
  2. package/.claude-plugin/plugin.json +4 -3
  3. package/.well-known/agentic-verify.txt +1 -0
  4. package/.well-known/llms.txt +33 -12
  5. package/.well-known/mcp/server-card.json +8 -8
  6. package/README.md +249 -30
  7. package/adapters/chatgpt/openapi.yaml +12 -0
  8. package/adapters/claude/.mcp.json +2 -2
  9. package/adapters/codex/config.toml +2 -2
  10. package/adapters/gemini/function-declarations.json +1 -0
  11. package/adapters/mcp/server-stdio.js +263 -11
  12. package/adapters/opencode/opencode.json +1 -1
  13. package/bench/thumbgate-bench.json +2 -2
  14. package/bin/cli.js +1429 -121
  15. package/bin/postinstall.js +1 -8
  16. package/config/gate-classifier-routing.json +98 -0
  17. package/config/gate-templates.json +216 -0
  18. package/config/gates/claim-verification.json +12 -0
  19. package/config/gates/default.json +31 -2
  20. package/config/github-about.json +2 -2
  21. package/config/mcp-allowlists.json +23 -13
  22. package/config/merge-quality-checks.json +0 -1
  23. package/config/model-candidates.json +121 -6
  24. package/config/post-deploy-marketing-pages.json +80 -0
  25. package/config/tessl-tiles.json +1 -3
  26. package/openapi/openapi.yaml +12 -0
  27. package/package.json +1 -1
  28. package/public/blog.html +4 -4
  29. package/public/codex-plugin.html +72 -20
  30. package/public/compare.html +31 -8
  31. package/public/dashboard.html +930 -166
  32. package/public/federal.html +2 -2
  33. package/public/guide.html +33 -13
  34. package/public/index.html +469 -111
  35. package/public/learn.html +183 -18
  36. package/public/lessons.html +168 -10
  37. package/public/numbers.html +7 -7
  38. package/public/pro.html +34 -11
  39. package/scripts/agent-memory-lifecycle.js +211 -0
  40. package/scripts/agent-readiness.js +20 -3
  41. package/scripts/agent-reward-model.js +53 -1
  42. package/scripts/auto-promote-gates.js +82 -10
  43. package/scripts/auto-wire-hooks.js +14 -0
  44. package/scripts/billing.js +93 -1
  45. package/scripts/bot-detection.js +61 -3
  46. package/scripts/build-metadata.js +50 -10
  47. package/scripts/cli-feedback.js +4 -2
  48. package/scripts/cli-schema.js +97 -0
  49. package/scripts/cli-telemetry.js +6 -1
  50. package/scripts/commercial-offer.js +82 -2
  51. package/scripts/context-manager.js +74 -6
  52. package/scripts/dashboard.js +68 -2
  53. package/scripts/export-databricks-bundle.js +5 -1
  54. package/scripts/export-dpo-pairs.js +7 -2
  55. package/scripts/feedback-loop.js +123 -1
  56. package/scripts/feedback-quality.js +87 -0
  57. package/scripts/filesystem-search.js +35 -10
  58. package/scripts/gate-stats.js +89 -0
  59. package/scripts/gates-engine.js +1176 -85
  60. package/scripts/gemini-embedding-policy.js +2 -1
  61. package/scripts/hook-runtime.js +20 -14
  62. package/scripts/hook-thumbgate-cache-updater.js +18 -2
  63. package/scripts/hybrid-feedback-context.js +142 -7
  64. package/scripts/lesson-inference.js +8 -3
  65. package/scripts/lesson-search.js +17 -1
  66. package/scripts/license.js +10 -10
  67. package/scripts/llm-client.js +169 -4
  68. package/scripts/local-model-profile.js +15 -8
  69. package/scripts/mcp-config.js +7 -1
  70. package/scripts/memory-scope-readiness.js +159 -0
  71. package/scripts/meta-agent-loop.js +36 -0
  72. package/scripts/operational-integrity.js +39 -5
  73. package/scripts/oss-pr-opportunity-scout.js +35 -5
  74. package/scripts/plausible-server-events.js +9 -6
  75. package/scripts/pro-local-dashboard.js +4 -4
  76. package/scripts/proxy-pointer-rag-guardrails.js +42 -1
  77. package/scripts/published-cli.js +0 -8
  78. package/scripts/rate-limiter.js +64 -13
  79. package/scripts/secret-scanner.js +44 -5
  80. package/scripts/security-scanner.js +260 -10
  81. package/scripts/self-distill-agent.js +3 -1
  82. package/scripts/seo-gsd.js +916 -7
  83. package/scripts/statusline-cache-path.js +17 -2
  84. package/scripts/statusline-local-stats.js +9 -1
  85. package/scripts/statusline-meta.js +28 -2
  86. package/scripts/statusline.sh +20 -4
  87. package/scripts/telemetry-analytics.js +357 -0
  88. package/scripts/thompson-sampling.js +31 -10
  89. package/scripts/thumbgate-bench.js +16 -1
  90. package/scripts/thumbgate-search.js +85 -19
  91. package/scripts/tool-registry.js +169 -1
  92. package/scripts/vector-store.js +45 -0
  93. package/scripts/workflow-sentinel.js +286 -53
  94. package/scripts/workspace-evolver.js +62 -2
  95. package/src/api/server.js +2683 -319
  96. package/scripts/bot-detector.js +0 -50
package/src/api/server.js CHANGED
@@ -3,6 +3,7 @@ const http = require('http');
3
3
  const https = require('https');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const crypto = require('node:crypto');
6
7
  const { EventEmitter } = require('node:events');
7
8
  const pkg = require('../../package.json');
8
9
  const {
@@ -19,7 +20,7 @@ const POSTHOG_STATIC_PATH_PREFIX = '/static/';
19
20
  // Stripe catalog, with the per-tier thumbnails wired in. Re-run the
20
21
  // bootstrap workflow to regenerate; the new URLs surface in the workflow
21
22
  // summary log.
22
- const FIRST_FAILURE_RULE_CHECKOUT_URL = 'https://buy.stripe.com/fZu28rfCY6zcbO99uj3sI2G';
23
+ const FIRST_FAILURE_RULE_CHECKOUT_URL = 'https://buy.stripe.com/7sY6oHaiEbTw6tP5e33sI3e';
23
24
  const QUICK_READ_CHECKOUT_URL = 'https://buy.stripe.com/5kQ7sL76s1eSaK55e33sI2H';
24
25
  const WORKFLOW_TEARDOWN_CHECKOUT_URL = 'https://buy.stripe.com/8x214n2Qc4r44lHayn3sI2I';
25
26
  const SPRINT_DIAGNOSTIC_CHECKOUT_URL = 'https://buy.stripe.com/28E00j3Uge1E2dzgWL3sI2J';
@@ -96,6 +97,9 @@ const {
96
97
  const {
97
98
  recordCheckoutFunnelEvent,
98
99
  } = require('../../scripts/plausible-server-events');
100
+ const {
101
+ resolvePlausibleDataDomain,
102
+ } = require('../../scripts/plausible-domain-config');
99
103
  const {
100
104
  buildCloudflareSandboxPlan,
101
105
  } = require('../../scripts/cloudflare-dynamic-sandbox');
@@ -165,7 +169,16 @@ const {
165
169
  buildReviewSnapshot,
166
170
  readDashboardReviewState,
167
171
  writeDashboardReviewState,
172
+ collectAllFeedbackEntries,
168
173
  } = 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');
169
182
  const {
170
183
  buildDashboardRenderSpec,
171
184
  } = require('../../scripts/dashboard-render-spec');
@@ -199,6 +212,12 @@ const {
199
212
  } = require('../../scripts/rate-limiter');
200
213
  const { sendProblem, PROBLEM_TYPES } = require('../../scripts/problem-detail');
201
214
  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;
202
221
  const resendMailer = require('../../scripts/mailer/resend-mailer');
203
222
  const {
204
223
  buildContextFootprintReport,
@@ -214,17 +233,26 @@ const PRO_PAGE_PATH = path.resolve(__dirname, '../../public/pro.html');
214
233
  const DASHBOARD_PAGE_PATH = path.resolve(__dirname, '../../public/dashboard.html');
215
234
  const LESSONS_PAGE_PATH = path.resolve(__dirname, '../../public/lessons.html');
216
235
  const GUIDE_PAGE_PATH = path.resolve(__dirname, '../../public/guide.html');
236
+ const CHATGPT_APP_PAGE_PATH = path.resolve(__dirname, '../../public/chatgpt-app.html');
217
237
  const CODEX_PLUGIN_PAGE_PATH = path.resolve(__dirname, '../../public/codex-plugin.html');
218
238
  const COMPARE_PAGE_PATH = path.resolve(__dirname, '../../public/compare.html');
219
239
  const LEARN_PAGE_PATH = path.resolve(__dirname, '../../public/learn.html');
220
240
  const NUMBERS_PAGE_PATH = path.resolve(__dirname, '../../public/numbers.html');
221
241
  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
+ const DIAGNOSTIC_PAGE_PATH = path.resolve(__dirname, '../../public/diagnostic.html');
245
+ const INSTALL_PAGE_PATH = path.resolve(__dirname, '../../public/install.html');
222
246
  const LEARN_DIR = path.resolve(__dirname, '../../public/learn');
223
247
  const GUIDES_DIR = path.resolve(__dirname, '../../public/guides');
224
248
  const COMPARE_DIR = path.resolve(__dirname, '../../public/compare');
225
249
  const USE_CASES_DIR = path.resolve(__dirname, '../../public/use-cases');
226
250
  const PUBLIC_DIR = path.resolve(__dirname, '../../public');
227
251
  const PUBLIC_ASSETS_DIR = path.resolve(__dirname, '../../public/assets');
252
+ const LEARN_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(LEARN_DIR);
253
+ const GUIDE_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(GUIDES_DIR);
254
+ const COMPARE_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(COMPARE_DIR);
255
+ const USE_CASE_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(USE_CASES_DIR);
228
256
  const BUYER_INTENT_SCRIPT_PATH = path.resolve(__dirname, '../../public/js/buyer-intent.js');
229
257
  const STATIC_MIME_BY_EXT = Object.freeze({
230
258
  '.png': 'image/png',
@@ -343,6 +371,7 @@ function serveStaticFile(res, filePath, { headOnly = false, cacheSeconds = 86400
343
371
  res.setHeader('Content-Type', contentType);
344
372
  res.setHeader('Content-Length', stat.size);
345
373
  res.setHeader('Cache-Control', `public, max-age=${cacheSeconds}, immutable`);
374
+ res.setHeader('Referrer-Policy', 'same-origin');
346
375
  if (headOnly) {
347
376
  res.end();
348
377
  return;
@@ -413,27 +442,75 @@ const TRACKED_LINK_TARGETS = Object.freeze({
413
442
  },
414
443
  allowCustomerEmail: true,
415
444
  },
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).
445
+ // 2026-06-02: Teams/Aiventyx deprecated. Redirect legacy links to Pro.
422
446
  teams: {
447
+ path: '/go/pro?utm_source=legacy_teams&utm_medium=redirect',
448
+ ctaId: 'go_pro',
449
+ ctaPlacement: 'link_router',
450
+ eventType: 'cta_click',
451
+ },
452
+ team: {
453
+ path: '/go/pro?utm_source=legacy_teams&utm_medium=redirect',
454
+ ctaId: 'go_pro',
455
+ ctaPlacement: 'link_router',
456
+ eventType: 'cta_click',
457
+ },
458
+ checkout: {
423
459
  path: '/checkout/pro',
424
- ctaId: 'go_teams',
460
+ ctaId: 'go_checkout',
425
461
  ctaPlacement: 'link_router',
426
462
  eventType: 'cta_click',
427
463
  defaults: {
428
464
  utm_source: 'website',
429
465
  utm_medium: 'link_router',
430
- utm_campaign: 'team_self_serve',
431
- plan_id: 'team',
432
- seat_count: '3',
466
+ utm_campaign: 'pro_upgrade',
467
+ plan_id: 'pro',
433
468
  billing_cycle: 'monthly',
434
469
  },
435
470
  allowCustomerEmail: true,
436
471
  },
472
+ diagnostic: {
473
+ configUrlKey: 'sprintDiagnosticCheckoutUrl',
474
+ fallbackHref: SPRINT_DIAGNOSTIC_CHECKOUT_URL,
475
+ external: true,
476
+ ctaId: 'go_diagnostic',
477
+ ctaPlacement: 'link_router',
478
+ eventType: 'cta_click',
479
+ defaults: {
480
+ utm_source: 'website',
481
+ utm_medium: 'link_router',
482
+ utm_campaign: 'sprint_diagnostic',
483
+ plan_id: 'sprint_diagnostic',
484
+ },
485
+ allowCustomerEmail: true,
486
+ },
487
+ sprint: {
488
+ configUrlKey: 'workflowSprintCheckoutUrl',
489
+ fallbackHref: WORKFLOW_SPRINT_CHECKOUT_URL,
490
+ external: true,
491
+ ctaId: 'go_sprint',
492
+ ctaPlacement: 'link_router',
493
+ eventType: 'cta_click',
494
+ defaults: {
495
+ utm_source: 'website',
496
+ utm_medium: 'link_router',
497
+ utm_campaign: 'workflow_sprint',
498
+ plan_id: 'workflow_sprint',
499
+ },
500
+ allowCustomerEmail: true,
501
+ },
502
+ trial: {
503
+ path: '/guide',
504
+ ctaId: 'go_trial',
505
+ ctaPlacement: 'link_router',
506
+ eventType: 'trial_start_click',
507
+ defaults: {
508
+ utm_source: 'website',
509
+ utm_medium: 'link_router',
510
+ utm_campaign: 'trial_start',
511
+ plan_id: 'free_trial',
512
+ },
513
+ },
437
514
  install: {
438
515
  path: '/guide',
439
516
  ctaId: 'go_install',
@@ -648,6 +725,51 @@ function resolveRequestProjectDir(req, parsed) {
648
725
  });
649
726
  }
650
727
 
728
+ function debugApiFallback(label, error) {
729
+ if (process.env.THUMBGATE_DEBUG_API !== '1') return;
730
+ console.warn(`[api] ${label}: ${error?.message || String(error)}`);
731
+ }
732
+
733
+ function stripEnvQuotes(value) {
734
+ return String(value || '').trim().replace(/^["']|["']$/g, '');
735
+ }
736
+
737
+ function extractEnvValue(content, names) {
738
+ const pattern = new RegExp(`^(?:${names.join('|')})=(.*)$`, 'm');
739
+ const match = pattern.exec(content);
740
+ return match ? stripEnvQuotes(match[1]) : '';
741
+ }
742
+
743
+ function readProjectChatSettings(req, parsed) {
744
+ const settings = {
745
+ geminiKey: '',
746
+ perplexityKey: '',
747
+ localEndpoint: '',
748
+ localModel: '',
749
+ geminiValidatedAt: null,
750
+ };
751
+ try {
752
+ const projectDir = resolveRequestProjectDir(req, parsed);
753
+ const envPath = path.join(projectDir, '.env');
754
+ if (fs.existsSync(envPath)) {
755
+ const content = fs.readFileSync(envPath, 'utf8');
756
+ settings.geminiKey = extractEnvValue(content, ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'THUMBGATE_GEMINI_API_KEY']);
757
+ settings.perplexityKey = extractEnvValue(content, ['PERPLEXITY_API_KEY', 'THUMBGATE_PERPLEXITY_API_KEY']);
758
+ settings.localEndpoint = extractEnvValue(content, ['THUMBGATE_LOCAL_LLM_ENDPOINT']);
759
+ settings.localModel = extractEnvValue(content, ['THUMBGATE_LOCAL_LLM_MODEL']);
760
+ }
761
+
762
+ const statusPath = path.join(projectDir, '.gemini-validated.json');
763
+ if (fs.existsSync(statusPath)) {
764
+ const status = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
765
+ settings.geminiValidatedAt = status.validatedAt || null;
766
+ }
767
+ } catch (error) {
768
+ debugApiFallback('project chat settings unavailable', error);
769
+ }
770
+ return settings;
771
+ }
772
+
651
773
  function shouldPreferProjectScopedFeedback(req, parsed) {
652
774
  const explicitProject = getEffectiveRequestedProjectSelection(req, parsed);
653
775
  if (explicitProject) return true;
@@ -713,16 +835,23 @@ function updateLessonRecord(feedbackDir, lessonId, updater) {
713
835
  function getPublicMcpTools() {
714
836
  return MCP_TOOLS.map((tool) => ({
715
837
  name: tool.name,
838
+ ...(tool.title ? { title: tool.title } : {}),
716
839
  description: tool.description,
717
840
  inputSchema: tool.inputSchema,
841
+ // Serve the tool-registry annotations (readOnlyHint/destructiveHint). Required
842
+ // by the Claude Connectors Directory (missing annotations = the #1 rejection
843
+ // cause) and used by MCP clients for permission prompts. Was being dropped here.
844
+ ...(tool.annotations ? { annotations: tool.annotations } : {}),
718
845
  }));
719
846
  }
720
847
 
721
848
  function getServerCardTools() {
722
849
  return MCP_TOOLS.map((tool) => ({
723
850
  name: tool.name,
851
+ ...(tool.title ? { title: tool.title } : {}),
724
852
  description: tool.description,
725
853
  inputSchema: tool.inputSchema,
854
+ ...(tool.annotations ? { annotations: tool.annotations } : {}),
726
855
  }));
727
856
  }
728
857
 
@@ -762,7 +891,7 @@ function getMcpSkillManifests(hostedConfig) {
762
891
  'Inspect prevention_rules after repeats.',
763
892
  ],
764
893
  installCommand: 'npx thumbgate init',
765
- contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
894
+ contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
766
895
  proofUrl: VERIFICATION_EVIDENCE_URL,
767
896
  },
768
897
  {
@@ -789,7 +918,7 @@ function getMcpSkillManifests(hostedConfig) {
789
918
  'Evaluate NDCG@10 on visual hard negatives.',
790
919
  'Require artifact links before using retrieved evidence in claims.',
791
920
  ],
792
- contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
921
+ contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
793
922
  proofUrl: VERIFICATION_EVIDENCE_URL,
794
923
  },
795
924
  {
@@ -803,7 +932,7 @@ function getMcpSkillManifests(hostedConfig) {
803
932
  'Compact feedback context with anchors for proof-critical lessons.',
804
933
  'Record estimated token savings next to the workflow evidence.',
805
934
  ],
806
- contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
935
+ contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
807
936
  footprintUrl: buildPublicUrl(hostedConfig, '/.well-known/mcp/footprint.json'),
808
937
  proofUrl: VERIFICATION_EVIDENCE_URL,
809
938
  },
@@ -818,7 +947,7 @@ function getMcpSkillManifests(hostedConfig) {
818
947
  'Require baseline evals before adding autonomy or subagents.',
819
948
  'Classify tool risk before allowing writes, money movement, production changes, or outbound actions.',
820
949
  ],
821
- contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
950
+ contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
822
951
  proofUrl: VERIFICATION_EVIDENCE_URL,
823
952
  },
824
953
  {
@@ -832,7 +961,7 @@ function getMcpSkillManifests(hostedConfig) {
832
961
  'Grade goal inference separately from intervention timing.',
833
962
  'Block multi-app proactive writes until rollback and orchestration evidence exists.',
834
963
  ],
835
- contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
964
+ contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
836
965
  proofUrl: VERIFICATION_EVIDENCE_URL,
837
966
  },
838
967
  {
@@ -846,7 +975,7 @@ function getMcpSkillManifests(hostedConfig) {
846
975
  'Map every proxy metric to the real user objective.',
847
976
  'Require holdout or regression proof before treating benchmark gains as product gains.',
848
977
  ],
849
- contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
978
+ contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
850
979
  proofUrl: VERIFICATION_EVIDENCE_URL,
851
980
  },
852
981
  {
@@ -860,7 +989,7 @@ function getMcpSkillManifests(hostedConfig) {
860
989
  'Reproduce locally before claiming a fix.',
861
990
  'Open one focused PR with tests, proof, and transparent ThumbGate context only when relevant.',
862
991
  ],
863
- contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
992
+ contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
864
993
  proofUrl: VERIFICATION_EVIDENCE_URL,
865
994
  },
866
995
  {
@@ -874,7 +1003,7 @@ function getMcpSkillManifests(hostedConfig) {
874
1003
  'Route self-serve intent to the guide and team pain to Workflow Hardening Sprint intake.',
875
1004
  'Block unsupported ad and landing-page claims before spend scales.',
876
1005
  ],
877
- contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
1006
+ contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
878
1007
  proofUrl: VERIFICATION_EVIDENCE_URL,
879
1008
  },
880
1009
  ];
@@ -1008,7 +1137,7 @@ function getMcpDiscoveryManifest(hostedConfig) {
1008
1137
  footprint: getContextFootprintReport(hostedConfig),
1009
1138
  proof: {
1010
1139
  verificationEvidenceUrl: VERIFICATION_EVIDENCE_URL,
1011
- llmContextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
1140
+ llmContextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
1012
1141
  },
1013
1142
  };
1014
1143
  }
@@ -1418,8 +1547,450 @@ async function loadLiveDashboardDataOrRespondProblem(res, parsed, feedbackDir, i
1418
1547
  }
1419
1548
  }
1420
1549
 
1421
- function buildLossAnalyticsResponse(data, summaryOptions) {
1550
+ function buildEnterpriseDataChatStatus(env = process.env) {
1551
+ const vertexProject = normalizeNullableText(env.VERTEX_PROJECT_ID)
1552
+ || normalizeNullableText(env.GOOGLE_VERTEX_PROJECT);
1553
+ const vertexLocation = normalizeNullableText(env.GOOGLE_VERTEX_LOCATION)
1554
+ || normalizeNullableText(env.VERTEX_LOCATION)
1555
+ || 'us-central1';
1556
+ const dfcxFulfillmentUrl = normalizeNullableText(env.THUMBGATE_DFCX_FULFILLMENT_URL);
1557
+ const dfcxAgentId = normalizeNullableText(env.THUMBGATE_DFCX_AGENT_ID);
1558
+ const dfcxLocation = normalizeNullableText(env.THUMBGATE_DFCX_LOCATION);
1559
+
1560
+ return {
1561
+ mode: 'local-dashboard',
1562
+ vertex: {
1563
+ configured: Boolean(vertexProject),
1564
+ projectId: vertexProject,
1565
+ location: vertexLocation,
1566
+ providerMode: normalizeNullableText(env.THUMBGATE_PROVIDER_MODE) || null,
1567
+ },
1568
+ dfcx: {
1569
+ apiSurface: 'Dialogflow CX REST API: projects.locations.agents',
1570
+ liveAgentConfigured: Boolean(dfcxAgentId && dfcxLocation),
1571
+ agentId: dfcxAgentId,
1572
+ location: dfcxLocation,
1573
+ fulfillmentProxyConfigured: Boolean(dfcxFulfillmentUrl),
1574
+ fulfillmentUrlConfigured: Boolean(dfcxFulfillmentUrl),
1575
+ gcloudCxCommandSupported: false,
1576
+ verification: dfcxAgentId && dfcxLocation
1577
+ ? 'Agent metadata is present in env; verify via REST/console before production claims.'
1578
+ : 'No live DFCX agent env configured. Use REST/console/deployed webhook evidence before claiming a live agent.',
1579
+ },
1580
+ chat: {
1581
+ available: true,
1582
+ source: 'local ThumbGate dashboard data with LanceDB-backed retrieval',
1583
+ guard: 'local data-access guard; DFCX adapter optional for customer Dialogflow deployments',
1584
+ providerRequired: false,
1585
+ localLlmEndpointConfigured: Boolean(normalizeNullableText(env.THUMBGATE_LOCAL_LLM_ENDPOINT)),
1586
+ },
1587
+ };
1588
+ }
1589
+
1590
+ const buildEnterpriseDialogflowStatus = buildEnterpriseDataChatStatus;
1591
+
1592
+ function normalizeEnterpriseChatPrompt(value) {
1593
+ const text = normalizeNullableText(value);
1594
+ if (!text) return null;
1595
+ return text.slice(0, 800);
1596
+ }
1597
+
1598
+ function classifyEnterpriseChatTopic(prompt) {
1599
+ const lower = String(prompt || '').toLowerCase();
1600
+ // Feedback-specific words run FIRST: "what mistakes were blocked today" is a
1601
+ // feedback question, not a gates question — must not be hijacked by /block/.
1602
+ if (/mistake|lesson|memory|feedback|thumb|negative|positive|what (?:went )?wrong|fail|win|success|worked|good/.test(lower)) return 'feedback';
1603
+ if (/gate|block|deny|prevent|guard|enforce/.test(lower)) return 'gates';
1604
+ if (/team|agent|org|enterprise|rollout/.test(lower)) return 'team';
1605
+ if (/token|cost|saving|budget|spend/.test(lower)) return 'cost';
1606
+ if (/vertex|gcp|google|dialogflow|dfcx|cloud/.test(lower)) return 'cloud';
1607
+ return 'overview';
1608
+ }
1609
+
1610
+ // Parse intent: LIST vs COUNT, and time window. Lets us answer "what mistakes
1611
+ // today?" with an actual filtered list instead of a canned total.
1612
+ function parseChatIntent(prompt) {
1613
+ const lower = String(prompt || '').toLowerCase();
1614
+ const terms = new Set(lower.split(/[^a-z0-9]+/).filter(Boolean));
1615
+ const hasTerm = (term) => terms.has(term);
1616
+ const wantsList = lower.includes('tell me about') ||
1617
+ ['what', 'which', 'list', 'show', 'example', 'examples'].some(hasTerm);
1618
+ let windowMs = null;
1619
+ let windowLabel = 'across all time';
1620
+ if (/\btoday\b/.test(lower)) { windowMs = 24 * 60 * 60 * 1000; windowLabel = 'today'; }
1621
+ else if (/\byesterday\b/.test(lower)) { windowMs = 48 * 60 * 60 * 1000; windowLabel = 'yesterday'; }
1622
+ 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'; }
1623
+ 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'; }
1624
+ return { wantsList, windowMs, windowLabel };
1625
+ }
1626
+
1627
+ // Read recent feedback entries (signal-filtered, time-filtered) directly from
1628
+ // the feedback log. Bounded + best-effort — never throws into the chat handler.
1629
+ // Treat short/placeholder context values as "no real description" so we don't
1630
+ // surface `"thumbs down"` × 3 as a useful list. Real feedback always has a
1631
+ // concrete sentence somewhere (whatWentWrong, distillation, whatToChange) —
1632
+ // pick the longest informative one.
1633
+ // Short tokens that mean "no real description" — kept as a plain Set, not a
1634
+ // big regex, to keep complexity low and easy to extend.
1635
+ const PLACEHOLDER_TOKENS = new Set([
1636
+ 'thumbs down', 'thumbs up', 'thumb down', 'thumb up',
1637
+ 'good', 'bad', 'ok', 'nice', 'verify', 'verifies', 'verification', 'test', 'testing',
1638
+ ]);
1639
+
1640
+ function isPlaceholder(text) {
1641
+ const t = String(text || '').trim();
1642
+ if (!t || t.length < 20) return true;
1643
+ return PLACEHOLDER_TOKENS.has(t.toLowerCase().replace(/\.$/, ''));
1644
+ }
1645
+
1646
+ function bestFeedbackDescription(row) {
1647
+ const candidates = [row.whatWentWrong, row.distillation, row.context, row.whatToChange, row.whatWorked, row.reasoning]
1648
+ .map((c) => String(c || '').trim());
1649
+ // Prefer the longest non-placeholder candidate; fall back to any non-empty.
1650
+ const informative = candidates.filter((c) => c && !isPlaceholder(c));
1651
+ const fallback = candidates.find(Boolean) || '';
1652
+ const best = informative.reduce((a, b) => (b.length > a.length ? b : a), '') || fallback;
1653
+ return best.slice(0, 220);
1654
+ }
1655
+
1656
+ function readRecentFeedbackEntries(feedbackDir, signal, windowMs, limit = 5, opts = {}) {
1657
+ try {
1658
+ if (!feedbackDir) return [];
1659
+ const rows = shouldAggregateFeedback()
1660
+ ? collectAggregateLogEntries('feedback-log.jsonl', { feedbackDir }).entries
1661
+ : (() => {
1662
+ const fsLocal = require('node:fs');
1663
+ const pathLocal = require('node:path');
1664
+ const logPath = pathLocal.join(feedbackDir, 'feedback-log.jsonl');
1665
+ if (!fsLocal.existsSync(logPath)) return [];
1666
+ const { readJsonl } = require('../../scripts/fs-utils');
1667
+ return readJsonl(logPath) || [];
1668
+ })();
1669
+ const cutoff = windowMs ? Date.now() - windowMs : 0;
1670
+ const filtered = rows
1671
+ .filter((r) => !signal || r.signal === signal)
1672
+ .filter((r) => {
1673
+ if (!cutoff) return true;
1674
+ const t = r.timestamp ? Date.parse(r.timestamp) : Number.NaN;
1675
+ return Number.isFinite(t) && t >= cutoff;
1676
+ })
1677
+ .reverse()
1678
+ .map((r) => {
1679
+ const out = {
1680
+ timestamp: r.timestamp,
1681
+ context: bestFeedbackDescription(r),
1682
+ tags: Array.isArray(r.tags) ? r.tags : []
1683
+ };
1684
+ if (opts.includeSignal) out.signal = r.signal;
1685
+ return out;
1686
+ });
1687
+ // For list display, drop entries with no real description so the list is useful,
1688
+ // not three "thumbs down" placeholders. For count-only (high limit), keep all.
1689
+ const useful = opts.skipPlaceholders === false || limit > 50
1690
+ ? filtered
1691
+ : filtered.filter((e) => e.context && !isPlaceholder(e.context));
1692
+ return useful.slice(0, limit);
1693
+ } catch {
1694
+ return [];
1695
+ }
1696
+ }
1697
+
1698
+ function containsUnsafeEnterpriseChatInput(prompt) {
1699
+ return /[;&|`$<>\\]/.test(String(prompt || ''));
1700
+ }
1701
+
1702
+ function compactNumber(value) {
1703
+ const n = Number(value || 0);
1704
+ return Number.isFinite(n) ? n : 0;
1705
+ }
1706
+
1707
+ // Pick a signal preference from the prompt. Returns 'negative' | 'positive' | null.
1708
+ function detectFeedbackSignalFromPrompt(prompt) {
1709
+ const lower = String(prompt || '').toLowerCase();
1710
+ const wantsNeg = /mistake|wrong|fail|negative|thumbs? *down|block/.test(lower);
1711
+ const wantsPos = /positive|thumbs? *up|worked|success|wins?\b|good/.test(lower);
1712
+ if (wantsNeg && !wantsPos) return 'negative';
1713
+ if (wantsPos && !wantsNeg) return 'positive';
1714
+ return null;
1715
+ }
1716
+
1717
+ const FEEDBACK_LIST_LABELS = Object.freeze({
1718
+ negative: 'Recent mistakes',
1719
+ positive: 'Recent wins',
1720
+ });
1721
+
1722
+ const FEEDBACK_OMITTED_TAGS = Object.freeze(['audit-trail', 'auto-capture']);
1723
+
1724
+ function formatChatTimestamp(isoString) {
1725
+ if (!isoString) return 'unknown time';
1726
+ try {
1727
+ const d = new Date(isoString);
1728
+ if (Number.isNaN(d.getTime())) return isoString;
1729
+ const pad = (n) => String(n).padStart(2, '0');
1730
+ const yyyy = d.getFullYear();
1731
+ const mm = pad(d.getMonth() + 1);
1732
+ const dd = pad(d.getDate());
1733
+ const hh = pad(d.getHours());
1734
+ const min = pad(d.getMinutes());
1735
+ const ss = pad(d.getSeconds());
1736
+ return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}`;
1737
+ } catch {
1738
+ return isoString;
1739
+ }
1740
+ }
1741
+
1742
+ function buildFeedbackEntries(windowed, signal) {
1743
+ return (signal ? windowed.filter((r) => r.signal === signal) : windowed)
1744
+ .filter((r) => r.context && !isPlaceholder(r.context))
1745
+ .slice(0, 5);
1746
+ }
1747
+
1748
+ function formatFeedbackEntry(entry) {
1749
+ const tsFormatted = formatChatTimestamp(entry.timestamp);
1750
+ const signalLabel = entry.signal ? ` [${entry.signal}]` : '';
1751
+ const tagsList = Array.isArray(entry.tags)
1752
+ ? entry.tags.filter((tag) => !FEEDBACK_OMITTED_TAGS.includes(tag))
1753
+ : [];
1754
+ const tagsStr = tagsList.length ? ` (${tagsList.join(', ')})` : '';
1755
+ return ` • ${tsFormatted}${signalLabel}${tagsStr} — ${entry.context}`;
1756
+ }
1757
+
1758
+ function appendFeedbackListLines(lines, { entries, signal, intent }) {
1759
+ if (!entries.length) {
1760
+ lines.push(`No ${signal || 'feedback'} entries found ${intent.windowLabel}.`);
1761
+ return;
1762
+ }
1763
+ lines.push(`${FEEDBACK_LIST_LABELS[signal] || 'Recent feedback'} (${intent.windowLabel}):`);
1764
+ lines.push(...entries.map(formatFeedbackEntry));
1765
+ }
1766
+
1767
+ function buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeline }) {
1768
+ const signal = detectFeedbackSignalFromPrompt(ctx.prompt);
1769
+ // One read of the time-windowed log, then in-memory counts + (signal-filtered,
1770
+ // placeholder-stripped) list. Counts include ALL entries (so "Feedback today: 5"
1771
+ // matches the dashboard tile); the list drops vague entries like literal "thumbs
1772
+ // down" / "good" so it surfaces only entries with real, actionable description.
1773
+ const windowed = readRecentFeedbackEntries(feedbackDir, null, intent.windowMs, 10000, { includeSignal: true });
1774
+ const windowPos = windowed.filter((r) => r.signal === 'positive').length;
1775
+ const windowNeg = windowed.filter((r) => r.signal === 'negative').length;
1776
+ const entries = buildFeedbackEntries(windowed, signal);
1777
+
1778
+ const lines = [
1779
+ intent.windowMs
1780
+ ? `Feedback ${intent.windowLabel}: ${windowed.length} (${windowPos} positive, ${windowNeg} negative).`
1781
+ : `Feedback total: ${compactNumber(approval.total)} (${compactNumber(approval.positive)} positive, ${compactNumber(approval.negative)} negative).`,
1782
+ ];
1783
+
1784
+ if (intent.wantsList) {
1785
+ appendFeedbackListLines(lines, { entries, signal, intent });
1786
+ } else {
1787
+ lines.push(`Lesson pipeline: ${compactNumber(lessonPipeline.lessons || lessonPipeline.generated || 0)} lessons visible in the current dashboard snapshot.`);
1788
+ }
1789
+ return { lines, sources: ['feedback log', intent.wantsList ? 'feedback contexts' : 'lesson pipeline'] };
1790
+ }
1791
+
1792
+ const GATE_EVENTS_REGEX = /activat|fired|trigger|block|denied|prevent|enforce|hit/;
1793
+
1794
+ function describeGate(g) {
1795
+ return `${g.name || g.id || 'unnamed'}${g.severity ? ' [' + g.severity + ']' : ''}`;
1796
+ }
1797
+
1798
+ function buildGatesSection({ ctx, intent, gates, gateStats }) {
1799
+ const asksEvents = GATE_EVENTS_REGEX.test(String(ctx.prompt || '').toLowerCase());
1800
+ const totalActive = gates.length || compactNumber(gateStats.totalGates);
1801
+ const totalBlocked = compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked);
1802
+
1803
+ const lines = [];
1804
+ if (asksEvents) {
1805
+ lines.push(`Blocked actions recorded (all time): ${totalBlocked}.`);
1806
+ if (intent.windowMs) {
1807
+ 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.)`);
1808
+ }
1809
+ } else {
1810
+ lines.push(`Active gates: ${totalActive}.`);
1811
+ }
1812
+ if (intent.wantsList && gates.length) {
1813
+ lines.push('Active gates:');
1814
+ for (const g of gates.slice(0, 8)) lines.push(` • ${describeGate(g)}`);
1815
+ } else if (gates[0] && !intent.wantsList) {
1816
+ lines.push(`Example gate: ${describeGate(gates[0])}.`);
1817
+ }
1818
+ return { lines, sources: ['gate stats'] };
1819
+ }
1820
+
1821
+ function buildEnterpriseChatSection(topic, dashboardData, status, ctx = {}) {
1822
+ const approval = dashboardData.approval || {};
1823
+ const gates = Array.isArray(dashboardData.gates) ? dashboardData.gates : [];
1824
+ const gateStats = dashboardData.gateStats || {};
1825
+ const team = dashboardData.team || {};
1826
+ const tokenSavings = dashboardData.tokenSavings || {};
1827
+ const lessonPipeline = dashboardData.lessonPipeline || {};
1828
+ const intent = ctx.intent || { wantsList: false, windowMs: null, windowLabel: 'across all time' };
1829
+ const feedbackDir = ctx.feedbackDir || null;
1830
+
1831
+ if (topic === 'feedback') {
1832
+ return buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeline });
1833
+ }
1834
+ if (topic === 'gates') {
1835
+ return buildGatesSection({ ctx, intent, gates, gateStats });
1836
+ }
1837
+ if (topic === 'team') {
1838
+ return {
1839
+ lines: [
1840
+ 'Team dashboard is available in this local Enterprise view.',
1841
+ `Tracked agents: ${compactNumber(team.totalAgents || team.agentCount || 0)}; risky agents: ${compactNumber(team.riskyAgents || team.highRiskAgents || 0)}.`,
1842
+ ],
1843
+ sources: ['team dashboard'],
1844
+ };
1845
+ }
1846
+ if (topic === 'cost') {
1847
+ return {
1848
+ lines: [
1849
+ `Estimated token savings: ${tokenSavings.dollarsSavedDisplay || '$0.00'} from ${compactNumber(tokenSavings.blockedCalls)} blocked calls.`,
1850
+ 'Google Cloud budget alerts are evidence for spend visibility; ThumbGate-side stop conditions must be verified separately before calling them a hard cap.',
1851
+ ],
1852
+ sources: ['token savings', 'budget posture'],
1853
+ };
1854
+ }
1855
+ if (topic === 'cloud') {
1856
+ const vertexLine = status.vertex.configured
1857
+ ? `Vertex routing config is present for project ${status.vertex.projectId} (${status.vertex.location}).`
1858
+ : 'Vertex routing config is not present in this server environment.';
1859
+ const dfcxLine = status.dfcx.liveAgentConfigured
1860
+ ? `DFCX env has agent ${status.dfcx.agentId} in ${status.dfcx.location}; verify it with REST/console before production claims.`
1861
+ : '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.';
1862
+ return {
1863
+ lines: [vertexLine, dfcxLine],
1864
+ sources: ['enterprise cloud status'],
1865
+ };
1866
+ }
1867
+ return {
1868
+ lines: [
1869
+ 'Ask about feedback, lessons, active gates, team rollout, token savings, or Vertex/DFCX readiness.',
1870
+ `Current local snapshot: ${compactNumber(approval.total)} feedback events and ${gates.length || compactNumber(gateStats.totalGates)} active gates.`,
1871
+ ],
1872
+ sources: [],
1873
+ };
1874
+ }
1875
+
1876
+ function buildEnterpriseChatAnswer(prompt, dashboardData, status, opts = {}) {
1877
+ const topic = classifyEnterpriseChatTopic(prompt);
1878
+ const intent = parseChatIntent(prompt);
1879
+ const section = buildEnterpriseChatSection(topic, dashboardData, status, {
1880
+ intent,
1881
+ feedbackDir: opts.feedbackDir,
1882
+ prompt,
1883
+ });
1884
+
1885
+ // List-style answers want newlines; single-line answers join with space.
1886
+ const hasList = section.lines.some((l) => /^\s*•/.test(l));
1887
+ const answer = hasList ? section.lines.join('\n') : section.lines.join(' ');
1888
+
1889
+ return {
1890
+ topic,
1891
+ answer,
1892
+ sources: ['local dashboard data', ...section.sources],
1893
+ };
1894
+ }
1895
+
1896
+ // Answer the dashboard "Chat with your data" panel LOCALLY (deterministic, no
1897
+ // cloud/LLM) from this install's own per-project dashboard data. Returns true if
1898
+ // it sent a response; false if the local snapshot was unavailable (caller then
1899
+ // falls through). Shared by the local-first path and the no-model fallback.
1900
+ async function trySendLocalDashboardChat(res, parsed, feedbackDir, prompt, suffix) {
1901
+ try {
1902
+ const dashboardResult = await buildLiveDashboardData(parsed, feedbackDir);
1903
+ const localChat = buildEnterpriseChatAnswer(
1904
+ prompt,
1905
+ dashboardResult.data,
1906
+ buildEnterpriseDataChatStatus(),
1907
+ { feedbackDir },
1908
+ );
1909
+ sendJson(res, 200, {
1910
+ ok: true,
1911
+ answer: suffix ? `${localChat.answer} ${suffix}` : localChat.answer,
1912
+ sources: (localChat.sources || []).map((title) => ({ title })),
1913
+ topic: localChat.topic,
1914
+ provider: 'local-data',
1915
+ llm: 'none',
1916
+ grounded: true,
1917
+ });
1918
+ return true;
1919
+ } catch {
1920
+ return false;
1921
+ }
1922
+ }
1923
+
1924
+ async function answerEnterpriseDataChat({ prompt, feedbackDir, parsed }) {
1925
+ const normalizedPrompt = normalizeEnterpriseChatPrompt(prompt);
1926
+ if (!normalizedPrompt) {
1927
+ throw createHttpError(400, 'prompt is required');
1928
+ }
1929
+ const status = buildEnterpriseDataChatStatus();
1930
+ if (containsUnsafeEnterpriseChatInput(normalizedPrompt)) {
1931
+ return {
1932
+ ok: false,
1933
+ blocked: true,
1934
+ answer: 'This prompt contains unsafe control characters and was blocked before data access.',
1935
+ status,
1936
+ dfcx: {
1937
+ blocked: true,
1938
+ evaluation: {
1939
+ allowed: false,
1940
+ gate: 'enterprise-chat-unsafe-input',
1941
+ severity: 'critical',
1942
+ },
1943
+ },
1944
+ sources: ['enterprise input guard'],
1945
+ };
1946
+ }
1947
+
1948
+ const dashboardResult = await buildLiveDashboardData(parsed, feedbackDir);
1949
+ const dashboardData = dashboardResult.data;
1950
+
1951
+ // This guarded stats endpoint stays deterministic for API compatibility.
1952
+ // The dashboard's real local/open-source chatbot turn goes through /v1/chat,
1953
+ // which uses lesson retrieval + optional LanceDB vector search + the user's
1954
+ // configured local or BYO model.
1955
+ const chat = buildEnterpriseChatAnswer(normalizedPrompt, dashboardData, status, { feedbackDir });
1956
+ const dfcxRequest = {
1957
+ fulfillmentInfo: { tag: 'chat-with-data' },
1958
+ sessionInfo: {
1959
+ session: 'local-dashboard/enterprise-chat',
1960
+ parameters: {
1961
+ topic: chat.topic,
1962
+ prompt_key: normalizedPrompt.toLowerCase().replace(/[^a-z0-9._ -]/g, '').slice(0, 64),
1963
+ },
1964
+ },
1965
+ languageCode: 'en',
1966
+ };
1967
+ const guarded = await guardDfcxWebhook(
1968
+ dfcxRequest,
1969
+ async () => ({
1970
+ fulfillment_response: { messages: [{ text: { text: [chat.answer] } }] },
1971
+ session_info: { parameters: { thumbgate_topic: chat.topic } },
1972
+ }),
1973
+ { blockOnRepeat: false },
1974
+ );
1975
+
1422
1976
  return {
1977
+ ok: !guarded.blocked,
1978
+ blocked: Boolean(guarded.blocked),
1979
+ answer: guarded.blocked ? 'ThumbGate blocked this enterprise chat turn before data access.' : chat.answer,
1980
+ status,
1981
+ dfcx: {
1982
+ blocked: Boolean(guarded.blocked),
1983
+ evaluation: guarded.evaluation,
1984
+ response: guarded.response,
1985
+ },
1986
+ sources: chat.sources,
1987
+ };
1988
+ }
1989
+
1990
+ const answerEnterpriseDialogflowChat = answerEnterpriseDataChat;
1991
+
1992
+ function buildLossAnalyticsResponse(data, summaryOptions) {
1993
+ return sanitizeHtmlUnsafeJsonValue({
1423
1994
  window: data.analytics.window || summaryOptions,
1424
1995
  lossAnalysis: data.analytics.lossAnalysis || null,
1425
1996
  buyerLoss: data.analytics.buyerLoss || null,
@@ -1431,13 +2002,50 @@ function buildLossAnalyticsResponse(data, summaryOptions) {
1431
2002
  ctas: data.analytics.telemetry && data.analytics.telemetry.ctas,
1432
2003
  visitors: data.analytics.telemetry && data.analytics.telemetry.visitors,
1433
2004
  },
1434
- };
2005
+ });
1435
2006
  }
1436
2007
 
1437
2008
  function createJourneyId(prefix) {
1438
2009
  return createTraceId(prefix).replace(/^trace_/, `${prefix}_`);
1439
2010
  }
1440
2011
 
2012
+ function normalizeCheckoutInterstitialSampleRate(value) {
2013
+ const parsed = Number.parseFloat(String(value || '').trim());
2014
+ if (!Number.isFinite(parsed) || parsed <= 0) {
2015
+ return 0;
2016
+ }
2017
+ if (parsed > 1) {
2018
+ return Math.min(parsed / 100, 1);
2019
+ }
2020
+ return Math.min(parsed, 1);
2021
+ }
2022
+
2023
+ function stableUnitInterval(value) {
2024
+ const text = String(value || '');
2025
+ let hash = 2166136261;
2026
+ for (const character of text) {
2027
+ hash ^= character.codePointAt(0);
2028
+ hash = Math.imul(hash, 16777619);
2029
+ }
2030
+ return (hash >>> 0) / 4294967296;
2031
+ }
2032
+
2033
+ function shouldSampleCheckoutInterstitial({ sampleRate, traceId, analyticsMetadata }) {
2034
+ if (sampleRate <= 0) {
2035
+ return false;
2036
+ }
2037
+ if (sampleRate >= 1) {
2038
+ return true;
2039
+ }
2040
+ const seed = [
2041
+ analyticsMetadata?.visitorId,
2042
+ analyticsMetadata?.sessionId,
2043
+ analyticsMetadata?.acquisitionId,
2044
+ traceId,
2045
+ ].filter(Boolean).join(':');
2046
+ return stableUnitInterval(seed || traceId) < sampleRate;
2047
+ }
2048
+
1441
2049
  function appendQueryParam(url, key, value) {
1442
2050
  const normalized = normalizeNullableText(value);
1443
2051
  if (normalized) {
@@ -1497,63 +2105,294 @@ function buildCheckoutIntentHref(baseUrl, metadata = {}, overrides = {}) {
1497
2105
  });
1498
2106
  }
1499
2107
 
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>`;
1507
- }
2108
+ const CHECKOUT_HIDDEN_ATTRIBUTION_KEYS = Object.freeze([
2109
+ 'trace_id',
2110
+ 'acquisition_id',
2111
+ 'visitor_id',
2112
+ 'session_id',
2113
+ 'visitor_session_id',
2114
+ 'install_id',
2115
+ 'utm_source',
2116
+ 'utm_medium',
2117
+ 'utm_campaign',
2118
+ 'utm_content',
2119
+ 'utm_term',
2120
+ 'creator',
2121
+ 'community',
2122
+ 'post_id',
2123
+ 'comment_id',
2124
+ 'campaign_variant',
2125
+ 'offer_code',
2126
+ 'cta_id',
2127
+ 'cta_placement',
2128
+ 'plan_id',
2129
+ 'billing_cycle',
2130
+ 'seat_count',
2131
+ 'landing_path',
2132
+ 'referrer_host',
2133
+ ]);
1508
2134
 
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
- };
2135
+ function normalizeHiddenAttributionValue(value) {
2136
+ const normalized = normalizeNullableText(value);
2137
+ if (!normalized) return '';
2138
+ return normalized.slice(0, 512);
1549
2139
  }
1550
2140
 
1551
- function buildCheckoutConfirmHref(parsed) {
1552
- const confirmUrl = new URL('/checkout/pro', 'https://thumbgate.invalid');
1553
- confirmUrl.searchParams.set('confirm', '1');
1554
- for (const [key, value] of parsed.searchParams.entries()) {
1555
- if (key === 'confirm') continue;
1556
- confirmUrl.searchParams.append(key, value);
2141
+ function buildCheckoutHiddenAttributionInputs(parsed = null) {
2142
+ if (!parsed?.searchParams) return '';
2143
+
2144
+ const inputs = [];
2145
+ for (const key of CHECKOUT_HIDDEN_ATTRIBUTION_KEYS) {
2146
+ const value = normalizeHiddenAttributionValue(parsed.searchParams.get(key));
2147
+ if (value) {
2148
+ inputs.push(`<input type="hidden" name="${key}" value="${escapeHtmlAttribute(value)}">`);
2149
+ }
2150
+ }
2151
+ return inputs.join('');
2152
+ }
2153
+
2154
+ function prunePendingOauthAuthorizeRequests(now = Date.now()) {
2155
+ for (const [token, entry] of pendingOauthAuthorizeRequests.entries()) {
2156
+ if (!entry || entry.expiresAt <= now) {
2157
+ pendingOauthAuthorizeRequests.delete(token);
2158
+ }
2159
+ }
2160
+ }
2161
+
2162
+ function createPendingOauthAuthorizeRequest(params, now = Date.now()) {
2163
+ prunePendingOauthAuthorizeRequests(now);
2164
+ const token = crypto.randomBytes(32).toString('base64url');
2165
+ pendingOauthAuthorizeRequests.set(token, {
2166
+ params,
2167
+ expiresAt: now + OAUTH_AUTHORIZE_REQUEST_TTL_MS,
2168
+ });
2169
+ return token;
2170
+ }
2171
+
2172
+ function consumePendingOauthAuthorizeRequest(token, now = Date.now()) {
2173
+ if (!token) return null;
2174
+ prunePendingOauthAuthorizeRequests(now);
2175
+ const entry = pendingOauthAuthorizeRequests.get(token);
2176
+ if (!entry) return null;
2177
+ pendingOauthAuthorizeRequests.delete(token);
2178
+ return entry.params;
2179
+ }
2180
+
2181
+ function getOauthAuthorizeParamsFromQuery(searchParams) {
2182
+ return {
2183
+ clientId: searchParams.get('client_id') || '',
2184
+ redirectUri: searchParams.get('redirect_uri') || '',
2185
+ codeChallenge: searchParams.get('code_challenge') || '',
2186
+ codeChallengeMethod: searchParams.get('code_challenge_method') || '',
2187
+ scope: searchParams.get('scope') || undefined,
2188
+ state: searchParams.get('state') || '',
2189
+ resource: searchParams.get('resource') || '',
2190
+ };
2191
+ }
2192
+
2193
+ function getOauthAuthorizeParamsFromForm(form, hostedConfig) {
2194
+ const pending = consumePendingOauthAuthorizeRequest(form.get('auth_request_token') || '');
2195
+ if (pending) return pending;
2196
+ return {
2197
+ clientId: form.get('client_id') || '',
2198
+ redirectUri: form.get('redirect_uri') || '',
2199
+ codeChallenge: form.get('code_challenge') || '',
2200
+ codeChallengeMethod: form.get('code_challenge_method') || '',
2201
+ scope: form.get('scope') || undefined,
2202
+ state: form.get('state') || '',
2203
+ resource: form.get('resource') || buildPublicUrl(hostedConfig, '/mcp'),
2204
+ };
2205
+ }
2206
+
2207
+ function renderCheckoutIntentPage(prefilledEmail = '', parsed = null, options = {}) {
2208
+ const plausibleDomain = escapeHtmlAttribute(resolvePlausibleDataDomain({ host: 'thumbgate.ai' }));
2209
+ const includeHiddenAttribution = options.includeHiddenAttribution === true;
2210
+ const hiddenInputs = includeHiddenAttribution
2211
+ ? buildCheckoutHiddenAttributionInputs(parsed)
2212
+ : '';
2213
+ return `<!doctype html>
2214
+ <html lang="en">
2215
+ <head>
2216
+ <meta charset="utf-8">
2217
+ <meta name="viewport" content="width=device-width,initial-scale=1">
2218
+ <title>Confirm - ThumbGate Pro</title>
2219
+ <script defer data-domain="${plausibleDomain}" src="https://plausible.io/js/script.tagged-events.js"></script>
2220
+ <script>window.plausible=window.plausible||function(){(window.plausible.q=window.plausible.q||[]).push(arguments)};</script>
2221
+ <style>
2222
+ body{background:#0a0a0a;color:#eee;font-family:system-ui,-apple-system,sans-serif;line-height:1.5}
2223
+ main{max-width:520px;margin:8vh auto;padding:0 20px}
2224
+ .brand{display:flex;align-items:center;gap:10px;margin-bottom:24px;font-size:14px;color:#94a3b8}
2225
+ .brand-mark{width:24px;height:24px;background:#22d3ee;border-radius:6px;display:inline-block}
2226
+ 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}
2227
+ p{color:#cbd5e1;margin:8px 0}form{margin:0}
2228
+ 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}
2229
+ input[type=email]:focus{border-color:#22d3ee}input[type=email]::placeholder{color:#64748b}
2230
+ 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%}
2231
+ 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}
2232
+ .trust,.objection-box{margin:24px 0;padding:16px;border:1px solid #1f2937;border-radius:8px;background:#0f172a}
2233
+ .trust-item{font-size:13px;color:#cbd5e1;padding:4px 0;display:flex;gap:8px}.trust-item::before{content:"✓";color:#22d3ee;font-weight:700}
2234
+ .choice-note,.email-note,.objection-note{font-size:13px;color:#94a3b8;margin-top:10px}
2235
+ .objection-grid{display:grid;gap:8px;margin-top:12px}
2236
+ .objection-grid button{border:1px solid #374151;background:#111827;color:#e5e7eb;border-radius:8px;padding:10px 12px;text-align:left;cursor:pointer}
2237
+ .objection-grid button:hover,.objection-grid button:focus{border-color:#22d3ee;outline:none}
2238
+ .feedback-saved{display:none;color:#22d3ee;font-size:13px;margin-top:10px}
2239
+ .back{text-align:center;color:#64748b;font-size:12px;margin-top:24px}.back a{color:#64748b;display:inline}
2240
+ </style>
2241
+ </head>
2242
+ <body>
2243
+ <main>
2244
+ <div class="brand"><span class="brand-mark"></span><span>ThumbGate</span></div>
2245
+ <h1>Start ThumbGate Pro</h1>
2246
+ <div class="price">$19<small>/mo</small></div>
2247
+ <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>
2248
+ <form action="https://buy.stripe.com/8x2dR91M84r4cSd9uj3sI3f" method="GET" data-i="pro_checkout_confirmed">
2249
+ ${hiddenInputs}
2250
+ <input type="email" name="prefilled_email" value="${escapeHtmlAttribute(prefilledEmail)}" placeholder="you@company.com" autocomplete="email">
2251
+ <p class="email-note">Optional. Stripe can collect your email on the secure checkout page.</p>
2252
+ <button type="submit" class="primary">Pay $19/mo with Stripe →</button>
2253
+ </form>
2254
+ <a class="secondary" data-i="workflow_sprint_intake" href="/#workflow-sprint-intake">Not sure yet? Send the workflow first</a>
2255
+ <p class="choice-note">Cancel anytime. 7-day refund, no questions. Diagnostics and sprints have their own pages.</p>
2256
+ <div class="objection-box" aria-label="Checkout feedback">
2257
+ <strong>Not buying today?</strong>
2258
+ <p class="objection-note">Tap one reason so we know what to fix. This does not sign you up.</p>
2259
+ <div class="objection-grid">
2260
+ <button type="button" data-reason="price_unclear">Price or scope is unclear</button>
2261
+ <button type="button" data-reason="need_more_proof">Need more proof first</button>
2262
+ <button type="button" data-reason="need_team_plan">Need a team/workflow plan instead</button>
2263
+ <button type="button" data-reason="not_urgent">Not urgent right now</button>
2264
+ </div>
2265
+ <div class="feedback-saved" id="feedback-saved">Feedback saved.</div>
2266
+ </div>
2267
+ <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>
2268
+ <p class="back"><a href="/">← Back to thumbgate.ai</a></p>
2269
+ </main>
2270
+ <script>
2271
+ (function(){
2272
+ var params = new URLSearchParams(window.location.search);
2273
+ var submitted = false;
2274
+ var feedbackSent = false;
2275
+ function pick(key, fallback) { return params.get(key) || fallback || null; }
2276
+ function sendTelemetry(eventType, extra) {
2277
+ var payload = Object.assign({
2278
+ eventType: eventType,
2279
+ clientType: 'web',
2280
+ page: '/checkout/pro',
2281
+ traceId: pick('trace_id'),
2282
+ acquisitionId: pick('acquisition_id'),
2283
+ visitorId: pick('visitor_id'),
2284
+ sessionId: pick('visitor_session_id') || pick('session_id'),
2285
+ installId: pick('install_id'),
2286
+ source: pick('utm_source') || pick('source') || 'website',
2287
+ utmSource: pick('utm_source') || pick('source') || 'website',
2288
+ utmMedium: pick('utm_medium') || 'checkout_interstitial',
2289
+ utmCampaign: pick('utm_campaign') || 'pro_pack',
2290
+ utmContent: pick('utm_content'),
2291
+ utmTerm: pick('utm_term'),
2292
+ creator: pick('creator') || pick('creator_handle'),
2293
+ community: pick('community') || pick('subreddit'),
2294
+ postId: pick('post_id'),
2295
+ commentId: pick('comment_id'),
2296
+ campaignVariant: pick('campaign_variant'),
2297
+ offerCode: pick('offer_code'),
2298
+ ctaId: pick('cta_id') || 'pricing_pro',
2299
+ ctaPlacement: pick('cta_placement') || 'checkout_interstitial',
2300
+ planId: pick('plan_id') || 'pro',
2301
+ billingCycle: pick('billing_cycle') || 'monthly',
2302
+ landingPath: pick('landing_path') || '/',
2303
+ referrerHost: pick('referrer_host'),
2304
+ referrer: document.referrer || null
2305
+ }, extra || {});
2306
+ var body = JSON.stringify(payload);
2307
+ if (navigator.sendBeacon) {
2308
+ navigator.sendBeacon('/v1/telemetry/ping', new Blob([body], { type: 'application/json' }));
2309
+ return;
2310
+ }
2311
+ fetch('/v1/telemetry/ping', { method:'POST', headers:{ 'content-type':'application/json' }, body: body, keepalive: true }).catch(function(){});
2312
+ }
2313
+ document.querySelector('form').addEventListener('submit', function(){
2314
+ submitted = true;
2315
+ var emailInput = document.querySelector('input[name=customer_email]');
2316
+ sendTelemetry('checkout_interstitial_cta_clicked', {
2317
+ ctaId: 'pro_checkout_confirmed',
2318
+ ctaPlacement: 'checkout_interstitial',
2319
+ customerEmail: emailInput ? emailInput.value : null
2320
+ });
2321
+ try { window.plausible && window.plausible('Checkout Pro Email Submitted', { props: { page:'/checkout/pro', source:'interstitial' } }); } catch(_) {}
2322
+ });
2323
+ document.querySelectorAll('[data-reason]').forEach(function(button) {
2324
+ button.addEventListener('click', function(){
2325
+ var reason = button.getAttribute('data-reason');
2326
+ feedbackSent = true;
2327
+ sendTelemetry('reason_not_buying', {
2328
+ reasonCode: reason,
2329
+ ctaId: 'checkout_interstitial_reason_' + reason,
2330
+ ctaPlacement: 'checkout_interstitial_feedback'
2331
+ });
2332
+ try { window.plausible && window.plausible('Checkout Pro Reason Not Buying', { props: { page:'/checkout/pro', reasonCode: reason } }); } catch(_) {}
2333
+ var saved = document.getElementById('feedback-saved');
2334
+ if (saved) saved.style.display = 'block';
2335
+ });
2336
+ });
2337
+ window.addEventListener('pagehide', function(){
2338
+ if (!submitted && !feedbackSent) {
2339
+ sendTelemetry('checkout_interstitial_abandoned', { reasonCode: 'left_without_confirming' });
2340
+ }
2341
+ });
2342
+ })();
2343
+ </script>
2344
+ </body>
2345
+ </html>`;
2346
+ }
2347
+
2348
+ function buildCheckoutBootstrapBody(parsed, req, journeyState = resolveJourneyState(req, parsed)) {
2349
+ const params = parsed.searchParams;
2350
+ const traceId = pickFirstText(params.get('trace_id')) || createJourneyId('checkout');
2351
+ const planId = normalizePlanId(pickFirstText(params.get('plan_id'), 'pro'));
2352
+ const billingCycle = normalizeBillingCycle(pickFirstText(params.get('billing_cycle'), 'monthly'));
2353
+ const seatCount = planId === 'team'
2354
+ ? normalizeSeatCount(pickFirstText(params.get('seat_count')))
2355
+ : 1;
2356
+ return {
2357
+ traceId,
2358
+ installId: pickFirstText(params.get('install_id')),
2359
+ acquisitionId: journeyState.acquisitionId,
2360
+ visitorId: journeyState.visitorId,
2361
+ sessionId: journeyState.sessionId,
2362
+ customerEmail: pickFirstText(params.get('customer_email')),
2363
+ source: pickFirstText(params.get('source'), params.get('utm_source'), 'website'),
2364
+ utmSource: pickFirstText(params.get('utm_source'), params.get('source'), 'website'),
2365
+ utmMedium: pickFirstText(params.get('utm_medium'), 'cta_button'),
2366
+ utmCampaign: pickFirstText(params.get('utm_campaign'), 'pro_pack'),
2367
+ utmContent: pickFirstText(params.get('utm_content')),
2368
+ utmTerm: pickFirstText(params.get('utm_term')),
2369
+ creator: pickFirstText(params.get('creator'), params.get('creator_handle')),
2370
+ community: pickFirstText(params.get('community'), params.get('subreddit')),
2371
+ postId: pickFirstText(params.get('post_id')),
2372
+ commentId: pickFirstText(params.get('comment_id')),
2373
+ campaignVariant: pickFirstText(params.get('campaign_variant')),
2374
+ offerCode: pickFirstText(params.get('offer_code')),
2375
+ landingPath: pickFirstText(params.get('landing_path'), req.headers.referer ? '/' : '/'),
2376
+ referrerHost: pickFirstText(params.get('referrer_host')),
2377
+ ctaId: pickFirstText(params.get('cta_id'), 'pricing_pro'),
2378
+ ctaPlacement: pickFirstText(params.get('cta_placement'), 'pricing'),
2379
+ planId,
2380
+ billingCycle,
2381
+ seatCount,
2382
+ metadata: {
2383
+ referrer: pickFirstText(params.get('referrer'), req.headers.referer, req.headers.referrer),
2384
+ landingPath: pickFirstText(params.get('landing_path'), '/'),
2385
+ referrerHost: pickFirstText(params.get('referrer_host')),
2386
+ },
2387
+ };
2388
+ }
2389
+
2390
+ function buildCheckoutConfirmHref(parsed) {
2391
+ const confirmUrl = new URL('/checkout/pro', 'https://thumbgate.invalid');
2392
+ confirmUrl.searchParams.set('confirm', '1');
2393
+ for (const [key, value] of parsed.searchParams.entries()) {
2394
+ if (key === 'confirm') continue;
2395
+ confirmUrl.searchParams.append(key, value);
1557
2396
  }
1558
2397
  return `${confirmUrl.pathname}${confirmUrl.search}`;
1559
2398
  }
@@ -1567,17 +2406,6 @@ function normalizeCheckoutCustomerEmail(value) {
1567
2406
  return email;
1568
2407
  }
1569
2408
 
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
-
1581
2409
  function normalizeTrackedLinkSlug(value) {
1582
2410
  return String(value || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
1583
2411
  }
@@ -1588,6 +2416,27 @@ function normalizePublicPageSlug(value) {
1588
2416
  .replace(/[^a-z0-9-]/g, '');
1589
2417
  }
1590
2418
 
2419
+ function buildPublicHtmlFileMap(directory) {
2420
+ const entries = new Map();
2421
+ try {
2422
+ for (const fileName of fs.readdirSync(directory)) {
2423
+ if (!/^[a-z0-9-]+\.html$/i.test(fileName)) continue;
2424
+ const slug = normalizePublicPageSlug(fileName);
2425
+ if (!slug) continue;
2426
+ entries.set(slug, path.join(directory, fileName));
2427
+ }
2428
+ } catch (error) {
2429
+ if (error?.code !== 'ENOENT') throw error;
2430
+ }
2431
+ return entries;
2432
+ }
2433
+
2434
+ function resolvePublicHtmlFile(publicPageMap, rawSlug) {
2435
+ const slug = normalizePublicPageSlug(rawSlug);
2436
+ if (!slug) return null;
2437
+ return publicPageMap.get(slug) || null;
2438
+ }
2439
+
1591
2440
  function getTrackedLinkTarget(slug) {
1592
2441
  const normalizedSlug = normalizeTrackedLinkSlug(slug);
1593
2442
  return TRACKED_LINK_TARGETS[normalizedSlug]
@@ -1623,8 +2472,10 @@ function appendTrackedLinkQueryParams(destinationUrl, parsed, target) {
1623
2472
  }
1624
2473
 
1625
2474
  function buildTrackedLinkDestination(target, hostedConfig, parsed) {
1626
- const destinationUrl = target.href
1627
- ? new URL(target.href)
2475
+ const configuredHref = target.configUrlKey ? hostedConfig[target.configUrlKey] : null;
2476
+ const href = target.href || configuredHref || target.fallbackHref;
2477
+ const destinationUrl = href
2478
+ ? new URL(href)
1628
2479
  : new URL(target.path || '/', hostedConfig.appOrigin);
1629
2480
  appendTrackedLinkQueryParams(destinationUrl, parsed, target);
1630
2481
  return destinationUrl;
@@ -1751,6 +2602,7 @@ function sendJson(res, statusCode, payload, extraHeaders = {}, options = {}) {
1751
2602
  const body = JSON.stringify(payload);
1752
2603
  res.writeHead(statusCode, {
1753
2604
  'Content-Type': 'application/json; charset=utf-8',
2605
+ 'X-Content-Type-Options': 'nosniff',
1754
2606
  'Content-Length': Buffer.byteLength(body),
1755
2607
  ...extraHeaders,
1756
2608
  });
@@ -1825,14 +2677,28 @@ function appendBestEffortTelemetry(feedbackDir, payload, headers, context) {
1825
2677
  evidence: [err?.message ? err.message : 'unknown_error'],
1826
2678
  },
1827
2679
  });
1828
- } catch (_) {}
2680
+ } catch {}
1829
2681
  return false;
1830
2682
  }
1831
2683
  }
1832
2684
 
2685
+ function inferActionIntegration(body = {}, headers = {}) {
2686
+ const explicit = pickFirstText(body.integration, body.source, body.actionIntegration, body.provider);
2687
+ const userAgent = String(headers['user-agent'] || '').toLowerCase();
2688
+ if (/chatgpt|gpt[_-]?actions?|openai/.test(String(explicit || '').toLowerCase()) || /chatgpt|openai/.test(userAgent)) {
2689
+ return 'chatgpt_gpt';
2690
+ }
2691
+ return explicit || 'api';
2692
+ }
2693
+
2694
+ function chatgptActionEventType(integration, suffix) {
2695
+ return integration === 'chatgpt_gpt' ? `chatgpt_action_${suffix}` : `api_action_${suffix}`;
2696
+ }
2697
+
1833
2698
  function getPublicOrigin(req) {
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';
2699
+ const rawProto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
2700
+ const proto = rawProto === 'https' ? 'https' : 'http';
2701
+ const host = getSafePublicRequestHost(req) || 'localhost';
1836
2702
  return `${proto}://${host}`;
1837
2703
  }
1838
2704
 
@@ -1851,6 +2717,47 @@ function getRequestHostHeader(req) {
1851
2717
  return forwardedHost || req.headers.host || '';
1852
2718
  }
1853
2719
 
2720
+ function normalizePublicRequestHost(value) {
2721
+ const rawHost = String(value || '').split(',')[0].trim().toLowerCase();
2722
+ if (!rawHost || rawHost.length > 253) return '';
2723
+
2724
+ const hostWithoutPort = rawHost.startsWith('[')
2725
+ ? rawHost.slice(1).split(']')[0]
2726
+ : rawHost.split(':')[0];
2727
+ const port = rawHost.startsWith('[')
2728
+ ? rawHost.split(']:')[1] || ''
2729
+ : rawHost.split(':')[1] || '';
2730
+
2731
+ if (!isAllowedPublicHostName(hostWithoutPort)) {
2732
+ return '';
2733
+ }
2734
+ if (port && !/^\d{1,5}$/.test(port)) return '';
2735
+ if (port && Number(port) > 65535) return '';
2736
+ return port ? `${hostWithoutPort}:${port}` : hostWithoutPort;
2737
+ }
2738
+
2739
+ function isAllowedPublicHostName(hostname) {
2740
+ return hostname === 'localhost'
2741
+ || hostname === '::1'
2742
+ || isIpv4Host(hostname)
2743
+ || isDnsHostName(hostname);
2744
+ }
2745
+
2746
+ function isIpv4Host(hostname) {
2747
+ const parts = hostname.split('.');
2748
+ return parts.length === 4 && parts.every((part) => /^\d{1,3}$/.test(part));
2749
+ }
2750
+
2751
+ function isDnsHostName(hostname) {
2752
+ return hostname
2753
+ .split('.')
2754
+ .every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
2755
+ }
2756
+
2757
+ function getSafePublicRequestHost(req) {
2758
+ return normalizePublicRequestHost(getRequestHostHeader(req));
2759
+ }
2760
+
1854
2761
  function isLoopbackHost(hostValue) {
1855
2762
  const rawHost = String(hostValue || '').split(',')[0].trim();
1856
2763
  if (!rawHost) {
@@ -1896,7 +2803,7 @@ function stripTrailingSlashes(value) {
1896
2803
  return input.slice(0, end);
1897
2804
  }
1898
2805
 
1899
- function normalizePublicMarketingHtml(html, runtimeConfig) {
2806
+ function normalizePublicMarketingHtml(html, runtimeConfig, requestHost) {
1900
2807
  const appOrigin = runtimeConfig?.appOrigin
1901
2808
  ? stripTrailingSlashes(runtimeConfig.appOrigin)
1902
2809
  : '';
@@ -1905,10 +2812,14 @@ function normalizePublicMarketingHtml(html, runtimeConfig) {
1905
2812
  let output = String(html);
1906
2813
  output = output.replaceAll(DEFAULT_PUBLIC_APP_ORIGIN, appOrigin);
1907
2814
  try {
1908
- const host = new URL(appOrigin).host;
2815
+ const host = normalizePublicRequestHost(requestHost) || new URL(appOrigin).host;
2816
+ const plausibleDomain = resolvePlausibleDataDomain({ host });
1909
2817
  output = output.replaceAll(
1910
2818
  'data-domain="thumbgate-production.up.railway.app"',
1911
- `data-domain="${escapeHtmlAttribute(host)}"`
2819
+ `data-domain="${escapeHtmlAttribute(plausibleDomain)}"`
2820
+ ).replaceAll(
2821
+ 'data-domain="thumbgate.ai"',
2822
+ `data-domain="${escapeHtmlAttribute(plausibleDomain)}"`
1912
2823
  );
1913
2824
  } catch {
1914
2825
  // appOrigin is normalized by hosted-config; leave static analytics domains
@@ -1952,12 +2863,12 @@ function loadPublicMarketingTemplateHtml(templatePath, runtimeConfig, pageContex
1952
2863
  '__SERVER_ACQUISITION_ID__': pageContext.serverAcquisitionId || '',
1953
2864
  '__SERVER_TELEMETRY_CAPTURED__': pageContext.serverTelemetryCaptured ? 'true' : 'false',
1954
2865
  '__VERIFICATION_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md',
1955
- '__COMPATIBILITY_REPORT_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/proof/compatibility/report.json',
1956
- '__AUTOMATION_REPORT_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/proof/automation/report.json',
2866
+ '__COMPATIBILITY_REPORT_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md',
2867
+ '__AUTOMATION_REPORT_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md',
1957
2868
  '__GTM_PLAN_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/GO_TO_MARKET_REVENUE_WEDGE_2026-03.md',
1958
2869
  '__GITHUB_URL__': 'https://github.com/IgorGanapolsky/ThumbGate',
1959
2870
  '__POSTHOG_API_KEY__': runtimeConfig.posthogApiKey || '',
1960
- }), runtimeConfig);
2871
+ }), runtimeConfig, pageContext.requestHost);
1961
2872
  }
1962
2873
 
1963
2874
  function loadLandingPageHtml(runtimeConfig, pageContext = {}) {
@@ -1968,6 +2879,30 @@ function loadProPageHtml(runtimeConfig, pageContext = {}) {
1968
2879
  return loadPublicMarketingTemplateHtml(PRO_PAGE_PATH, runtimeConfig, pageContext);
1969
2880
  }
1970
2881
 
2882
+ function loadDiagnosticPageHtml(runtimeConfig, pageContext = {}) {
2883
+ return loadPublicMarketingTemplateHtml(DIAGNOSTIC_PAGE_PATH, runtimeConfig, pageContext);
2884
+ }
2885
+
2886
+ function loadInstallPageHtml(runtimeConfig, pageContext = {}) {
2887
+ return loadPublicMarketingTemplateHtml(INSTALL_PAGE_PATH, runtimeConfig, pageContext);
2888
+ }
2889
+
2890
+ function loadPricingPageHtml(runtimeConfig, pageContext = {}) {
2891
+ return loadPublicMarketingTemplateHtml(PRICING_PAGE_PATH, runtimeConfig, pageContext);
2892
+ }
2893
+
2894
+ function loadAboutPageHtml(runtimeConfig, pageContext = {}) {
2895
+ return loadPublicMarketingTemplateHtml(ABOUT_PAGE_PATH, runtimeConfig, pageContext);
2896
+ }
2897
+
2898
+ function loadGuidePageHtml(runtimeConfig, pageContext = {}) {
2899
+ return loadPublicMarketingTemplateHtml(GUIDE_PAGE_PATH, runtimeConfig, pageContext);
2900
+ }
2901
+
2902
+ function loadLearnPageHtml(runtimeConfig, pageContext = {}) {
2903
+ return loadPublicMarketingTemplateHtml(LEARN_PAGE_PATH, runtimeConfig, pageContext);
2904
+ }
2905
+
1971
2906
  function readOptionalPublicTemplate(filePath) {
1972
2907
  try {
1973
2908
  return fs.readFileSync(filePath, 'utf-8');
@@ -1977,6 +2912,10 @@ function readOptionalPublicTemplate(filePath) {
1977
2912
  }
1978
2913
  }
1979
2914
 
2915
+ function escapeJsonForInlineScript(value) {
2916
+ return JSON.stringify(value).replaceAll('<', String.raw`\u003c`);
2917
+ }
2918
+
1980
2919
  function resolveLocalPageBootstrap(req, expectedApiKey) {
1981
2920
  const forwardedHost = req.headers['x-forwarded-host'];
1982
2921
  const hostHeader = Array.isArray(forwardedHost)
@@ -1985,7 +2924,13 @@ function resolveLocalPageBootstrap(req, expectedApiKey) {
1985
2924
  const localProBootstrap = process.env.THUMBGATE_PRO_MODE === '1' && Boolean(expectedApiKey) && isLoopbackHost(hostHeader);
1986
2925
  const devOverride = expectedApiKey === null && isLoopbackHost(hostHeader);
1987
2926
  const bootstrapActive = localProBootstrap || devOverride;
1988
- const serializedBootstrapKey = JSON.stringify(localProBootstrap ? expectedApiKey : devOverride ? 'dev-override' : '').replace(/</g, '\\u003c');
2927
+ let bootstrapKey = '';
2928
+ if (localProBootstrap) {
2929
+ bootstrapKey = expectedApiKey;
2930
+ } else if (devOverride) {
2931
+ bootstrapKey = process.env.THUMBGATE_API_KEY || 'dev-override';
2932
+ }
2933
+ const serializedBootstrapKey = escapeJsonForInlineScript(bootstrapKey);
1989
2934
 
1990
2935
  return {
1991
2936
  bootstrapActive,
@@ -2026,6 +2971,7 @@ window.THUMBGATE_DASHBOARD_BOOTSTRAP = { enabled: ${bootstrapActive ? 'true' : '
2026
2971
  <p>This lightweight npm dashboard is bundled without marketing assets, so installs stay small while core feedback, lessons, and API routes remain available.</p>
2027
2972
  <div class="grid">
2028
2973
  <a class="card" href="/v1/dashboard"><strong>Dashboard JSON</strong><span>Inspect feedback totals, lesson counts, and Reliability Gateway health.</span></a>
2974
+ <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>
2029
2975
  <a class="card" href="/lessons"><strong>Lessons</strong><span>Review remembered thumbs-up/down lessons and enforcement context.</span></a>
2030
2976
  <a class="card" href="/health"><strong>Health</strong><span>Verify the installed package version and runtime status.</span></a>
2031
2977
  </div>
@@ -2105,6 +3051,53 @@ function normalizeLessonSignal(signal) {
2105
3051
  return 'down';
2106
3052
  }
2107
3053
 
3054
+ function splitLessonTitlePrefix(titleText) {
3055
+ const prefixMatch = /^(MISTAKE|SUCCESS|LEARNING|PREFERENCE):\s*(.*)/i.exec(titleText);
3056
+ if (!prefixMatch) return { prefix: '', rest: titleText };
3057
+ return {
3058
+ prefix: `${prefixMatch[1].toUpperCase()}: `,
3059
+ rest: prefixMatch[2],
3060
+ };
3061
+ }
3062
+
3063
+ function maybeParseJsonObject(value) {
3064
+ const trimmed = String(value || '').trim();
3065
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null;
3066
+ try {
3067
+ return JSON.parse(trimmed);
3068
+ } catch (error) {
3069
+ debugApiFallback('lesson JSON parse skipped', error);
3070
+ return null;
3071
+ }
3072
+ }
3073
+
3074
+ function formatLessonJsonTitle(prefix, parsed) {
3075
+ const dirName = parsed.cwd ? parsed.cwd.split('/').pop() : '';
3076
+ const suffix = dirName ? ` inside ${dirName}` : '';
3077
+ if (parsed.prompt) return `${prefix}Prompt "${parsed.prompt}"${suffix}`;
3078
+ const hookVal = parsed.hook_event_name || parsed.hookEventName;
3079
+ if (hookVal) return `${prefix}Hook event ${hookVal}${suffix}`;
3080
+ if (parsed.signal) return `${prefix}${parsed.signal === 'up' ? 'Thumbs Up' : 'Thumbs Down'}${suffix}`;
3081
+ return '';
3082
+ }
3083
+
3084
+ function cleanLessonTitle(titleText) {
3085
+ if (!titleText) return 'Untitled Lesson';
3086
+ const { prefix, rest } = splitLessonTitlePrefix(titleText);
3087
+ const parsed = maybeParseJsonObject(rest);
3088
+ if (parsed) {
3089
+ const jsonTitle = formatLessonJsonTitle(prefix, parsed);
3090
+ if (jsonTitle) return jsonTitle;
3091
+ }
3092
+ return titleText;
3093
+ }
3094
+
3095
+ function formatLessonTextValue(value) {
3096
+ if (!value) return '';
3097
+ const parsed = maybeParseJsonObject(value);
3098
+ return parsed ? JSON.stringify(parsed, null, 2) : value;
3099
+ }
3100
+
2108
3101
  function renderLessonDetailHtml(record, lessonId) {
2109
3102
  if (!record) {
2110
3103
  return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Lesson Not Found</title>
@@ -2123,10 +3116,15 @@ a{color:#22d3ee;text-decoration:none}</style></head><body>
2123
3116
  const signal = normalizeLessonSignal(merged.signal);
2124
3117
  const emoji = signal === 'up' ? '👍' : '👎';
2125
3118
  const signalColor = signal === 'up' ? '#4ade80' : '#f87171';
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
+ const rawTitle = merged.title || merged.context || 'Untitled Lesson';
3120
+ const rawContext = merged.context || '';
3121
+ const rawWhatWentWrong = merged.whatWentWrong || '';
3122
+ const rawWhatWorked = merged.whatWorked || '';
3123
+
3124
+ const title = cleanLessonTitle(rawTitle);
3125
+ const context = formatLessonTextValue(rawContext);
3126
+ const whatWentWrong = formatLessonTextValue(rawWhatWentWrong);
3127
+ const whatWorked = formatLessonTextValue(rawWhatWorked);
2130
3128
  const whatToChange = merged.whatToChange || '';
2131
3129
  const tags = Array.isArray(merged.tags) ? merged.tags.join(', ') : (merged.tags || '');
2132
3130
  const timestamp = merged.timestamp ? new Date(merged.timestamp).toLocaleString() : '';
@@ -2344,7 +3342,7 @@ nav .container { display: flex; justify-content: space-between; align-items: cen
2344
3342
  </head>
2345
3343
  <body>
2346
3344
  <nav><div class="container">
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>
3345
+ <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>
2348
3346
  <div class="nav-links">
2349
3347
  <a href="/dashboard">Dashboard</a>
2350
3348
  <a href="/lessons">Lessons</a>
@@ -2468,6 +3466,16 @@ function renderRobotsTxt(runtimeConfig) {
2468
3466
  'Disallow: /v1/billing/',
2469
3467
  '',
2470
3468
  '# AI crawler access — allow all major LLM crawlers',
3469
+ 'User-agent: OAI-SearchBot',
3470
+ 'Allow: /',
3471
+ 'Disallow: /checkout/',
3472
+ 'Disallow: /v1/billing/',
3473
+ '',
3474
+ 'User-agent: ChatGPT-User',
3475
+ 'Allow: /',
3476
+ 'Disallow: /checkout/',
3477
+ 'Disallow: /v1/billing/',
3478
+ '',
2471
3479
  'User-agent: GPTBot',
2472
3480
  'Allow: /',
2473
3481
  'Disallow: /checkout/',
@@ -2476,9 +3484,18 @@ function renderRobotsTxt(runtimeConfig) {
2476
3484
  'User-agent: ClaudeBot',
2477
3485
  'Allow: /',
2478
3486
  '',
3487
+ 'User-agent: Claude-SearchBot',
3488
+ 'Allow: /',
3489
+ '',
3490
+ 'User-agent: Claude-User',
3491
+ 'Allow: /',
3492
+ '',
2479
3493
  'User-agent: PerplexityBot',
2480
3494
  'Allow: /',
2481
3495
  '',
3496
+ 'User-agent: Perplexity-User',
3497
+ 'Allow: /',
3498
+ '',
2482
3499
  'User-agent: Googlebot',
2483
3500
  'Allow: /',
2484
3501
  '',
@@ -2493,6 +3510,7 @@ function renderRobotsTxt(runtimeConfig) {
2493
3510
  '',
2494
3511
  '# LLM context document — clean declarative content for AI retrieval',
2495
3512
  `# ${runtimeConfig.appOrigin}/llm-context.md`,
3513
+ `# ${runtimeConfig.appOrigin}/llms.txt`,
2496
3514
  '',
2497
3515
  `Sitemap: ${runtimeConfig.appOrigin}/sitemap.xml`,
2498
3516
  ].join('\n');
@@ -2502,10 +3520,57 @@ function renderSitemapXml(runtimeConfig) {
2502
3520
  const entries = [
2503
3521
  { path: '/', changefreq: 'weekly', priority: '1.0' },
2504
3522
  { path: '/pro', changefreq: 'weekly', priority: '0.9' },
3523
+ { path: '/diagnostic', changefreq: 'weekly', priority: '0.9' },
3524
+ { path: '/workflow-hardening-sprint', changefreq: 'weekly', priority: '0.9' },
3525
+ { path: '/install', changefreq: 'weekly', priority: '0.9' },
3526
+ { path: '/agent-manager', changefreq: 'weekly', priority: '0.9' },
2505
3527
  { path: '/llm-context.md', changefreq: 'weekly', priority: '0.8' },
3528
+ { path: '/chatgpt-app', changefreq: 'weekly', priority: '0.85' },
2506
3529
  { path: '/codex-plugin', changefreq: 'weekly', priority: '0.75' },
3530
+ { path: '/codex-enterprise', changefreq: 'weekly', priority: '0.85' },
3531
+ { path: '/agents-cost-savings', changefreq: 'weekly', priority: '0.85' },
3532
+ { path: '/ai-malpractice-prevention', changefreq: 'weekly', priority: '0.9' },
3533
+ { path: '/learn/background-agent-control-layer', changefreq: 'weekly', priority: '0.85' },
3534
+ { path: '/learn/ac-dc-runtime-enforcement', changefreq: 'weekly', priority: '0.85' },
3535
+ { path: '/learn/feedback-loop-vs-decision-layer', changefreq: 'weekly', priority: '0.9' },
3536
+ { path: '/learn/agentic-enterprise-context-brain', changefreq: 'weekly', priority: '0.85' },
3537
+ { path: '/learn/deterministic-agent-workflows', changefreq: 'weekly', priority: '0.85' },
3538
+ { path: '/learn/codex-role-plugins-need-governance', changefreq: 'weekly', priority: '0.85' },
3539
+ { path: '/learn/agentic-os-team-governance', changefreq: 'weekly', priority: '0.85' },
3540
+ { path: '/learn/cost-aware-agent-gate-routing', changefreq: 'weekly', priority: '0.85' },
3541
+ { path: '/learn/pretix-stripe-connect-marketplaces', changefreq: 'weekly', priority: '0.9' },
3542
+ { path: '/compare/claude-code-hooks', changefreq: 'weekly', priority: '0.85' },
3543
+ { path: '/compare/bumblebee', changefreq: 'weekly', priority: '0.85' },
3544
+ { path: '/compare/anthropic-containment', changefreq: 'weekly', priority: '0.85' },
3545
+ { path: '/compare/oak-and-sparrow-gatekeeper', changefreq: 'weekly', priority: '0.85' },
3546
+ { path: '/compare/arcjet', changefreq: 'weekly', priority: '0.85' },
3547
+ { path: '/compare/anthropic-claude-for-legal', changefreq: 'weekly', priority: '0.9' },
2507
3548
  ...THUMBGATE_SEO_SITEMAP_ENTRIES,
2508
3549
  ];
3550
+ // Auto-include every hand-written SEO page so /sitemap.xml can never drift out
3551
+ // of sync with public/compare/*.html or public/guides/*.html. Crawlers and AI
3552
+ // answer engines only surface pages they can discover, so a buyer-intent page
3553
+ // missing from the sitemap is invisible on its query. De-duped against entries
3554
+ // already declared above (e.g. seo-gsd specs), which keep explicit priorities.
3555
+ const declaredPaths = new Set(entries.map((entry) => entry.path));
3556
+ try {
3557
+ const seoDirectories = [
3558
+ { dir: 'compare', route: '/compare', priority: '0.85' },
3559
+ { dir: 'guides', route: '/guides', priority: '0.85' },
3560
+ ];
3561
+ for (const catalog of seoDirectories) {
3562
+ const files = fs.readdirSync(path.join(PUBLIC_DIR, catalog.dir)).sort((a, b) => a.localeCompare(b));
3563
+ for (const file of files) {
3564
+ if (!file.endsWith('.html')) continue;
3565
+ const publicPath = `${catalog.route}/${file.replace(/\.html$/, '')}`;
3566
+ if (declaredPaths.has(publicPath)) continue;
3567
+ declaredPaths.add(publicPath);
3568
+ entries.push({ path: publicPath, changefreq: 'weekly', priority: catalog.priority });
3569
+ }
3570
+ }
3571
+ } catch {
3572
+ // SEO directories absent in a stripped bundle — fall back to static entries.
3573
+ }
2509
3574
  return [
2510
3575
  '<?xml version="1.0" encoding="UTF-8"?>',
2511
3576
  '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
@@ -2631,11 +3696,13 @@ function servePublicMarketingPage({
2631
3696
  }, req.headers, 'seo_landing_view');
2632
3697
  }
2633
3698
 
3699
+ const requestHost = getSafePublicRequestHost(req);
2634
3700
  const html = renderHtml(hostedConfig, {
2635
3701
  serverVisitorId: journeyState.visitorId,
2636
3702
  serverSessionId: journeyState.sessionId,
2637
3703
  serverAcquisitionId: journeyState.acquisitionId,
2638
3704
  serverTelemetryCaptured: landingTelemetryCaptured,
3705
+ requestHost,
2639
3706
  });
2640
3707
 
2641
3708
  sendHtml(
@@ -2775,11 +3842,12 @@ function renderCheckoutSuccessPage(runtimeConfig) {
2775
3842
  </style>
2776
3843
  <link rel="icon" type="image/png" href="/thumbgate-icon.png">
2777
3844
  <link rel="apple-touch-icon" href="/assets/brand/thumbgate-mark.svg">
2778
- <script defer data-domain="thumbgate-production.up.railway.app" src="https://plausible.io/js/script.js"></script>
3845
+ <script defer data-domain="thumbgate-production.up.railway.app" src="https://plausible.io/js/script.tagged-events.js"></script>
3846
+ <script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments); };</script>
2779
3847
  </head>
2780
3848
  <body>
2781
3849
  <main>
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>
3850
+ <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>
2783
3851
  <span class="eyebrow">ThumbGate Pro</span>
2784
3852
  <h1>Your local Pro dashboard is ready.</h1>
2785
3853
  <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>
@@ -2916,7 +3984,7 @@ function renderCheckoutSuccessPage(runtimeConfig) {
2916
3984
  if (window.sessionStorage) {
2917
3985
  window.sessionStorage.setItem(marker, '1');
2918
3986
  }
2919
- } catch (_) {
3987
+ } catch {
2920
3988
  sendTelemetry(eventType, extra);
2921
3989
  }
2922
3990
  }
@@ -2953,6 +4021,7 @@ function renderCheckoutSuccessPage(runtimeConfig) {
2953
4021
  }
2954
4022
 
2955
4023
  sendTelemetryOnce('checkout_paid_confirmed');
4024
+ try { window.plausible && window.plausible('Checkout Pro Success Page Confirmed', { props: { sessionId: sessionId || '', traceId: traceId || '', source: 'success_page' } }); } catch (_) {}
2956
4025
  statusEl.textContent = 'ThumbGate Pro activated.';
2957
4026
  const resolvedTraceId = body.traceId || traceId || '';
2958
4027
  const emailStatus = body.trialEmail || {};
@@ -3352,6 +4421,39 @@ function renderWorkflowSprintIntakeResultPage(runtimeConfig, { title, detail, le
3352
4421
  </html>`;
3353
4422
  }
3354
4423
 
4424
+ function renderBrokerAuditIntakeResultPage(runtimeConfig, { title, detail, leadId = null }) {
4425
+ const safeTitle = escapeHtmlAttribute(title || 'Broker audit request received');
4426
+ const safeDetail = escapeHtmlAttribute(detail || 'Your broker lead-flow audit request is in the queue.');
4427
+ const safeLeadId = leadId ? `<p><strong>Lead ID:</strong> ${escapeHtmlAttribute(leadId)}</p>` : '';
4428
+ return `<!DOCTYPE html>
4429
+ <html lang="en">
4430
+ <head>
4431
+ <meta charset="UTF-8" />
4432
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
4433
+ <title>${safeTitle}</title>
4434
+ <meta name="robots" content="noindex,nofollow" />
4435
+ <style>
4436
+ body { margin: 0; font-family: system-ui, -apple-system, sans-serif; background: #0b1220; color: #e5edf8; line-height: 1.6; }
4437
+ main { max-width: 680px; margin: 0 auto; padding: 72px 20px; }
4438
+ .card { border: 1px solid #26354f; border-radius: 14px; padding: 24px; background: #111a2b; }
4439
+ h1 { margin: 0 0 10px; font-size: 30px; }
4440
+ p { color: #b8c5d8; }
4441
+ a { color: #7dd3fc; font-weight: 700; text-decoration: none; }
4442
+ </style>
4443
+ </head>
4444
+ <body>
4445
+ <main>
4446
+ <div class="card">
4447
+ <h1>${safeTitle}</h1>
4448
+ <p>${safeDetail}</p>
4449
+ ${safeLeadId}
4450
+ <p><a href="${escapeHtmlAttribute(runtimeConfig.appOrigin)}/broker-audit">Back to broker audit page</a></p>
4451
+ </div>
4452
+ </main>
4453
+ </body>
4454
+ </html>`;
4455
+ }
4456
+
3355
4457
  function readBodyBuffer(req, maxBytes = 1024 * 1024) {
3356
4458
  return new Promise((resolve, reject) => {
3357
4459
  let total = 0;
@@ -3396,6 +4498,72 @@ async function parseFormBody(req, maxBytes = 1024 * 1024) {
3396
4498
  return Object.fromEntries(params.entries());
3397
4499
  }
3398
4500
 
4501
+ function normalizeLeadEmail(value) {
4502
+ const email = normalizeNullableText(value);
4503
+ if (!email || !/^[^\s@]{1,64}@[^\s@]{1,255}\.[^\s@]{1,63}$/.test(email)) {
4504
+ throw createHttpError(400, 'A valid email is required');
4505
+ }
4506
+ return email.toLowerCase();
4507
+ }
4508
+
4509
+ function normalizeHttpUrl(value, fieldName) {
4510
+ const raw = normalizeNullableText(value);
4511
+ if (!raw) throw createHttpError(400, `${fieldName} is required`);
4512
+ try {
4513
+ const parsedUrl = new URL(raw);
4514
+ if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
4515
+ throw new Error('unsupported protocol');
4516
+ }
4517
+ return parsedUrl.toString();
4518
+ } catch {
4519
+ throw createHttpError(400, `${fieldName} must be an http(s) URL`);
4520
+ }
4521
+ }
4522
+
4523
+ function appendBrokerAuditLead(feedbackDir, payload) {
4524
+ const lead = {
4525
+ leadId: createTraceId('broker_audit_lead'),
4526
+ status: 'new',
4527
+ offer: 'broker_lead_flow_audit_free',
4528
+ requestedAt: new Date().toISOString(),
4529
+ contact: {
4530
+ name: normalizeNullableText(payload.name),
4531
+ email: normalizeLeadEmail(payload.email),
4532
+ brokerage: normalizeNullableText(payload.brokerage),
4533
+ website: normalizeHttpUrl(payload.website, 'website'),
4534
+ },
4535
+ qualification: {
4536
+ suspectedLeak: normalizeNullableText(payload.suspected_leak || payload.suspectedLeak),
4537
+ },
4538
+ attribution: {
4539
+ traceId: payload.traceId,
4540
+ acquisitionId: payload.acquisitionId,
4541
+ visitorId: payload.visitorId,
4542
+ sessionId: payload.sessionId,
4543
+ source: payload.source,
4544
+ utmSource: payload.utmSource,
4545
+ utmMedium: payload.utmMedium,
4546
+ utmCampaign: payload.utmCampaign,
4547
+ utmContent: payload.utmContent,
4548
+ utmTerm: payload.utmTerm,
4549
+ ctaId: payload.ctaId,
4550
+ ctaPlacement: payload.ctaPlacement,
4551
+ page: payload.page,
4552
+ landingPath: payload.landingPath,
4553
+ referrer: payload.referrer,
4554
+ referrerHost: payload.referrerHost,
4555
+ },
4556
+ };
4557
+
4558
+ if (!lead.contact.name) throw createHttpError(400, 'name is required');
4559
+ if (!lead.contact.brokerage) throw createHttpError(400, 'brokerage is required');
4560
+
4561
+ const leadsPath = path.join(feedbackDir, 'broker-audit-leads.jsonl');
4562
+ fs.mkdirSync(path.dirname(leadsPath), { recursive: true });
4563
+ fs.appendFileSync(leadsPath, `${JSON.stringify(lead)}\n`, 'utf8');
4564
+ return lead;
4565
+ }
4566
+
3399
4567
  function parseOptionalObject(input, name) {
3400
4568
  if (input == null) return {};
3401
4569
  if (typeof input === 'object' && !Array.isArray(input)) return input;
@@ -3470,6 +4638,49 @@ function extractApiKey(req) {
3470
4638
  return '';
3471
4639
  }
3472
4640
 
4641
+ /**
4642
+ * Map a ThumbGate key presented at the OAuth consent screen to a role.
4643
+ *
4644
+ * - When any key is configured (production), the presented key MUST match a
4645
+ * configured admin/operator/reviewer key; otherwise returns null (reject) so
4646
+ * the OAuth flow actually authenticates the holder.
4647
+ * - When NO keys are configured (insecure/dev mode, e.g. local tests), any
4648
+ * non-empty key is accepted as role 'dev' to preserve local development.
4649
+ *
4650
+ * 'reviewer' (THUMBGATE_REVIEWER_KEY) is a read-only, independently-revocable
4651
+ * credential safe to share with a directory reviewer — tool execution enforces
4652
+ * read-only for it (see the tools/call handler).
4653
+ */
4654
+ // Constant-time comparison of two API/OAuth bearer keys. We compare the raw
4655
+ // bytes with crypto.timingSafeEqual after an explicit equal-length guard
4656
+ // (timingSafeEqual throws on a length mismatch). No digest is taken: these are
4657
+ // high-entropy random credentials compared transiently at request time, not
4658
+ // user passwords stored at rest, so neither a KDF (bcrypt/scrypt/argon2) nor a
4659
+ // length-normalizing hash is appropriate — and hashing the credential here is
4660
+ // exactly what static analysis (correctly, for *stored* passwords) flags. The
4661
+ // only side-channel is whether the two keys share a length, which reveals
4662
+ // nothing useful about a random secret. Once lengths match, the byte compare
4663
+ // is constant-time and leaks no information about where they differ.
4664
+ function safeKeyEqual(a, b) {
4665
+ const x = Buffer.from(String(a || ''), 'utf8');
4666
+ const y = Buffer.from(String(b || ''), 'utf8');
4667
+ if (x.length === 0 || y.length === 0 || x.length !== y.length) return false;
4668
+ return require('crypto').timingSafeEqual(x, y);
4669
+ }
4670
+
4671
+ function resolveKeyRole(key) {
4672
+ const k = String(key || '').trim();
4673
+ const adminKey = String(process.env.THUMBGATE_API_KEY || '').trim();
4674
+ const operatorKey = String(process.env.THUMBGATE_OPERATOR_KEY || '').trim();
4675
+ const reviewerKey = String(process.env.THUMBGATE_REVIEWER_KEY || '').trim();
4676
+ const configured = [adminKey, operatorKey, reviewerKey].filter(Boolean);
4677
+ if (configured.length === 0) return k ? 'dev' : null;
4678
+ if (safeKeyEqual(k, adminKey)) return 'admin';
4679
+ if (safeKeyEqual(k, operatorKey)) return 'operator';
4680
+ if (safeKeyEqual(k, reviewerKey)) return 'reviewer';
4681
+ return null;
4682
+ }
4683
+
3473
4684
  /**
3474
4685
  * Admin-only guard for static THUMBGATE_API_KEY.
3475
4686
  * Billing keys are intentionally excluded from admin actions.
@@ -3570,6 +4781,42 @@ function normalizeJobIdFromPath(pathname, suffix = '') {
3570
4781
  return match ? decodeURIComponent(match[1]) : null;
3571
4782
  }
3572
4783
 
4784
+ function escapeHtml(value) {
4785
+ return String(value)
4786
+ .replaceAll('&', '&amp;')
4787
+ .replaceAll('<', '&lt;')
4788
+ .replaceAll('>', '&gt;')
4789
+ .replaceAll('"', '&quot;')
4790
+ .replaceAll("'", '&#39;');
4791
+ }
4792
+
4793
+ function escapeHtmlUnsafeJsonString(value) {
4794
+ return String(value)
4795
+ .replaceAll('<', String.raw`\u003c`)
4796
+ .replaceAll('>', String.raw`\u003e`)
4797
+ .replaceAll('&', String.raw`\u0026`)
4798
+ .replaceAll('\u2028', String.raw`\u2028`)
4799
+ .replaceAll('\u2029', String.raw`\u2029`);
4800
+ }
4801
+
4802
+ function sanitizeHtmlUnsafeJsonValue(value) {
4803
+ if (typeof value === 'string') {
4804
+ return escapeHtmlUnsafeJsonString(value);
4805
+ }
4806
+ if (Array.isArray(value)) {
4807
+ return value.map((entry) => sanitizeHtmlUnsafeJsonValue(entry));
4808
+ }
4809
+ if (!value || typeof value !== 'object') {
4810
+ return value;
4811
+ }
4812
+ return Object.fromEntries(
4813
+ Object.entries(value).map(([key, entry]) => [
4814
+ escapeHtmlUnsafeJsonString(key),
4815
+ sanitizeHtmlUnsafeJsonValue(entry),
4816
+ ])
4817
+ );
4818
+ }
4819
+
3573
4820
  function normalizeDocumentIdFromPath(pathname) {
3574
4821
  const match = pathname.match(/^\/v1\/documents\/([^/]+)$/);
3575
4822
  return match ? decodeURIComponent(match[1]) : null;
@@ -3679,12 +4926,75 @@ function createApiServer() {
3679
4926
  tools: getPublicMcpTools(),
3680
4927
  },
3681
4928
  });
4929
+ } else if (msg.method === 'tools/call') {
4930
+ // Authenticated tool execution. Accept either an OAuth 2.1 access
4931
+ // token (audience-bound to this MCP server, RFC 8707) or a raw
4932
+ // ThumbGate API key, both via the Bearer header.
4933
+ const bearer = extractBearerToken(req);
4934
+ const resourceUrl = buildPublicUrl(hostedConfig, '/mcp');
4935
+ const oauthSession = mcpOauth.resolveAccessToken(oauthStore, bearer);
4936
+ // OAuth path: token must resolve AND be audience-bound to this server
4937
+ // (RFC 8707). Raw-key path: only an exact match to a configured
4938
+ // operator/admin key — never "any non-empty bearer".
4939
+ const adminKey = String(process.env.THUMBGATE_API_KEY || '').trim();
4940
+ const operatorKey = String(process.env.THUMBGATE_OPERATOR_KEY || '').trim();
4941
+ const reviewerKey = String(process.env.THUMBGATE_REVIEWER_KEY || '').trim();
4942
+ const rawKeyValid = Boolean(bearer) && (safeKeyEqual(bearer, adminKey) || safeKeyEqual(bearer, operatorKey) || safeKeyEqual(bearer, reviewerKey));
4943
+ const authed = oauthSession
4944
+ ? mcpOauth.tokenAudienceValid(oauthSession, resourceUrl)
4945
+ : rawKeyValid;
4946
+ if (!authed) {
4947
+ res.writeHead(401, {
4948
+ 'Content-Type': 'application/json',
4949
+ // RFC 9728: point unauthenticated clients at the resource metadata.
4950
+ 'WWW-Authenticate': `Bearer resource_metadata="${buildPublicUrl(hostedConfig, '/.well-known/oauth-protected-resource')}"`,
4951
+ });
4952
+ res.end(JSON.stringify({
4953
+ jsonrpc: '2.0', id: msg.id,
4954
+ error: { code: -32001, message: 'Authentication required. Use OAuth 2.1 (see /.well-known/oauth-protected-resource) or a ThumbGate API key.' },
4955
+ }));
4956
+ return;
4957
+ }
4958
+ // The reviewer credential (THUMBGATE_REVIEWER_KEY) is read-only: it may
4959
+ // only invoke tools annotated readOnlyHint:true. This makes a credential
4960
+ // safe to share (e.g. with a directory reviewer) without granting the
4961
+ // ability to mutate shared server state.
4962
+ const effectiveKey = oauthSession ? String(oauthSession.boundKey || '') : bearer;
4963
+ const isReviewer = Boolean(reviewerKey) && safeKeyEqual(effectiveKey, reviewerKey);
4964
+ if (isReviewer) {
4965
+ const name = msg.params && msg.params.name;
4966
+ const tool = MCP_TOOLS.find((t) => t.name === name);
4967
+ const readOnly = Boolean(tool && tool.annotations && tool.annotations.readOnlyHint === true);
4968
+ if (!readOnly) {
4969
+ sendJson(res, 200, {
4970
+ jsonrpc: '2.0', id: msg.id,
4971
+ error: { code: -32002, message: `Tool "${name}" requires write access; the reviewer credential is read-only.` },
4972
+ });
4973
+ return;
4974
+ }
4975
+ }
4976
+ (async () => {
4977
+ try {
4978
+ const { callTool } = require('../../adapters/mcp/server-stdio');
4979
+ const name = msg.params && msg.params.name;
4980
+ const args = (msg.params && msg.params.arguments) || {};
4981
+ const result = await callTool(name, args);
4982
+ sendJson(res, 200, {
4983
+ jsonrpc: '2.0', id: msg.id,
4984
+ result: { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }] },
4985
+ });
4986
+ } catch (err) {
4987
+ sendJson(res, 200, {
4988
+ jsonrpc: '2.0', id: msg.id,
4989
+ result: { isError: true, content: [{ type: 'text', text: String(err && err.message || err) }] },
4990
+ });
4991
+ }
4992
+ })();
3682
4993
  } else {
3683
- // All other tool calls require auth — return method not found for unauthenticated
3684
4994
  sendJson(res, 200, {
3685
4995
  jsonrpc: '2.0',
3686
4996
  id: msg.id,
3687
- error: { code: -32601, message: 'Method requires authentication. Provide Bearer token.' },
4997
+ error: { code: -32601, message: `Method not found: ${msg.method}` },
3688
4998
  });
3689
4999
  }
3690
5000
  } catch (_e) {
@@ -3772,7 +5082,10 @@ function createApiServer() {
3772
5082
  });
3773
5083
  sendJson(res, 200, result);
3774
5084
  } catch (e) {
3775
- sendJson(res, 500, { error: 'feedback submission failed' });
5085
+ sendJson(res, 500, {
5086
+ error: 'feedback submission failed',
5087
+ message: e?.message || 'Unable to submit dashboard feedback.',
5088
+ });
3776
5089
  }
3777
5090
  });
3778
5091
  return;
@@ -3908,7 +5221,7 @@ function createApiServer() {
3908
5221
  if (req.method === 'POST' && pathname === '/api/event') {
3909
5222
  // Filter bots from analytics to keep Plausible data clean
3910
5223
  let _botDetector;
3911
- try { _botDetector = require('../../scripts/bot-detector'); } catch (_e) { _botDetector = null; }
5224
+ try { _botDetector = require('../../scripts/bot-detection'); } catch (_e) { _botDetector = null; }
3912
5225
  if (_botDetector && _botDetector.shouldExcludeFromAnalytics(req)) {
3913
5226
  sendJson(res, 202, { status: 'filtered', reason: 'bot' });
3914
5227
  return;
@@ -3961,7 +5274,7 @@ function createApiServer() {
3961
5274
  return;
3962
5275
  }
3963
5276
 
3964
- if (isGetLikeRequest && pathname === '/.well-known/llms.txt') {
5277
+ if (isGetLikeRequest && (pathname === '/.well-known/llms.txt' || pathname === '/llms.txt')) {
3965
5278
  const llmsTxtPath = path.join(__dirname, '..', '..', '.well-known', 'llms.txt');
3966
5279
  try {
3967
5280
  const content = fs.readFileSync(llmsTxtPath, 'utf8');
@@ -3972,11 +5285,22 @@ function createApiServer() {
3972
5285
  return;
3973
5286
  }
3974
5287
 
3975
- if (isGetLikeRequest && pathname === '/sitemap.xml') {
3976
- sendText(res, 200, renderSitemapXml(hostedConfig), {
3977
- 'Content-Type': 'application/xml; charset=utf-8',
3978
- }, {
3979
- headOnly: isHeadRequest,
5288
+ if (isGetLikeRequest && pathname === '/.well-known/agentic-verify.txt') {
5289
+ const agenticVerifyPath = path.join(__dirname, '..', '..', '.well-known', 'agentic-verify.txt');
5290
+ try {
5291
+ const content = fs.readFileSync(agenticVerifyPath, 'utf8');
5292
+ sendText(res, 200, content, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'public, max-age=3600' }, { headOnly: isHeadRequest });
5293
+ } catch {
5294
+ sendJson(res, 404, { error: 'agentic verification file not found' });
5295
+ }
5296
+ return;
5297
+ }
5298
+
5299
+ if (isGetLikeRequest && pathname === '/sitemap.xml') {
5300
+ sendText(res, 200, renderSitemapXml(hostedConfig), {
5301
+ 'Content-Type': 'application/xml; charset=utf-8',
5302
+ }, {
5303
+ headOnly: isHeadRequest,
3980
5304
  });
3981
5305
  return;
3982
5306
  }
@@ -4264,12 +5588,66 @@ async function addContext(){
4264
5588
  return;
4265
5589
  }
4266
5590
 
5591
+ if (isGetLikeRequest && (pathname === '/about' || pathname === '/about.html')) {
5592
+ try {
5593
+ servePublicMarketingPage({
5594
+ req,
5595
+ res,
5596
+ parsed,
5597
+ hostedConfig,
5598
+ isHeadRequest,
5599
+ renderHtml: loadAboutPageHtml,
5600
+ extraTelemetry: {
5601
+ pageType: 'about',
5602
+ },
5603
+ });
5604
+ } catch (err) {
5605
+ sendText(res, 500, err.message || 'About page unavailable');
5606
+ }
5607
+ return;
5608
+ }
5609
+
4267
5610
  if (isGetLikeRequest && (pathname === '/guide' || pathname === '/guide.html')) {
4268
5611
  try {
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' });
5612
+ servePublicMarketingPage({
5613
+ req,
5614
+ res,
5615
+ parsed,
5616
+ hostedConfig,
5617
+ isHeadRequest,
5618
+ renderHtml: loadGuidePageHtml,
5619
+ extraTelemetry: {
5620
+ pageType: 'guide',
5621
+ },
5622
+ });
5623
+ } catch (err) {
5624
+ sendText(res, 500, err.message || 'Guide page unavailable');
5625
+ }
5626
+ return;
5627
+ }
5628
+
5629
+ if (isGetLikeRequest && (
5630
+ pathname === '/install'
5631
+ || pathname === '/install.html'
5632
+ || pathname === '/marketplace'
5633
+ || pathname === '/marketplace.html'
5634
+ || pathname === '/marketplaces'
5635
+ || pathname === '/marketplaces.html'
5636
+ )) {
5637
+ try {
5638
+ servePublicMarketingPage({
5639
+ req,
5640
+ res,
5641
+ parsed,
5642
+ hostedConfig,
5643
+ isHeadRequest,
5644
+ renderHtml: loadInstallPageHtml,
5645
+ extraTelemetry: {
5646
+ pageType: 'install',
5647
+ },
5648
+ });
5649
+ } catch (err) {
5650
+ sendText(res, 500, err.message || 'Install page unavailable');
4273
5651
  }
4274
5652
  return;
4275
5653
  }
@@ -4284,6 +5662,28 @@ async function addContext(){
4284
5662
  return;
4285
5663
  }
4286
5664
 
5665
+ if (isGetLikeRequest && (
5666
+ pathname === '/chatgpt-app'
5667
+ || pathname === '/chatgpt-app.html'
5668
+ || pathname === '/chatgpt-plugin'
5669
+ || pathname === '/chatgpt-plugin.html'
5670
+ )) {
5671
+ try {
5672
+ servePublicMarketingPage({
5673
+ req,
5674
+ res,
5675
+ parsed,
5676
+ hostedConfig,
5677
+ isHeadRequest,
5678
+ renderHtml: () => fs.readFileSync(CHATGPT_APP_PAGE_PATH, 'utf-8'),
5679
+ extraTelemetry: { pageType: 'chatgpt_app' },
5680
+ });
5681
+ } catch {
5682
+ sendJson(res, 404, { error: 'ChatGPT app page not found' });
5683
+ }
5684
+ return;
5685
+ }
5686
+
4287
5687
  if (isGetLikeRequest && (pathname === '/compare' || pathname === '/compare.html')) {
4288
5688
  try {
4289
5689
  const html = fs.readFileSync(COMPARE_PAGE_PATH, 'utf-8');
@@ -4307,10 +5707,19 @@ async function addContext(){
4307
5707
 
4308
5708
  if (isGetLikeRequest && (pathname === '/learn' || pathname === '/learn.html')) {
4309
5709
  try {
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' });
5710
+ servePublicMarketingPage({
5711
+ req,
5712
+ res,
5713
+ parsed,
5714
+ hostedConfig,
5715
+ isHeadRequest,
5716
+ renderHtml: loadLearnPageHtml,
5717
+ extraTelemetry: {
5718
+ pageType: 'learn',
5719
+ },
5720
+ });
5721
+ } catch (err) {
5722
+ sendText(res, 500, err.message || 'Learn page unavailable');
4314
5723
  }
4315
5724
  return;
4316
5725
  }
@@ -4333,13 +5742,16 @@ async function addContext(){
4333
5742
  return;
4334
5743
  }
4335
5744
 
4336
- // Natural marketing URLs for the Workflow Hardening Sprint. Outbound
4337
- // messages, social posts, and word-of-mouth all refer to "the sprint"
4338
- // or "workflow hardening" recipients who type or paste the natural
4339
- // URLs currently hit the generic API 401 JSON error and bounce.
4340
- // Redirect them to the canonical intake anchor instead.
5745
+ // Natural marketing URLs for the Workflow Hardening Diagnostic/Sprint.
5746
+ // High-intent outreach refers to "the diagnostic", "the sprint", or
5747
+ // "workflow hardening"; serve a focused buyer page instead of sending
5748
+ // qualified traffic to the generic homepage anchor.
4341
5749
  if (isGetLikeRequest && (
4342
- pathname === '/sprint'
5750
+ pathname === '/diagnostic'
5751
+ || pathname === '/diagnostic.html'
5752
+ || pathname === '/workflow-diagnostic'
5753
+ || pathname === '/workflow-diagnostic.html'
5754
+ || pathname === '/sprint'
4343
5755
  || pathname === '/sprint.html'
4344
5756
  || pathname === '/workflow-hardening'
4345
5757
  || pathname === '/workflow-hardening.html'
@@ -4348,11 +5760,21 @@ async function addContext(){
4348
5760
  || pathname === '/workflow-sprint'
4349
5761
  || pathname === '/workflow-sprint.html'
4350
5762
  )) {
4351
- res.writeHead(302, {
4352
- Location: '/#workflow-sprint-intake',
4353
- 'Cache-Control': 'no-store',
4354
- });
4355
- res.end();
5763
+ try {
5764
+ servePublicMarketingPage({
5765
+ req,
5766
+ res,
5767
+ parsed,
5768
+ hostedConfig,
5769
+ isHeadRequest,
5770
+ renderHtml: loadDiagnosticPageHtml,
5771
+ extraTelemetry: {
5772
+ pageType: 'diagnostic',
5773
+ },
5774
+ });
5775
+ } catch (err) {
5776
+ sendText(res, 500, err.message || 'Diagnostic page unavailable');
5777
+ }
4356
5778
  return;
4357
5779
  }
4358
5780
 
@@ -4398,6 +5820,94 @@ async function addContext(){
4398
5820
  return;
4399
5821
  }
4400
5822
 
5823
+ if (isGetLikeRequest && (pathname === '/agent-manager' || pathname === '/agent-manager.html')) {
5824
+ // ICP landing page for the role Anthropic named (Agent Manager —
5825
+ // hybrid PM/engineer DRI who owns CLAUDE.md hierarchy, plugin
5826
+ // marketplace, permissions policy, and which skills ship). Routed
5827
+ // through servePublicMarketingPage so arrivals via X/Bluesky/LinkedIn
5828
+ // threads about the role capture UTM attribution and
5829
+ // landing_page_view telemetry for downstream pilot-pipeline analysis.
5830
+ try {
5831
+ servePublicMarketingPage({
5832
+ req,
5833
+ res,
5834
+ parsed,
5835
+ hostedConfig,
5836
+ isHeadRequest,
5837
+ renderHtml: () => fs.readFileSync(path.join(PUBLIC_DIR, 'agent-manager.html'), 'utf-8'),
5838
+ extraTelemetry: { pageType: 'agent_manager' },
5839
+ });
5840
+ } catch {
5841
+ sendJson(res, 404, { error: 'Agent Manager page not found' });
5842
+ }
5843
+ return;
5844
+ }
5845
+
5846
+ if (isGetLikeRequest && (pathname === '/codex-enterprise' || pathname === '/codex-enterprise.html')) {
5847
+ // Landing page riding the 2026-05-20 OpenAI×Dell Codex Enterprise
5848
+ // partnership announcement. Dell-distributed Codex expands the TAM
5849
+ // for ThumbGate's governance layer — capture every agent decision,
5850
+ // promote repeat failures to PreToolUse gates, ship the audit trail
5851
+ // procurement requires. Routed through servePublicMarketingPage so
5852
+ // arrivals via the partnership news cycle capture UTM attribution
5853
+ // and landing_page_view telemetry with pageType: 'codex_enterprise'.
5854
+ try {
5855
+ servePublicMarketingPage({
5856
+ req,
5857
+ res,
5858
+ parsed,
5859
+ hostedConfig,
5860
+ isHeadRequest,
5861
+ renderHtml: () => fs.readFileSync(path.join(PUBLIC_DIR, 'codex-enterprise.html'), 'utf-8'),
5862
+ extraTelemetry: { pageType: 'codex_enterprise' },
5863
+ });
5864
+ } catch {
5865
+ sendJson(res, 404, { error: 'Codex Enterprise page not found' });
5866
+ }
5867
+ return;
5868
+ }
5869
+
5870
+ if (isGetLikeRequest && (pathname === '/agents-cost-savings' || pathname === '/agents-cost-savings.html')) {
5871
+ // FinOps-for-AI positioning page. Pairs with the `thumbgate cost` CLI
5872
+ // shipped in #2281: the CLI prints the dollar amount, this page is
5873
+ // the public-facing explanation of why "prevention" (ThumbGate's
5874
+ // PreToolUse gates) is a distinct category from "reporting" (Finout,
5875
+ // Helicone, Vantage, AgentOps). Reply-to-pitch surface for buyers
5876
+ // who get a FinOps-for-AI marketing email and need a frame.
5877
+ try {
5878
+ servePublicMarketingPage({
5879
+ req,
5880
+ res,
5881
+ parsed,
5882
+ hostedConfig,
5883
+ isHeadRequest,
5884
+ renderHtml: () => fs.readFileSync(path.join(PUBLIC_DIR, 'agents-cost-savings.html'), 'utf-8'),
5885
+ extraTelemetry: { pageType: 'agents_cost_savings' },
5886
+ });
5887
+ } catch {
5888
+ sendJson(res, 404, { error: 'Agents Cost Savings page not found' });
5889
+ }
5890
+ return;
5891
+ }
5892
+
5893
+ if (isGetLikeRequest && (pathname === '/ai-malpractice-prevention' || pathname === '/ai-malpractice-prevention.html')) {
5894
+ // Legal-vertical landing page (2026-05-21).
5895
+ try {
5896
+ servePublicMarketingPage({
5897
+ req,
5898
+ res,
5899
+ parsed,
5900
+ hostedConfig,
5901
+ isHeadRequest,
5902
+ renderHtml: () => fs.readFileSync(path.join(PUBLIC_DIR, 'ai-malpractice-prevention.html'), 'utf-8'),
5903
+ extraTelemetry: { pageType: 'ai_malpractice_prevention' },
5904
+ });
5905
+ } catch {
5906
+ sendJson(res, 404, { error: 'AI Malpractice Prevention page not found' });
5907
+ }
5908
+ return;
5909
+ }
5910
+
4401
5911
  if (isGetLikeRequest && pathname === '/learn/learn.css') {
4402
5912
  try {
4403
5913
  const cssPath = path.join(LEARN_DIR, 'learn.css');
@@ -4413,13 +5923,13 @@ async function addContext(){
4413
5923
 
4414
5924
  if (isGetLikeRequest && pathname.startsWith('/learn/')) {
4415
5925
  try {
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' });
5926
+ const articlePath = resolvePublicHtmlFile(LEARN_PAGE_PATHS_BY_SLUG, pathname.replace('/learn/', ''));
5927
+ if (!articlePath) {
5928
+ sendJson(res, 404, { error: 'Article not found' });
4420
5929
  return;
4421
5930
  }
4422
- const html = fs.readFileSync(articlePath, 'utf-8');
5931
+ const requestHost = getSafePublicRequestHost(req);
5932
+ const html = normalizePublicMarketingHtml(fs.readFileSync(articlePath, 'utf-8'), hostedConfig, requestHost);
4423
5933
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
4424
5934
  } catch {
4425
5935
  sendJson(res, 404, { error: 'Article not found' });
@@ -4429,10 +5939,10 @@ async function addContext(){
4429
5939
 
4430
5940
  if (isGetLikeRequest && pathname.startsWith('/guides/')) {
4431
5941
  try {
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');
5942
+ const guidePath = resolvePublicHtmlFile(GUIDE_PAGE_PATHS_BY_SLUG, pathname.replace('/guides/', ''));
5943
+ if (!guidePath) { sendJson(res, 404, { error: 'Guide not found' }); return; }
5944
+ const requestHost = getSafePublicRequestHost(req);
5945
+ const html = normalizePublicMarketingHtml(fs.readFileSync(guidePath, 'utf-8'), hostedConfig, requestHost);
4436
5946
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
4437
5947
  } catch { sendJson(res, 404, { error: 'Guide not found' }); }
4438
5948
  return;
@@ -4440,10 +5950,10 @@ async function addContext(){
4440
5950
 
4441
5951
  if (isGetLikeRequest && pathname.startsWith('/compare/') && pathname !== '/compare') {
4442
5952
  try {
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');
5953
+ const comparePath = resolvePublicHtmlFile(COMPARE_PAGE_PATHS_BY_SLUG, pathname.replace('/compare/', ''));
5954
+ if (!comparePath) { sendJson(res, 404, { error: 'Comparison not found' }); return; }
5955
+ const requestHost = getSafePublicRequestHost(req);
5956
+ const html = normalizePublicMarketingHtml(fs.readFileSync(comparePath, 'utf-8'), hostedConfig, requestHost);
4447
5957
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
4448
5958
  } catch { sendJson(res, 404, { error: 'Comparison not found' }); }
4449
5959
  return;
@@ -4451,10 +5961,10 @@ async function addContext(){
4451
5961
 
4452
5962
  if (isGetLikeRequest && pathname.startsWith('/use-cases/')) {
4453
5963
  try {
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');
5964
+ const useCasePath = resolvePublicHtmlFile(USE_CASE_PAGE_PATHS_BY_SLUG, pathname.replace('/use-cases/', ''));
5965
+ if (!useCasePath) { sendJson(res, 404, { error: 'Use case not found' }); return; }
5966
+ const requestHost = getSafePublicRequestHost(req);
5967
+ const html = normalizePublicMarketingHtml(fs.readFileSync(useCasePath, 'utf-8'), hostedConfig, requestHost);
4458
5968
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
4459
5969
  } catch { sendJson(res, 404, { error: 'Use case not found' }); }
4460
5970
  return;
@@ -4471,6 +5981,18 @@ async function addContext(){
4471
5981
  return;
4472
5982
  }
4473
5983
 
5984
+ if (isGetLikeRequest && pathname.startsWith('/media/')) {
5985
+ const rel = pathname.slice('/media/'.length);
5986
+ const mediaDir = path.join(PUBLIC_DIR, 'media');
5987
+ const resolved = path.resolve(mediaDir, rel);
5988
+ if (!resolved.startsWith(mediaDir + path.sep) && resolved !== mediaDir) {
5989
+ sendJson(res, 403, { error: 'Forbidden' });
5990
+ return;
5991
+ }
5992
+ serveStaticFile(res, resolved, { headOnly: isHeadRequest });
5993
+ return;
5994
+ }
5995
+
4474
5996
  if (isGetLikeRequest && (
4475
5997
  pathname === '/favicon.ico'
4476
5998
  || pathname === '/thumbgate-logo.png'
@@ -4489,7 +6011,7 @@ async function addContext(){
4489
6011
  version: pkg.version,
4490
6012
  status: 'ok',
4491
6013
  docs: 'https://github.com/IgorGanapolsky/ThumbGate',
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'],
6014
+ 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'],
4493
6015
  }, {}, {
4494
6016
  headOnly: isHeadRequest,
4495
6017
  });
@@ -4514,9 +6036,14 @@ async function addContext(){
4514
6036
  return;
4515
6037
  }
4516
6038
 
4517
- if (isGetLikeRequest && pathname === '/checkout/pro') {
6039
+ // HOTFIX 2026-06-04 accept ANY method (GET/HEAD/POST) on /checkout/pro
6040
+ // to prevent the API-key guard from 401'ing real prospective customers
6041
+ // whose forms or fetch() calls land via POST. Audit: 69 emails submitted
6042
+ // → 0 paid because POST hit the auth gate. Query params still drive the
6043
+ // Stripe session creation; POST bodies are ignored harmlessly.
6044
+ if ((isGetLikeRequest || req.method === 'POST') && pathname === '/checkout/pro') {
4518
6045
  if (isHeadRequest) {
4519
- sendText(res, 200, '', {}, {
6046
+ sendHtml(res, 200, '', {}, {
4520
6047
  headOnly: true,
4521
6048
  });
4522
6049
  return;
@@ -4545,10 +6072,76 @@ async function addContext(){
4545
6072
  // (form submission JS-less bots don't do), (b) a `customer_email`
4546
6073
  // query param treats the request as confirmed even from a bot UA,
4547
6074
  // because no real crawler appends customer_email to discovered URLs.
6075
+ const rawCheckoutEmail = normalizeNullableText(bootstrapBody.customerEmail);
6076
+ const normalizedCheckoutEmail = normalizeCheckoutCustomerEmail(rawCheckoutEmail);
4548
6077
  const hasCustomerEmailHint = !!parsed?.searchParams?.has('customer_email');
4549
- const botShouldBypass = !botClassification.isBot || hasCustomerEmailHint;
4550
- const isConfirmedCheckout = req.method === 'POST'
4551
- || (hasConfirmFlag && botShouldBypass);
6078
+ const hasValidCustomerEmailHint = !!normalizedCheckoutEmail;
6079
+ const botShouldBypass = !botClassification.isBot || hasValidCustomerEmailHint;
6080
+ // 2026-06-29 conversion audit: requiring email before Stripe gave us a
6081
+ // recoverable abandoned-session theory, but the hosted funnel showed
6082
+ // traffic with 0 checkout starts. Let confirmed human clicks reach
6083
+ // Stripe; Stripe can collect email on the secure checkout page. Bots
6084
+ // still need a valid email hint to bypass deflection.
6085
+ const isConfirmedCheckout = (
6086
+ (req.method === 'POST' && hasValidCustomerEmailHint)
6087
+ || hasConfirmFlag
6088
+ ) && botShouldBypass;
6089
+ // 2026-06-05 revenue bypass: env-gated direct-to-Stripe redirect.
6090
+ // Live 30d billing showed 254 interstitial views → 1 Stripe click-through
6091
+ // → 0 paid. When THUMBGATE_CHECKOUT_INTERSTITIAL_BYPASS=1 is set we
6092
+ // route raw /checkout/pro GETs (no confirm=1, no POST) straight to the
6093
+ // pro Stripe Payment Link, preserving UTM + attribution metadata via
6094
+ // buildCheckoutFallbackUrl. Default-off; bot-deflection still applies
6095
+ // (bot + no email hint still falls through to the existing interstitial).
6096
+ const interstitialBypassEnabled = process.env.THUMBGATE_CHECKOUT_INTERSTITIAL_BYPASS !== '0';
6097
+ const interstitialSampleRate = normalizeCheckoutInterstitialSampleRate(
6098
+ process.env.THUMBGATE_CHECKOUT_INTERSTITIAL_SAMPLE_RATE
6099
+ );
6100
+ const interstitialSampled = shouldSampleCheckoutInterstitial({
6101
+ sampleRate: interstitialSampleRate,
6102
+ traceId,
6103
+ analyticsMetadata,
6104
+ });
6105
+ if (
6106
+ !isConfirmedCheckout
6107
+ && interstitialBypassEnabled
6108
+ && req.method !== 'POST'
6109
+ && botShouldBypass
6110
+ && !interstitialSampled
6111
+ ) {
6112
+ // Always target the pro Stripe Payment Link directly. The
6113
+ // hostedConfig.checkoutFallbackUrl (e.g. https://thumbgate.ai/go/pro)
6114
+ // is a router that 302s back to /checkout/pro, which would create a
6115
+ // redirect loop when bypass is on. Env override via
6116
+ // THUMBGATE_CHECKOUT_PRO_STRIPE_URL is supported for future
6117
+ // price-link rotation without a redeploy.
6118
+ const bypassTarget = process.env.THUMBGATE_CHECKOUT_PRO_STRIPE_URL
6119
+ || FIRST_FAILURE_RULE_CHECKOUT_URL;
6120
+ appendBestEffortTelemetry(FEEDBACK_DIR, {
6121
+ eventType: 'checkout_interstitial_bypass_redirect',
6122
+ clientType: 'web',
6123
+ traceId,
6124
+ acquisitionId: analyticsMetadata.acquisitionId,
6125
+ visitorId: analyticsMetadata.visitorId,
6126
+ sessionId: analyticsMetadata.sessionId,
6127
+ utmSource: analyticsMetadata.utmSource,
6128
+ utmMedium: analyticsMetadata.utmMedium,
6129
+ utmCampaign: analyticsMetadata.utmCampaign,
6130
+ utmContent: analyticsMetadata.utmContent,
6131
+ utmTerm: analyticsMetadata.utmTerm,
6132
+ referrer: analyticsMetadata.referrer,
6133
+ referrerHost: analyticsMetadata.referrerHost,
6134
+ page: '/checkout/pro',
6135
+ planId: analyticsMetadata.planId,
6136
+ interstitialSampleRate,
6137
+ }, req.headers, 'checkout_interstitial_bypass_redirect');
6138
+ res.writeHead(302, {
6139
+ ...responseHeaders,
6140
+ Location: buildCheckoutFallbackUrl(bypassTarget, analyticsMetadata),
6141
+ });
6142
+ res.end();
6143
+ return;
6144
+ }
4552
6145
  // Plausible funnel event #1 of 3: page view. Fired before interstitial
4553
6146
  // deflection so we get the full top-of-funnel count, with isBot as a
4554
6147
  // prop so the dashboard can filter human vs. crawler traffic. Fire-and-forget.
@@ -4586,6 +6179,7 @@ async function addContext(){
4586
6179
  const eventType = botClassification.isBot
4587
6180
  ? 'checkout_bot_deflected'
4588
6181
  : 'checkout_interstitial_view';
6182
+ const missingConfirmedEmail = hasConfirmFlag && !hasValidCustomerEmailHint;
4589
6183
  appendBestEffortTelemetry(FEEDBACK_DIR, {
4590
6184
  eventType,
4591
6185
  clientType: 'web',
@@ -4604,27 +6198,24 @@ async function addContext(){
4604
6198
  ctaId: analyticsMetadata.ctaId,
4605
6199
  ctaPlacement: analyticsMetadata.ctaPlacement,
4606
6200
  planId: analyticsMetadata.planId,
6201
+ billingCycle: analyticsMetadata.billingCycle,
6202
+ landingPath: analyticsMetadata.landingPath,
4607
6203
  isBot: botClassification.isBot ? 'true' : 'false',
4608
- reason: botClassification.reason,
6204
+ interstitialSampled: interstitialSampled ? 'true' : 'false',
6205
+ interstitialSampleRate,
6206
+ reason: missingConfirmedEmail
6207
+ ? (hasCustomerEmailHint ? 'invalid_customer_email' : 'missing_customer_email')
6208
+ : botClassification.reason,
6209
+ confirmEmailRequired: missingConfirmedEmail ? 'true' : 'false',
4609
6210
  }, req.headers, eventType);
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,
6211
+ const prefilledEmail = parsed?.searchParams?.get('customer_email') || '';
6212
+ const html = renderCheckoutIntentPage(prefilledEmail, parsed, {
6213
+ includeHiddenAttribution: !botClassification.isBot,
4621
6214
  });
4622
6215
  sendHtml(res, 200, html, responseHeaders);
4623
6216
  return;
4624
6217
  }
4625
6218
 
4626
- const rawCheckoutEmail = normalizeNullableText(bootstrapBody.customerEmail);
4627
- const normalizedCheckoutEmail = normalizeCheckoutCustomerEmail(rawCheckoutEmail);
4628
6219
  if (!normalizedCheckoutEmail) {
4629
6220
  appendBestEffortTelemetry(FEEDBACK_DIR, {
4630
6221
  eventType: 'checkout_email_deferred_to_stripe',
@@ -4881,10 +6472,13 @@ async function addContext(){
4881
6472
  // Public-facing broker lead-flow audit landing page. Wedge for the
4882
6473
  // real-estate broker outreach. Static HTML served from src/api/static.
4883
6474
  try {
4884
- const html = fs.readFileSync(
6475
+ const host = getSafePublicRequestHost(req);
6476
+ const html = normalizePublicMarketingHtml(fillTemplate(fs.readFileSync(
4885
6477
  path.resolve(__dirname, '../../assets/static/broker-audit.html'),
4886
6478
  'utf8'
4887
- );
6479
+ ), {
6480
+ '__POSTHOG_API_KEY__': hostedConfig.posthogApiKey || '',
6481
+ }), hostedConfig, host);
4888
6482
  if (isHeadRequest) {
4889
6483
  sendHtml(res, 200, html, {}, { headOnly: true });
4890
6484
  return;
@@ -4897,6 +6491,25 @@ async function addContext(){
4897
6491
  return;
4898
6492
  }
4899
6493
 
6494
+ if (isGetLikeRequest && pathname === '/leash-beta') {
6495
+ // Hermes Mobile founding beta landing (mac-yolo-safeguards/hermes-mobile/docs/beta-page).
6496
+ try {
6497
+ const html = fs.readFileSync(
6498
+ path.resolve(__dirname, '../../assets/static/leash-beta.html'),
6499
+ 'utf8'
6500
+ );
6501
+ if (isHeadRequest) {
6502
+ sendHtml(res, 200, html, {}, { headOnly: true });
6503
+ return;
6504
+ }
6505
+ sendHtml(res, 200, html);
6506
+ } catch (err) {
6507
+ console.error('leash-beta page read failed:', err?.message);
6508
+ sendJson(res, 500, { error: 'leash-beta page unavailable' });
6509
+ }
6510
+ return;
6511
+ }
6512
+
4900
6513
  if (isGetLikeRequest && pathname === '/.well-known/mcp.json') {
4901
6514
  sendJson(res, 200, getMcpDiscoveryManifest(hostedConfig), {}, {
4902
6515
  headOnly: isHeadRequest,
@@ -4904,6 +6517,117 @@ async function addContext(){
4904
6517
  return;
4905
6518
  }
4906
6519
 
6520
+ // OAuth 2.1 discovery metadata for the remote MCP connector (RFC 9728 / RFC 8414).
6521
+ // Lets Claude discover how to authenticate before calling authenticated tools.
6522
+ if (isGetLikeRequest && pathname === '/.well-known/oauth-protected-resource') {
6523
+ sendJson(res, 200, mcpOauth.buildProtectedResourceMetadata(buildPublicUrl(hostedConfig, '')), {}, {
6524
+ headOnly: isHeadRequest,
6525
+ });
6526
+ return;
6527
+ }
6528
+ if (isGetLikeRequest && (pathname === '/.well-known/oauth-authorization-server' || pathname === '/.well-known/openid-configuration')) {
6529
+ sendJson(res, 200, mcpOauth.buildAuthServerMetadata(buildPublicUrl(hostedConfig, '')), {}, {
6530
+ headOnly: isHeadRequest,
6531
+ });
6532
+ return;
6533
+ }
6534
+
6535
+ // --- OAuth 2.1 (PKCE) endpoints for the remote MCP connector ---
6536
+ // RFC 7591 Dynamic Client Registration.
6537
+ if (req.method === 'POST' && pathname === '/oauth/register') {
6538
+ let body = '';
6539
+ req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
6540
+ req.on('end', () => {
6541
+ let parsed = {};
6542
+ try { parsed = body ? JSON.parse(body) : {}; } catch { /* ignore */ }
6543
+ const reg = mcpOauth.registerClient(oauthStore, parsed);
6544
+ if (reg.error) { sendJson(res, 400, reg); return; }
6545
+ sendJson(res, 201, reg);
6546
+ });
6547
+ return;
6548
+ }
6549
+ // Authorization endpoint: GET renders consent, POST issues the code.
6550
+ if (pathname === '/oauth/authorize') {
6551
+ if (isGetLikeRequest) {
6552
+ const authRequestToken = createPendingOauthAuthorizeRequest(
6553
+ getOauthAuthorizeParamsFromQuery(parsed.searchParams)
6554
+ );
6555
+ const html = `<!doctype html><html><head><meta charset="utf-8"><title>Authorize ThumbGate</title>
6556
+ <style>body{font:15px system-ui;margin:0;background:#0b0b0c;color:#eee;display:flex;min-height:100vh;align-items:center;justify-content:center}
6557
+ .card{background:#161618;border:1px solid #2a2a2e;border-radius:12px;padding:28px;max-width:420px}
6558
+ input[type=password]{width:100%;padding:10px;margin:8px 0 16px;border-radius:8px;border:1px solid #2a2a2e;background:#0b0b0c;color:#eee}
6559
+ button{width:100%;padding:11px;border-radius:8px;border:0;background:#10b981;color:#04120c;font-weight:600;cursor:pointer}
6560
+ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oauth/authorize">
6561
+ <h2>Authorize Claude → ThumbGate</h2>
6562
+ <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>
6563
+ <input type="hidden" name="auth_request_token" value="${escapeHtmlAttribute(authRequestToken)}">
6564
+ <input type="password" name="api_key" placeholder="ThumbGate API key" autocomplete="off" required>
6565
+ <button type="submit" name="approve" value="yes">Approve</button>
6566
+ </form></body></html>`;
6567
+ sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
6568
+ return;
6569
+ }
6570
+ if (req.method === 'POST') {
6571
+ let body = '';
6572
+ req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
6573
+ req.on('end', () => {
6574
+ const form = new URLSearchParams(body);
6575
+ const authorizationParams = getOauthAuthorizeParamsFromForm(form, hostedConfig);
6576
+ const redirectUri = authorizationParams.redirectUri;
6577
+ const state = authorizationParams.state;
6578
+ // Validate the presented ThumbGate key before issuing a code. When keys
6579
+ // are configured (production) the key MUST match a configured admin /
6580
+ // operator / reviewer key — otherwise OAuth would authenticate nobody.
6581
+ // In insecure/dev mode (no keys configured) any non-empty key is accepted.
6582
+ if (!resolveKeyRole(form.get('api_key') || '')) {
6583
+ sendJson(res, 400, { error: 'access_denied', error_description: 'invalid ThumbGate API key' });
6584
+ return;
6585
+ }
6586
+ const issued = mcpOauth.createAuthorizationCode(oauthStore, {
6587
+ clientId: authorizationParams.clientId,
6588
+ redirectUri,
6589
+ codeChallenge: authorizationParams.codeChallenge,
6590
+ codeChallengeMethod: authorizationParams.codeChallengeMethod,
6591
+ scope: authorizationParams.scope,
6592
+ resource: authorizationParams.resource || buildPublicUrl(hostedConfig, '/mcp'),
6593
+ boundKey: form.get('api_key') || '',
6594
+ state,
6595
+ });
6596
+ if (issued.error) {
6597
+ sendJson(res, 400, { error: issued.error, error_description: issued.error_description });
6598
+ return;
6599
+ }
6600
+ const sep = redirectUri.includes('?') ? '&' : '?';
6601
+ const loc = `${redirectUri}${sep}code=${encodeURIComponent(issued.code)}${state ? `&state=${encodeURIComponent(state)}` : ''}`;
6602
+ res.writeHead(302, { Location: loc });
6603
+ res.end();
6604
+ });
6605
+ return;
6606
+ }
6607
+ }
6608
+ // Token endpoint (authorization_code + PKCE).
6609
+ if (req.method === 'POST' && pathname === '/oauth/token') {
6610
+ let body = '';
6611
+ req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
6612
+ req.on('end', () => {
6613
+ const form = new URLSearchParams(body);
6614
+ if (form.get('grant_type') !== 'authorization_code') {
6615
+ sendJson(res, 400, { error: 'unsupported_grant_type' });
6616
+ return;
6617
+ }
6618
+ const tok = mcpOauth.exchangeCode(oauthStore, {
6619
+ code: form.get('code') || '',
6620
+ codeVerifier: form.get('code_verifier') || '',
6621
+ clientId: form.get('client_id') || '',
6622
+ redirectUri: form.get('redirect_uri') || '',
6623
+ resource: form.get('resource') || undefined,
6624
+ });
6625
+ if (tok.error) { sendJson(res, 400, tok); return; }
6626
+ sendJson(res, 200, tok, { 'Cache-Control': 'no-store' });
6627
+ });
6628
+ return;
6629
+ }
6630
+
4907
6631
  if (isGetLikeRequest && pathname === '/.well-known/mcp/tools.json') {
4908
6632
  sendJson(res, 200, {
4909
6633
  name: 'thumbgate',
@@ -5016,40 +6740,57 @@ async function addContext(){
5016
6740
  // was down, when feedback-dir was unwritable, when env was misconfigured.
5017
6741
  // The fix is shallow but meaningful: probe each critical subsystem and
5018
6742
  // surface failures with HTTP 503 + a per-check breakdown.
6743
+ // Tiered failure classification — not all degradations should kill the
6744
+ // container. A *service-failing* check (feedback dir unwritable,
6745
+ // appOrigin missing) returns 503 → Railway's healthcheck fails → SIGTERM
6746
+ // → restart loop → outage (this exact failure mode took prod down
6747
+ // 2026-05-21 18:21Z → 19:30Z when BUILD_METADATA.buildSha came up empty
6748
+ // after a Railway env-var cleanup). A *telemetry-degraded* check (missing
6749
+ // buildSha) returns 200 with `degraded: true` so observability stays
6750
+ // visible but Railway doesn't kill an otherwise-healthy container over
6751
+ // a SHA gap.
5019
6752
  const checks = {};
5020
- let allOk = true;
6753
+ let failing = false; // any service-failing check → 503
6754
+ let degraded = false; // any telemetry-degraded check → 200 + flag
5021
6755
 
5022
6756
  // Check 1: feedback dir exists and is writable.
6757
+ // SERVICE-FAILING — if we can't write feedback, the API can't function.
5023
6758
  try {
5024
6759
  const { FEEDBACK_DIR } = getFeedbackPaths();
5025
6760
  fs.accessSync(FEEDBACK_DIR, fs.constants.W_OK);
5026
6761
  checks.feedbackDir = { ok: true };
5027
6762
  } catch (err) {
5028
- checks.feedbackDir = { ok: false, error: err?.code || 'inaccessible' };
5029
- allOk = false;
6763
+ checks.feedbackDir = { ok: false, error: err?.code || 'inaccessible', severity: 'failing' };
6764
+ failing = true;
5030
6765
  }
5031
6766
 
5032
6767
  // Check 2: hosted config resolves the canonical app origin.
5033
- // If appOrigin is missing/empty, redirects + checkout flow break silently.
6768
+ // SERVICE-FAILING — if appOrigin is missing/empty, redirects + checkout
6769
+ // flow break silently.
5034
6770
  if (hostedConfig?.appOrigin) {
5035
6771
  checks.hostedConfig = { ok: true };
5036
6772
  } else {
5037
- checks.hostedConfig = { ok: false, error: 'missing_appOrigin' };
5038
- allOk = false;
6773
+ checks.hostedConfig = { ok: false, error: 'missing_appOrigin', severity: 'failing' };
6774
+ failing = true;
5039
6775
  }
5040
6776
 
5041
- // Check 3: build metadata loaded. If BUILD_METADATA.buildSha is empty,
5042
- // Railway didn't inject the deploy SHA observability is degraded.
6777
+ // Check 3: build metadata loaded.
6778
+ // TELEMETRY-DEGRADED observability gap, not a runtime outage. The
6779
+ // container still serves requests fine; we just can't tag responses with
6780
+ // the deployed SHA. Surfaces the gap to monitors via `degraded: true`
6781
+ // without triggering Railway's SIGTERM-on-503 loop.
5043
6782
  if (BUILD_METADATA?.buildSha) {
5044
6783
  checks.buildMetadata = { ok: true };
5045
6784
  } else {
5046
- checks.buildMetadata = { ok: false, error: 'missing_buildSha' };
5047
- allOk = false;
6785
+ checks.buildMetadata = { ok: false, error: 'missing_buildSha', severity: 'degraded' };
6786
+ degraded = true;
5048
6787
  }
5049
6788
 
5050
- const statusCode = allOk ? 200 : 503;
6789
+ const statusCode = failing ? 503 : 200;
6790
+ const status = failing ? 'failing' : (degraded ? 'degraded' : 'ok');
5051
6791
  sendJson(res, statusCode, {
5052
- status: allOk ? 'ok' : 'degraded',
6792
+ status,
6793
+ degraded: degraded || failing,
5053
6794
  version: pkg.version,
5054
6795
  buildSha: BUILD_METADATA.buildSha,
5055
6796
  uptime: process.uptime(),
@@ -5119,7 +6860,7 @@ async function addContext(){
5119
6860
  evidence: [err?.message ? err.message : 'unknown_error'],
5120
6861
  },
5121
6862
  });
5122
- } catch (_) {
6863
+ } catch {
5123
6864
  // Telemetry is best-effort and must never fail the caller.
5124
6865
  }
5125
6866
  }
@@ -5144,18 +6885,28 @@ async function addContext(){
5144
6885
  }
5145
6886
 
5146
6887
  if (req.method === 'GET' && pathname === '/v1/metrics/real') {
5147
- const bd = require('../../scripts/bot-detector');
6888
+ const bd = require('../../scripts/bot-detection');
5148
6889
  const { FEEDBACK_DIR: metricsDir } = getFeedbackPaths();
5149
6890
  const telemetryPath = path.join(metricsDir, 'telemetry-pings.jsonl');
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 = []; }
6891
+
6892
+ // Cache parsed entries for 60s to avoid re-parsing 72K+ JSON lines per request
6893
+ const cacheKey = '_metricsRealCache';
6894
+ const cacheTTL = 60_000;
6895
+ let entries;
6896
+ if (global[cacheKey] && (Date.now() - global[cacheKey].ts) < cacheTTL) {
6897
+ entries = global[cacheKey].entries;
6898
+ } else {
6899
+ entries = [];
6900
+ try {
6901
+ if (fs.existsSync(telemetryPath)) {
6902
+ entries = fs.readFileSync(telemetryPath, 'utf8')
6903
+ .split('\n').filter(Boolean)
6904
+ .map(l => { try { return JSON.parse(l); } catch(_e) { return null; } })
6905
+ .filter(Boolean);
6906
+ }
6907
+ } catch { entries = []; }
6908
+ global[cacheKey] = { entries, ts: Date.now() };
6909
+ }
5159
6910
 
5160
6911
  const now = Date.now();
5161
6912
  const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000;
@@ -5184,6 +6935,52 @@ async function addContext(){
5184
6935
  byVisitorType[e.visitorType] = (byVisitorType[e.visitorType] || 0) + 1;
5185
6936
  });
5186
6937
 
6938
+ // ---------------------------------------------------------------
6939
+ // Active user analytics: group by installId to distinguish real
6940
+ // users from bots/mirrors. "Active" = performed a meaningful CLI
6941
+ // action (capture, recall, search, gate-check, stats) — not just init.
6942
+ // ---------------------------------------------------------------
6943
+ // Only events that prove a human used the product beyond init.
6944
+ // cli_pro_view excluded: browsing the upgrade page is not "using" the product.
6945
+ // cli_gate_check excluded: runs as subprocess, never fires trackEvent().
6946
+ // cli_search excluded: event name doesn't exist in production telemetry.
6947
+ const ACTIVE_EVENTS = new Set([
6948
+ 'cli_capture', 'cli_recall', 'cli_stats',
6949
+ 'activation_first_rule_promoted',
6950
+ ]);
6951
+ const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
6952
+ const last30 = classified.filter(e => {
6953
+ const ts = e.timestamp || e.receivedAt;
6954
+ return ts && new Date(ts).getTime() > thirtyDaysAgo;
6955
+ });
6956
+
6957
+ // Per-install activity (all-time and recent windows)
6958
+ const activeInstalls7d = new Set();
6959
+ const activeInstalls30d = new Set();
6960
+ const allTimeActiveInstalls = new Set();
6961
+ const uniqueSessions7d = new Set();
6962
+ const uniqueSessions30d = new Set();
6963
+
6964
+ for (const e of classified) {
6965
+ if (!e.installId) continue;
6966
+ const et = e.eventType || '';
6967
+ if (!ACTIVE_EVENTS.has(et)) continue;
6968
+ if (e.visitorType === 'bot' || e.visitorType === 'ci') continue;
6969
+ allTimeActiveInstalls.add(e.installId);
6970
+ }
6971
+ for (const e of recent) {
6972
+ if (!e.installId) continue;
6973
+ if (e.visitorType === 'bot' || e.visitorType === 'ci') continue;
6974
+ if (ACTIVE_EVENTS.has(e.eventType || '')) activeInstalls7d.add(e.installId);
6975
+ if (e.sessionId) uniqueSessions7d.add(e.sessionId);
6976
+ }
6977
+ for (const e of last30) {
6978
+ if (!e.installId) continue;
6979
+ if (e.visitorType === 'bot' || e.visitorType === 'ci') continue;
6980
+ if (ACTIVE_EVENTS.has(e.eventType || '')) activeInstalls30d.add(e.installId);
6981
+ if (e.sessionId) uniqueSessions30d.add(e.sessionId);
6982
+ }
6983
+
5187
6984
  sendJson(res, 200, {
5188
6985
  allTime: {
5189
6986
  total: classified.length,
@@ -5192,6 +6989,12 @@ async function addContext(){
5192
6989
  owner: classified.filter(e => e.visitorType === 'owner').length,
5193
6990
  ci: classified.filter(e => e.visitorType === 'ci').length,
5194
6991
  uniqueInstalls: uniqueInstallIds.size,
6992
+ activeInstalls: allTimeActiveInstalls.size,
6993
+ },
6994
+ last30Days: {
6995
+ uniqueInstalls: new Set(last30.filter(e => e.installId).map(e => e.installId)).size,
6996
+ activeInstalls: activeInstalls30d.size,
6997
+ uniqueSessions: uniqueSessions30d.size,
5195
6998
  },
5196
6999
  last7Days: {
5197
7000
  total: recent.length,
@@ -5200,6 +7003,8 @@ async function addContext(){
5200
7003
  owner: recent.filter(e => e.visitorType === 'owner').length,
5201
7004
  ci: recent.filter(e => e.visitorType === 'ci').length,
5202
7005
  uniqueInstalls: recentInstallIds.size,
7006
+ activeInstalls: activeInstalls7d.size,
7007
+ uniqueSessions: uniqueSessions7d.size,
5203
7008
  },
5204
7009
  byEventType,
5205
7010
  byVisitorType,
@@ -5264,6 +7069,7 @@ async function addContext(){
5264
7069
  source,
5265
7070
  telemetry: { rows: [], truncated: false, totalAfterSince: 0 },
5266
7071
  funnel: { rows: [], truncated: false, totalAfterSince: 0 },
7072
+ journeySummary: null,
5267
7073
  };
5268
7074
 
5269
7075
  function readJsonlSince(p) {
@@ -5293,25 +7099,173 @@ async function addContext(){
5293
7099
  result.telemetry.truncated = all.length > limit;
5294
7100
  }
5295
7101
 
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;
7102
+ if (wantFunnel) {
7103
+ // Use the canonical funnel ledger path from scripts/billing.js so
7104
+ // any test override (THUMBGATE_FUNNEL_LEDGER_PATH / _TEST_FUNNEL_LEDGER_PATH)
7105
+ // resolves correctly. Falls back to the feedback dir's funnel-events.jsonl.
7106
+ let funnelPath;
7107
+ try {
7108
+ const billing = require('../../scripts/billing');
7109
+ funnelPath = (billing._FUNNEL_LEDGER_PATH && billing._FUNNEL_LEDGER_PATH())
7110
+ || path.join(exportDir, 'funnel-events.jsonl');
7111
+ } catch {
7112
+ funnelPath = path.join(exportDir, 'funnel-events.jsonl');
7113
+ }
7114
+ const all = readJsonlSince(funnelPath);
7115
+ result.funnel.totalAfterSince = all.length;
7116
+ result.funnel.rows = all.slice(-limit);
7117
+ result.funnel.truncated = all.length > limit;
7118
+ }
7119
+
7120
+ try {
7121
+ const { buildVisitorJourneySummary } = require('../../scripts/visitor-journey');
7122
+ result.journeySummary = buildVisitorJourneySummary({
7123
+ telemetryRows: wantTelemetry ? result.telemetry.rows : [],
7124
+ funnelRows: wantFunnel ? result.funnel.rows : [],
7125
+ limit: Math.min(limit, 500),
7126
+ });
7127
+ } catch (err) {
7128
+ result.journeySummary = {
7129
+ error: err && err.message ? err.message : 'journey_summary_unavailable',
7130
+ };
7131
+ }
7132
+
7133
+ sendJson(res, 200, result);
7134
+ return;
7135
+ }
7136
+
7137
+ if (req.method === 'OPTIONS' && pathname === '/v1/marketing/install-email') {
7138
+ // CORS preflight for the npm postinstall email-capture endpoint.
7139
+ // The endpoint is called from `npx thumbgate subscribe <email>` so
7140
+ // the CLI gets the right CORS headers if it ever hits the proxy
7141
+ // path; in practice CLI hits the server directly so this is mostly
7142
+ // defense-in-depth.
7143
+ sendPublicBillingPreflight(res);
7144
+ return;
7145
+ }
7146
+
7147
+ if (req.method === 'POST' && pathname === '/v1/marketing/install-email') {
7148
+ // Email-capture wedge for npm installers. The `subscribe` CLI
7149
+ // subcommand POSTs { email, source, installId, cliVersion } here;
7150
+ // we validate the email, persist it to a dedicated capture ledger
7151
+ // (telemetry sanitizer would strip the email as PII), emit a
7152
+ // privacy-clean telemetry ping for funnel attribution, and fire
7153
+ // a Resend newsletter welcome (if RESEND_API_KEY is configured).
7154
+ // No auth required — this is a public opt-in surface.
7155
+ const { FEEDBACK_DIR } = getFeedbackPaths();
7156
+ let body = '';
7157
+ let total = 0;
7158
+ let oversize = false;
7159
+ const MAX_BODY = 2048;
7160
+ req.on('data', (chunk) => {
7161
+ total += chunk.length;
7162
+ if (total > MAX_BODY) {
7163
+ oversize = true;
7164
+ return; // stop appending; let 'end' fire naturally to send 413
7165
+ }
7166
+ body += chunk;
7167
+ });
7168
+ req.on('end', async () => {
7169
+ if (oversize) {
7170
+ sendJson(res, 413, { error: 'payload_too_large' });
7171
+ return;
7172
+ }
7173
+ let parsed;
7174
+ try {
7175
+ parsed = body ? JSON.parse(body) : {};
7176
+ } catch {
7177
+ sendJson(res, 400, { error: 'invalid_json' });
7178
+ return;
7179
+ }
7180
+ const rawEmail = typeof parsed.email === 'string' ? parsed.email.trim() : '';
7181
+ // RFC 5321-bounded email shape — same regex shape as the CLI side
7182
+ // to keep the contract honest.
7183
+ const emailValid = /^[^\s@]{1,64}@[^\s@]{1,255}\.[^\s@]{1,64}$/.test(rawEmail);
7184
+ if (!emailValid) {
7185
+ sendJson(res, 400, { error: 'invalid_email' });
7186
+ return;
7187
+ }
7188
+ const source = typeof parsed.source === 'string' && parsed.source.length <= 64
7189
+ ? parsed.source
7190
+ : 'cli_subscribe';
7191
+ const installId = typeof parsed.installId === 'string' && parsed.installId.length <= 128
7192
+ ? parsed.installId
7193
+ : null;
7194
+ const cliVersion = typeof parsed.cliVersion === 'string' && parsed.cliVersion.length <= 32
7195
+ ? parsed.cliVersion
7196
+ : null;
7197
+
7198
+ // Persist the capture to a dedicated ledger. The standard
7199
+ // telemetry sanitizer in scripts/telemetry-analytics.js
7200
+ // intentionally strips arbitrary fields (PII protection), so
7201
+ // we cannot rely on appendBestEffortTelemetry to preserve the
7202
+ // email. The capture ledger lives alongside other feedback
7203
+ // artifacts and is the source of truth for the marketing drip.
7204
+ try {
7205
+ const fsModule = require('node:fs');
7206
+ const pathModule = require('node:path');
7207
+ const captureDir = pathModule.resolve(FEEDBACK_DIR);
7208
+ fsModule.mkdirSync(captureDir, { recursive: true });
7209
+ const capturePath = pathModule.join(captureDir, 'marketing-install-emails.jsonl');
7210
+ fsModule.appendFileSync(capturePath, JSON.stringify({
7211
+ capturedAt: new Date().toISOString(),
7212
+ email: rawEmail,
7213
+ source,
7214
+ installId,
7215
+ cliVersion,
7216
+ remoteAddr: req.socket?.remoteAddress || null,
7217
+ userAgent: req.headers['user-agent'] || null,
7218
+ }) + '\n', 'utf-8');
7219
+ } catch (err) {
7220
+ // Capture failure is recoverable — we still want to fire the
7221
+ // welcome email and surface the failure to ops via diagnostic.
7222
+ try {
7223
+ const { appendDiagnosticRecord } = require('../../scripts/feedback-loop');
7224
+ appendDiagnosticRecord({
7225
+ source: 'install_email_capture',
7226
+ step: 'capture_persist',
7227
+ context: 'failed to persist install-email capture to ledger',
7228
+ metadata: { error: err?.message || 'unknown' },
7229
+ });
7230
+ } catch {}
7231
+ }
7232
+
7233
+ // Privacy-clean telemetry ping for funnel attribution (no email).
7234
+ appendBestEffortTelemetry(FEEDBACK_DIR, {
7235
+ eventType: 'marketing_install_email_captured',
7236
+ clientType: 'cli',
7237
+ source,
7238
+ installId,
7239
+ cliVersion,
7240
+ utmSource: source,
7241
+ utmMedium: 'npm_postinstall',
7242
+ utmCampaign: 'install_email_capture',
7243
+ }, req.headers, 'marketing_install_email_captured');
7244
+
7245
+ // Fire Resend welcome. If RESEND_API_KEY is unset the mailer
7246
+ // returns { sent: false, reason: 'no_api_key' } and the
7247
+ // capture still succeeds — the operator can drip later from
7248
+ // the captured ledger.
7249
+ let mailerResult = { sent: false, reason: 'not_attempted' };
5301
7250
  try {
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');
7251
+ const { sendNewsletterWelcomeEmail } = require('../../scripts/mailer');
7252
+ mailerResult = await sendNewsletterWelcomeEmail({
7253
+ to: rawEmail,
7254
+ source,
7255
+ installId,
7256
+ cliVersion,
7257
+ });
7258
+ } catch (err) {
7259
+ mailerResult = { sent: false, reason: `mailer_error:${err.message || 'unknown'}` };
5307
7260
  }
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
- }
5313
7261
 
5314
- sendJson(res, 200, result);
7262
+ sendJson(res, 200, {
7263
+ ok: true,
7264
+ captured: true,
7265
+ mailerSent: !!mailerResult.sent,
7266
+ mailerReason: mailerResult.reason || null,
7267
+ });
7268
+ });
5315
7269
  return;
5316
7270
  }
5317
7271
 
@@ -5320,6 +7274,130 @@ async function addContext(){
5320
7274
  return;
5321
7275
  }
5322
7276
 
7277
+ if (req.method === 'OPTIONS' && pathname === '/v1/intake/broker-audit') {
7278
+ sendPublicBillingPreflight(res);
7279
+ return;
7280
+ }
7281
+
7282
+ if (req.method === 'POST' && pathname === '/v1/intake/broker-audit') {
7283
+ const { FEEDBACK_DIR } = getFeedbackPaths();
7284
+ const traceId = createTraceId('broker_audit');
7285
+ const journeyState = resolveJourneyState(req, parsed);
7286
+ const referrerAttribution = buildReferrerAttribution(req);
7287
+ const contentType = String(req.headers['content-type'] || '').toLowerCase();
7288
+ const isFormSubmission = contentType.includes('application/x-www-form-urlencoded');
7289
+ try {
7290
+ const body = isFormSubmission
7291
+ ? await parseFormBody(req, 16 * 1024)
7292
+ : await parseJsonBody(req, 16 * 1024);
7293
+ const lead = appendBrokerAuditLead(FEEDBACK_DIR, {
7294
+ ...body,
7295
+ traceId: body.traceId || traceId,
7296
+ acquisitionId: body.acquisitionId || journeyState.acquisitionId,
7297
+ visitorId: body.visitorId || journeyState.visitorId,
7298
+ sessionId: body.sessionId || journeyState.sessionId,
7299
+ page: body.page || referrerAttribution.page || '/broker-audit',
7300
+ landingPath: body.landingPath || referrerAttribution.landingPath || '/broker-audit',
7301
+ ctaId: body.ctaId || 'broker_audit_form',
7302
+ ctaPlacement: body.ctaPlacement || 'broker_audit',
7303
+ source: body.source || body.utmSource || referrerAttribution.source || 'website',
7304
+ utmSource: body.utmSource || body.source || referrerAttribution.utmSource || 'website',
7305
+ utmMedium: body.utmMedium || referrerAttribution.utmMedium || 'broker_audit',
7306
+ utmCampaign: body.utmCampaign || referrerAttribution.utmCampaign || 'broker_audit_free',
7307
+ utmContent: body.utmContent || referrerAttribution.utmContent || null,
7308
+ utmTerm: body.utmTerm || referrerAttribution.utmTerm || null,
7309
+ referrerHost: body.referrerHost || referrerAttribution.referrerHost || null,
7310
+ referrer: body.referrer || referrerAttribution.referrer || null,
7311
+ });
7312
+
7313
+ appendBestEffortTelemetry(FEEDBACK_DIR, {
7314
+ eventType: 'broker_audit_lead_submitted',
7315
+ clientType: 'web',
7316
+ traceId: lead.attribution.traceId,
7317
+ acquisitionId: lead.attribution.acquisitionId,
7318
+ visitorId: lead.attribution.visitorId,
7319
+ sessionId: lead.attribution.sessionId,
7320
+ source: lead.attribution.source,
7321
+ utmSource: lead.attribution.utmSource,
7322
+ utmMedium: lead.attribution.utmMedium,
7323
+ utmCampaign: lead.attribution.utmCampaign,
7324
+ utmContent: lead.attribution.utmContent,
7325
+ utmTerm: lead.attribution.utmTerm,
7326
+ ctaId: lead.attribution.ctaId,
7327
+ ctaPlacement: lead.attribution.ctaPlacement,
7328
+ page: lead.attribution.page,
7329
+ landingPath: lead.attribution.landingPath,
7330
+ referrerHost: lead.attribution.referrerHost,
7331
+ referrer: lead.attribution.referrer,
7332
+ }, req.headers, 'broker_audit_lead_submitted');
7333
+
7334
+ if (isFormSubmission && !wantsJson(req, parsed)) {
7335
+ sendHtml(
7336
+ res,
7337
+ 201,
7338
+ renderBrokerAuditIntakeResultPage(hostedConfig, {
7339
+ title: 'Broker audit request received',
7340
+ detail: 'Your free broker lead-flow audit request is captured. The next step is the 1-page PDF with the specific lead leaks.',
7341
+ leadId: lead.leadId,
7342
+ }),
7343
+ journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}
7344
+ );
7345
+ return;
7346
+ }
7347
+
7348
+ sendJson(res, 201, {
7349
+ ok: true,
7350
+ leadId: lead.leadId,
7351
+ status: lead.status,
7352
+ offer: lead.offer,
7353
+ nextStep: 'deliver_1_page_pdf',
7354
+ }, {
7355
+ ...getPublicBillingHeaders(lead.attribution.traceId),
7356
+ ...(journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}),
7357
+ });
7358
+ } catch (err) {
7359
+ appendBestEffortTelemetry(FEEDBACK_DIR, {
7360
+ eventType: 'broker_audit_lead_failed',
7361
+ clientType: 'web',
7362
+ traceId,
7363
+ acquisitionId: journeyState.acquisitionId,
7364
+ visitorId: journeyState.visitorId,
7365
+ sessionId: journeyState.sessionId,
7366
+ source: referrerAttribution.source || 'website',
7367
+ utmSource: referrerAttribution.utmSource || 'website',
7368
+ utmMedium: referrerAttribution.utmMedium || 'broker_audit',
7369
+ utmCampaign: referrerAttribution.utmCampaign || 'broker_audit_free',
7370
+ ctaId: 'broker_audit_form',
7371
+ ctaPlacement: 'broker_audit',
7372
+ page: referrerAttribution.page || '/broker-audit',
7373
+ landingPath: referrerAttribution.landingPath || '/broker-audit',
7374
+ referrerHost: referrerAttribution.referrerHost,
7375
+ referrer: referrerAttribution.referrer,
7376
+ failureCode: err?.message ? err.message : 'broker_audit_lead_failed',
7377
+ httpStatus: err && err.statusCode ? err.statusCode : null,
7378
+ }, req.headers, 'broker_audit_lead_failed');
7379
+ if (isFormSubmission && !wantsJson(req, parsed)) {
7380
+ sendHtml(
7381
+ res,
7382
+ err.statusCode || 500,
7383
+ renderBrokerAuditIntakeResultPage(hostedConfig, {
7384
+ title: 'Broker audit request failed',
7385
+ detail: err.message || 'Unable to capture broker audit request.',
7386
+ }),
7387
+ journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}
7388
+ );
7389
+ return;
7390
+ }
7391
+ sendProblem(res, {
7392
+ type: !err.statusCode || err.statusCode >= 500 ? PROBLEM_TYPES.INTERNAL : PROBLEM_TYPES.BAD_REQUEST,
7393
+ title: !err.statusCode || err.statusCode >= 500 ? 'Internal Server Error' : 'Request Error',
7394
+ status: err.statusCode || 500,
7395
+ detail: err.message || 'Unable to capture broker audit request.',
7396
+ }, getPublicBillingHeaders(traceId));
7397
+ }
7398
+ return;
7399
+ }
7400
+
5323
7401
  if (req.method === 'POST' && pathname === '/v1/intake/workflow-sprint') {
5324
7402
  const { FEEDBACK_DIR } = getFeedbackPaths();
5325
7403
  const traceId = createTraceId('sprint_intake');
@@ -5467,7 +7545,19 @@ async function addContext(){
5467
7545
 
5468
7546
  // Public OpenAPI spec — no auth required (needed for ChatGPT GPT Store import)
5469
7547
  if (isGetLikeRequest && (pathname === '/openapi.json' || pathname === '/openapi.yaml')) {
5470
- const specPath = path.join(__dirname, '../../adapters/chatgpt/openapi.yaml');
7548
+ const specPath = [
7549
+ path.join(__dirname, '../../openapi/openapi.yaml'),
7550
+ path.join(__dirname, '../../adapters/chatgpt/openapi.yaml'),
7551
+ ].find((candidate) => fs.existsSync(candidate));
7552
+ if (!specPath) {
7553
+ sendProblem(res, {
7554
+ type: PROBLEM_TYPES.NOT_FOUND,
7555
+ title: 'Not Found',
7556
+ status: 404,
7557
+ detail: 'OpenAPI spec not found.',
7558
+ });
7559
+ return;
7560
+ }
5471
7561
  try {
5472
7562
  const yaml = renderOpenApiYamlForRequest(fs.readFileSync(specPath, 'utf8'), req);
5473
7563
  if (pathname === '/openapi.yaml') {
@@ -5543,38 +7633,14 @@ async function addContext(){
5543
7633
  // surface that was missing: thumbgate.ai had no /case-studies, so visitors
5544
7634
  // landed on CLI install commands without seeing whether anyone actually
5545
7635
  // got value. First entry is the Aiventyx Teams listing integration: real
5546
- // third-party CTR signal (5/8 clicks before the /go/teams fix, end-to-end
5547
- // verified after).
7636
+ // third-party CTR signal (5/8 clicks before the /go/teams fix, now routed
7637
+ // through team intake so scope happens before checkout).
5548
7638
  if (isGetLikeRequest && pathname === '/case-studies') {
5549
7639
  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>
5550
7640
  <h1>Case Studies</h1>
5551
7641
  <p class="lede">Real integrations. No fabricated logos, no aspirational numbers — every claim below is reproducible.</p>
5552
7642
 
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>
7643
+ <p><em>New case studies for individual Pro operators coming soon.</em></p>
5578
7644
 
5579
7645
  <footer>
5580
7646
  <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>
@@ -5593,87 +7659,66 @@ async function addContext(){
5593
7659
  // Sprint (proof-pack, sales-led) → Pro (self-serve recurring) → Team
5594
7660
  // (after qualification). Every paid CTA across the site should funnel
5595
7661
  // here OR directly into Stripe checkout — never a different price.
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>
7662
+ if (isGetLikeRequest && (pathname === '/pricing' || pathname === '/pricing.html')) {
7663
+ try {
7664
+ servePublicMarketingPage({
7665
+ req,
7666
+ res,
7667
+ parsed,
7668
+ hostedConfig,
7669
+ isHeadRequest,
7670
+ renderHtml: loadPricingPageHtml,
7671
+ extraTelemetry: {
7672
+ pageType: 'pricing',
7673
+ },
7674
+ });
7675
+ } catch (err) {
7676
+ sendText(res, 500, err.message || 'Pricing page unavailable');
7677
+ }
7678
+ return;
7679
+ }
5616
7680
 
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>
5631
7681
 
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>
7682
+ // Remote MCP connector documentation — the `resource_documentation` target
7683
+ // advertised by /.well-known/oauth-protected-resource. The Claude Connectors
7684
+ // Directory requires this URL to resolve (200) for submission review.
7685
+ if (isGetLikeRequest && (pathname === '/docs/connectors' || pathname === '/docs/connectors/')) {
7686
+ const mcpUrl = buildPublicUrl(hostedConfig, '/mcp');
7687
+ const cardUrl = buildPublicUrl(hostedConfig, '/.well-known/mcp/server-card.json');
7688
+ const prmUrl = buildPublicUrl(hostedConfig, '/.well-known/oauth-protected-resource');
7689
+ 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>
7690
+ <h1>ThumbGate — Remote MCP Connector</h1>
7691
+ <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>
7692
+
7693
+ <h2>Connect URL</h2>
7694
+ <pre>${esc(mcpUrl)}</pre>
7695
+ <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>
7696
+
7697
+ <h2>Authorization (OAuth 2.1 + PKCE)</h2>
7698
+ <ol>
7699
+ <li>Claude reads the <code>resource</code> and <code>authorization_servers</code> from the protected-resource metadata.</li>
7700
+ <li>It runs the standard OAuth 2.1 authorization-code flow with PKCE (<code>S256</code> only — <code>plain</code> is rejected).</li>
7701
+ <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>
7702
+ <li>The bearer token is then sent on the <code>Authorization</code> header for every <code>tools/call</code>.</li>
7703
+ </ol>
7704
+
7705
+ <h2>Available tools</h2>
7706
+ <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>
5637
7707
  <ul>
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>
7708
+ <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>
7709
+ <li><strong>Context engineering</strong> <code>construct_context_pack</code>, <code>evaluate_context_pack</code>, <code>unified_context</code>, <code>recall</code>.</li>
7710
+ <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>
7711
+ <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>
5642
7712
  </ul>
5643
- <a class="cta cta-secondary" href="/go/teams?utm_source=pricing">Start Team →</a>
5644
- </div>
5645
7713
 
5646
- </div>
7714
+ <h2>Reviewer credential (read-only)</h2>
7715
+ <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>
5647
7716
 
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>
7717
+ <h2>Source &amp; contact</h2>
7718
+ <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>
5669
7719
 
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
- });
7720
+ <footer><a href="/">ThumbGate</a> · <a href="/support">Support</a> · <a href="/privacy">Privacy</a> · <a href="/terms">Terms</a></footer>
7721
+ </body></html>`, {}, { headOnly: isHeadRequest });
5677
7722
  return;
5678
7723
  }
5679
7724
 
@@ -5916,6 +7961,14 @@ async function addContext(){
5916
7961
  return;
5917
7962
  }
5918
7963
 
7964
+ // Catch non-API GET requests that didn't match any public page route above.
7965
+ // Without this, /about, /docs, /demo etc. fall through to the API auth gate
7966
+ // and return a raw JSON 401 instead of a user-friendly 404.
7967
+ if (isGetLikeRequest && !pathname.startsWith('/v1/') && !pathname.startsWith('/api/')) {
7968
+ 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>`);
7969
+ return;
7970
+ }
7971
+
5919
7972
  // Operator key is allowed to bypass the general admin gate for its dedicated endpoint
5920
7973
  const _reqToken = extractApiKey(req);
5921
7974
  const isOperatorBillingRequest = Boolean(expectedOperatorKey)
@@ -5941,7 +7994,161 @@ async function addContext(){
5941
7994
 
5942
7995
  try {
5943
7996
  if (req.method === 'GET' && pathname === '/v1/feedback/stats') {
5944
- sendJson(res, 200, analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH));
7997
+ const stats = shouldAggregateFeedback()
7998
+ ? computeAggregateFeedbackStats({ feedbackDir: requestFeedbackPaths.FEEDBACK_DIR })
7999
+ : analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH);
8000
+ try {
8001
+ const { getStatuslineMeta } = require('../../scripts/statusline-meta');
8002
+ const meta = getStatuslineMeta({ env: process.env });
8003
+ stats.tier = meta.tier;
8004
+ } catch (error) {
8005
+ debugApiFallback('statusline meta unavailable', error);
8006
+ stats.tier = 'Pro';
8007
+ }
8008
+
8009
+ const projectChatSettings = readProjectChatSettings(req, parsed);
8010
+
8011
+ stats.geminiConfigured = Boolean(
8012
+ projectChatSettings.geminiKey ||
8013
+ process.env.GEMINI_API_KEY ||
8014
+ process.env.THUMBGATE_GEMINI_API_KEY ||
8015
+ process.env.GOOGLE_API_KEY
8016
+ );
8017
+ stats.perplexityConfigured = Boolean(
8018
+ projectChatSettings.perplexityKey ||
8019
+ process.env.PERPLEXITY_API_KEY ||
8020
+ process.env.THUMBGATE_PERPLEXITY_API_KEY
8021
+ );
8022
+ stats.geminiValidatedAt = projectChatSettings.geminiValidatedAt;
8023
+ stats.geminiKeyStatus = 'none';
8024
+ if (projectChatSettings.geminiKey) {
8025
+ stats.geminiKeyStatus = 'present';
8026
+ }
8027
+ if (projectChatSettings.geminiValidatedAt) {
8028
+ stats.geminiKeyStatus = 'validated';
8029
+ }
8030
+ stats.hybridInferenceAvailable = !!(stats.geminiConfigured || stats.perplexityConfigured);
8031
+ stats.localLlmConfigured = Boolean(process.env.THUMBGATE_LOCAL_LLM_ENDPOINT);
8032
+ stats.localLlmEndpoint = process.env.THUMBGATE_LOCAL_LLM_ENDPOINT || null;
8033
+ stats.localLlmModel = process.env.THUMBGATE_LOCAL_LLM_MODEL || null;
8034
+ sendJson(res, 200, stats);
8035
+ return;
8036
+ }
8037
+
8038
+ // Chat with your data — LOCAL-FIRST. Powers the dashboard "Chat with your
8039
+ // data" panel. Factual/metric questions (gates, blocks, feedback, token
8040
+ // savings, team) are answered DETERMINISTICALLY from this install's own
8041
+ // dashboard data — no cloud, no LLM, no API key (the local-first thesis).
8042
+ // Only open-ended/qualitative questions fall through to lesson retrieval +
8043
+ // the user's configured LOCAL model (a BYO cloud key is optional, not required).
8044
+ if (req.method === 'POST' && pathname === '/v1/chat') {
8045
+ const body = await parseJsonBody(req);
8046
+ const question = body.question || body.q || body.message;
8047
+ const normalizedChatPrompt = normalizeEnterpriseChatPrompt(question);
8048
+ const chatFeedbackDir = requestFeedbackPaths.FEEDBACK_DIR;
8049
+
8050
+ // Local-first: factual/metric questions (gates, blocks, feedback, cost,
8051
+ // team) are answered deterministically from local data — no cloud/LLM/key.
8052
+ if (
8053
+ normalizedChatPrompt
8054
+ && !containsUnsafeEnterpriseChatInput(normalizedChatPrompt)
8055
+ && classifyEnterpriseChatTopic(normalizedChatPrompt) !== 'overview'
8056
+ && await trySendLocalDashboardChat(res, parsed, chatFeedbackDir, normalizedChatPrompt)
8057
+ ) {
8058
+ return;
8059
+ }
8060
+
8061
+ const { answerDataQuestion } = require('../../scripts/dashboard-chat');
8062
+
8063
+ const projectChatSettings = readProjectChatSettings(req, parsed);
8064
+
8065
+ const result = await answerDataQuestion(question, {
8066
+ feedbackDir: chatFeedbackDir,
8067
+ model: typeof body.model === 'string' ? body.model : undefined,
8068
+ 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 || '',
8069
+ localEndpoint: projectChatSettings.localEndpoint || process.env.THUMBGATE_LOCAL_LLM_ENDPOINT || '',
8070
+ localModel: projectChatSettings.localModel || process.env.THUMBGATE_LOCAL_LLM_MODEL || '',
8071
+ });
8072
+
8073
+ // Local-first guarantee: if no model is configured, never hard-fail with
8074
+ // "no_api_key" — fall back to a deterministic local answer. No cloud required.
8075
+ if (
8076
+ !result.ok
8077
+ && (result.error === 'no_api_key' || result.error === 'no_model')
8078
+ && await trySendLocalDashboardChat(res, parsed, chatFeedbackDir, normalizedChatPrompt || question, '(Connect a local model via THUMBGATE_LOCAL_LLM_ENDPOINT for open-ended analysis over your lessons.)')
8079
+ ) {
8080
+ return;
8081
+ }
8082
+
8083
+ sendJson(res, result.ok ? 200 : (result.error === 'no_api_key' ? 503 : 400), result);
8084
+ return;
8085
+ }
8086
+
8087
+ // Save Gemini API key from the dashboard UI
8088
+ if (req.method === 'POST' && pathname === '/v1/settings/gemini-key') {
8089
+ const body = await parseJsonBody(req);
8090
+ const key = String(body.key || '').trim();
8091
+ if (!key) {
8092
+ sendJson(res, 400, { ok: false, error: 'missing_key', message: 'No API key provided.' });
8093
+ return;
8094
+ }
8095
+
8096
+ // Validate the candidate key using the *exact* same code path as /v1/chat
8097
+ // (project-scoped .env read + RAG + Gemini call). This prevents saving a
8098
+ // key that will later produce the confusing "API key not valid" error in chat.
8099
+ let validation;
8100
+ try {
8101
+ const { answerDataQuestion } = require('../../scripts/dashboard-chat');
8102
+ validation = await answerDataQuestion('Reply with the single word: PONG', {
8103
+ feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
8104
+ apiKey: key,
8105
+ });
8106
+ } catch (e) {
8107
+ validation = { ok: false, error: 'validation_exception', message: String(e?.message || e) };
8108
+ }
8109
+
8110
+ if (!validation.ok) {
8111
+ const detail = validation.error === 'gemini_error'
8112
+ ? (validation.message || 'Gemini rejected the key')
8113
+ : (validation.message || validation.error || 'unknown error');
8114
+ sendJson(res, 400, {
8115
+ ok: false,
8116
+ error: 'invalid_key',
8117
+ 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.'
8118
+ });
8119
+ return;
8120
+ }
8121
+
8122
+ try {
8123
+ const projectDir = resolveRequestProjectDir(req, parsed);
8124
+ const envPath = path.join(projectDir, '.env');
8125
+ let content = '';
8126
+ if (fs.existsSync(envPath)) {
8127
+ content = fs.readFileSync(envPath, 'utf8');
8128
+ }
8129
+ const regex = /^GEMINI_API_KEY=.*$/m;
8130
+ if (regex.test(content)) {
8131
+ content = content.replace(regex, `GEMINI_API_KEY=${key}`);
8132
+ } else {
8133
+ content = content.trim() + `\nGEMINI_API_KEY=${key}\n`;
8134
+ }
8135
+ fs.writeFileSync(envPath, content, 'utf8');
8136
+ // Also set it in the current process so it takes effect immediately without restart
8137
+ process.env.GEMINI_API_KEY = key;
8138
+ // Persist validation success for reliable "configured" status in stats/hints
8139
+ try {
8140
+ const statusPath = path.join(projectDir, '.gemini-validated.json');
8141
+ fs.writeFileSync(statusPath, JSON.stringify({
8142
+ validatedAt: new Date().toISOString(),
8143
+ validatedBy: 'dashboard-save'
8144
+ }, null, 2));
8145
+ } catch (error) {
8146
+ debugApiFallback('Gemini validation marker unavailable', error);
8147
+ }
8148
+ sendJson(res, 200, { ok: true, message: 'Key saved and validated.' });
8149
+ } catch (e) {
8150
+ sendJson(res, 500, { ok: false, error: 'fs_error', message: 'Failed to write to .env file: ' + e.message });
8151
+ }
5945
8152
  return;
5946
8153
  }
5947
8154
 
@@ -6000,6 +8207,28 @@ async function addContext(){
6000
8207
  return;
6001
8208
  }
6002
8209
 
8210
+ if (req.method === 'GET' && (
8211
+ pathname === '/v1/enterprise/data-chat/status'
8212
+ || pathname === '/v1/enterprise/dialogflow/status'
8213
+ )) {
8214
+ sendJson(res, 200, buildEnterpriseDataChatStatus());
8215
+ return;
8216
+ }
8217
+
8218
+ if (req.method === 'POST' && (
8219
+ pathname === '/v1/enterprise/data-chat/chat'
8220
+ || pathname === '/v1/enterprise/dialogflow/chat'
8221
+ )) {
8222
+ const body = await parseJsonBody(req, 16 * 1024);
8223
+ const result = await answerEnterpriseDataChat({
8224
+ prompt: body.prompt || body.message || body.query,
8225
+ feedbackDir: requestFeedbackDir,
8226
+ parsed,
8227
+ });
8228
+ sendJson(res, 200, result);
8229
+ return;
8230
+ }
8231
+
6003
8232
  if (req.method === 'GET' && pathname === '/v1/intents/catalog') {
6004
8233
  const mcpProfile = parsed.searchParams.get('mcpProfile') || undefined;
6005
8234
  const bundleId = parsed.searchParams.get('bundleId') || undefined;
@@ -6025,6 +8254,7 @@ async function addContext(){
6025
8254
  bundleId: body.bundleId,
6026
8255
  partnerProfile: body.partnerProfile,
6027
8256
  delegationMode: body.delegationMode,
8257
+ enforcePlanQuality: body.enforcePlanQuality === true,
6028
8258
  approved: body.approved === true,
6029
8259
  repoPath: body.repoPath,
6030
8260
  });
@@ -6283,6 +8513,7 @@ async function addContext(){
6283
8513
  summary: body.summary,
6284
8514
  allowedPaths: body.allowedPaths,
6285
8515
  protectedPaths: body.protectedPaths,
8516
+ workflowContract: body.workflowContract,
6286
8517
  repoPath: body.repoPath,
6287
8518
  localOnly: body.localOnly === true,
6288
8519
  clear: body.clear === true,
@@ -6360,6 +8591,11 @@ async function addContext(){
6360
8591
  }
6361
8592
 
6362
8593
  if (req.method === 'GET' && pathname === '/v1/lessons/search') {
8594
+ const lessonLimit = checkLimit('search_lessons');
8595
+ if (!lessonLimit.allowed) {
8596
+ sendJson(res, 429, { error: 'Free tier: lesson search requires Pro. Upgrade at /pricing', code: 'PRO_REQUIRED' });
8597
+ return;
8598
+ }
6363
8599
  const query = parsed.searchParams.get('q') || parsed.searchParams.get('query') || '';
6364
8600
  const limit = Number(parsed.searchParams.get('limit') || 10);
6365
8601
  const category = parsed.searchParams.get('category') || '';
@@ -6379,17 +8615,24 @@ async function addContext(){
6379
8615
  }
6380
8616
 
6381
8617
  if (req.method === 'GET' && pathname === '/v1/search') {
8618
+ const searchLimit = checkLimit('search_thumbgate');
8619
+ if (!searchLimit.allowed) {
8620
+ sendJson(res, 429, { error: 'Free tier: search requires Pro. Upgrade at /pricing', code: 'PRO_REQUIRED' });
8621
+ return;
8622
+ }
6382
8623
  const query = parsed.searchParams.get('q') || parsed.searchParams.get('query') || '';
6383
8624
  const limit = Number(parsed.searchParams.get('limit') || 10);
6384
8625
  const source = parsed.searchParams.get('source') || 'all';
6385
8626
  const signal = parsed.searchParams.get('signal') || null;
6386
8627
  let results;
6387
8628
  try {
8629
+ const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
6388
8630
  results = searchThumbgate({
6389
8631
  query,
6390
8632
  limit: Number.isFinite(limit) ? limit : 10,
6391
8633
  source,
6392
8634
  signal,
8635
+ feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
6393
8636
  });
6394
8637
  } catch (err) {
6395
8638
  throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
@@ -6399,14 +8642,21 @@ async function addContext(){
6399
8642
  }
6400
8643
 
6401
8644
  if (req.method === 'POST' && pathname === '/v1/search') {
8645
+ const searchLimit = checkLimit('search_thumbgate');
8646
+ if (!searchLimit.allowed) {
8647
+ sendJson(res, 429, { error: 'Free tier: search requires Pro. Upgrade at /pricing', code: 'PRO_REQUIRED' });
8648
+ return;
8649
+ }
6402
8650
  const body = await parseJsonBody(req);
6403
8651
  let results;
6404
8652
  try {
8653
+ const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
6405
8654
  results = searchThumbgate({
6406
8655
  query: body.query || body.q || '',
6407
8656
  limit: body.limit,
6408
8657
  source: body.source,
6409
8658
  signal: body.signal,
8659
+ feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
6410
8660
  });
6411
8661
  } catch (err) {
6412
8662
  throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
@@ -6432,11 +8682,14 @@ async function addContext(){
6432
8682
  {
6433
8683
  const documentId = normalizeDocumentIdFromPath(pathname);
6434
8684
  if (req.method === 'GET' && documentId) {
8685
+ if (!/^[a-zA-Z0-9-_]+$/.test(documentId)) {
8686
+ throw createHttpError(400, 'Invalid document ID format');
8687
+ }
6435
8688
  const document = readImportedDocument(documentId, {
6436
8689
  feedbackDir: requestFeedbackDir,
6437
8690
  });
6438
8691
  if (!document) {
6439
- throw createHttpError(404, `Imported document not found: ${documentId}`);
8692
+ throw createHttpError(404, `Imported document not found: ${escapeHtml(documentId)}`);
6440
8693
  }
6441
8694
  sendJson(res, 200, { document });
6442
8695
  return;
@@ -6463,7 +8716,7 @@ async function addContext(){
6463
8716
  feedbackDir: getSafeDataDir(),
6464
8717
  limit: 10,
6465
8718
  });
6466
- } catch (_) { /* best-effort — conversation window is optional */ }
8719
+ } catch { /* best-effort — conversation window is optional */ }
6467
8720
  }
6468
8721
  const result = captureFeedback({
6469
8722
  signal: body.signal,
@@ -6482,6 +8735,18 @@ async function addContext(){
6482
8735
  tags: extractTags(body.tags),
6483
8736
  skill: body.skill,
6484
8737
  });
8738
+ const actionIntegration = inferActionIntegration(body, req.headers);
8739
+ appendBestEffortTelemetry(requestFeedbackDir, {
8740
+ eventType: chatgptActionEventType(actionIntegration, 'capture_feedback'),
8741
+ clientType: 'api',
8742
+ source: actionIntegration,
8743
+ integration: actionIntegration,
8744
+ actionOperation: 'captureFeedback',
8745
+ endpoint: '/v1/feedback/capture',
8746
+ actionStatus: result.accepted ? 'accepted' : 'clarification_required',
8747
+ accepted: Boolean(result.accepted),
8748
+ failureCode: result.accepted ? null : 'clarification_required',
8749
+ }, req.headers, 'api_action_capture_feedback');
6485
8750
  if (result?.accepted) {
6486
8751
  // Fan out to any connected dashboard clients so they re-render
6487
8752
  // without polling. Non-sensitive summary only (no chat history,
@@ -6562,6 +8827,11 @@ async function addContext(){
6562
8827
  }
6563
8828
 
6564
8829
  if (req.method === 'POST' && pathname === '/v1/dpo/export') {
8830
+ const dpoLimit = checkLimit('export_dpo');
8831
+ if (!dpoLimit.allowed) {
8832
+ sendJson(res, 429, { error: 'Free tier: DPO export requires Pro. Upgrade at /pricing', code: 'PRO_REQUIRED' });
8833
+ return;
8834
+ }
6565
8835
  const body = await parseJsonBody(req);
6566
8836
  const paths = resolveDpoExportPaths(body, {
6567
8837
  safeDataDir: requestSafeDataDir,
@@ -7139,6 +9409,40 @@ async function addContext(){
7139
9409
  return;
7140
9410
  }
7141
9411
 
9412
+ // GET /v1/dashboard/ai-inventory -- Enterprise AI inventory evidence
9413
+ if (req.method === 'GET' && pathname === '/v1/dashboard/ai-inventory') {
9414
+ try {
9415
+ const {
9416
+ scanAiComponents,
9417
+ buildCycloneDxMlBom,
9418
+ } = require('../../scripts/ai-component-inventory');
9419
+ const requestedRoot = parsed.searchParams.get('root');
9420
+ const serverRoot = process.cwd();
9421
+ const rootDir = requestedRoot ? path.resolve(requestedRoot) : serverRoot;
9422
+ const rootRel = path.relative(serverRoot, rootDir);
9423
+ if (rootRel.startsWith('..') || path.isAbsolute(rootRel)) {
9424
+ sendJson(res, 400, {
9425
+ error: 'ai_inventory_root_out_of_scope',
9426
+ message: 'Dashboard AI inventory root must stay within the server working directory. Use the CLI for explicit cross-project scans.',
9427
+ });
9428
+ return;
9429
+ }
9430
+ const inventory = scanAiComponents({
9431
+ rootDir,
9432
+ maxFiles: parsed.searchParams.get('maxFiles') ? Number(parsed.searchParams.get('maxFiles')) : undefined,
9433
+ includeSnippets: parsed.searchParams.get('snippets') !== '0',
9434
+ });
9435
+ const format = String(parsed.searchParams.get('format') || 'json').toLowerCase();
9436
+ sendJson(res, 200, format === 'cyclonedx' ? buildCycloneDxMlBom(inventory, { version: pkg.version }) : inventory);
9437
+ } catch (err) {
9438
+ sendJson(res, 500, {
9439
+ error: 'ai_inventory_failed',
9440
+ message: err?.message || 'Unable to scan AI component inventory.',
9441
+ });
9442
+ }
9443
+ return;
9444
+ }
9445
+
7142
9446
  // GET /v1/dashboard/review-state -- incremental review baseline and deltas
7143
9447
  if (req.method === 'GET' && pathname === '/v1/dashboard/review-state') {
7144
9448
  const reviewState = readDashboardReviewState(requestFeedbackDir);
@@ -7155,7 +9459,12 @@ async function addContext(){
7155
9459
 
7156
9460
  // POST /v1/dashboard/review-state -- mark current dashboard state as reviewed
7157
9461
  if (req.method === 'POST' && pathname === '/v1/dashboard/review-state') {
9462
+ const body = await parseJsonBody(req);
7158
9463
  const snapshot = buildReviewSnapshot(requestFeedbackDir);
9464
+ // Override snapshot timestamp with client-provided one if available
9465
+ if (body?.reviewedAt) {
9466
+ snapshot.reviewedAt = body.reviewedAt;
9467
+ }
7159
9468
  writeDashboardReviewState(requestFeedbackDir, snapshot);
7160
9469
  const data = generateDashboard(requestFeedbackDir, {
7161
9470
  reviewBaseline: snapshot,
@@ -7287,6 +9596,18 @@ async function addContext(){
7287
9596
  });
7288
9597
  report.actionId = evaluation.actionId;
7289
9598
  if (report.decisionControl) report.decisionControl.actionId = evaluation.actionId;
9599
+ const actionIntegration = inferActionIntegration(body, req.headers);
9600
+ appendBestEffortTelemetry(requestFeedbackDir, {
9601
+ eventType: chatgptActionEventType(actionIntegration, 'evaluate_decision'),
9602
+ clientType: 'api',
9603
+ source: actionIntegration,
9604
+ integration: actionIntegration,
9605
+ actionOperation: 'evaluateDecision',
9606
+ endpoint: '/v1/decisions/evaluate',
9607
+ actionStatus: 'accepted',
9608
+ accepted: true,
9609
+ decisionMode: report.decisionControl && report.decisionControl.executionMode,
9610
+ }, req.headers, 'api_action_evaluate_decision');
7290
9611
  sendJson(res, 200, report);
7291
9612
  return;
7292
9613
  }
@@ -7391,6 +9712,7 @@ function startServer({ port, host } = {}) {
7391
9712
  const listenPort = Number(port ?? process.env.PORT ?? 8787);
7392
9713
  const listenHost = String(host ?? process.env.HOST ?? '0.0.0.0').trim() || '0.0.0.0';
7393
9714
  const server = createApiServer();
9715
+ registerGracefulShutdown(server);
7394
9716
  return new Promise((resolve) => {
7395
9717
  server.listen(listenPort, listenHost, () => {
7396
9718
  const address = server.address();
@@ -7406,6 +9728,39 @@ function startServer({ port, host } = {}) {
7406
9728
  });
7407
9729
  }
7408
9730
 
9731
+ // Railway / Cloud Run / Kubernetes deploy rotations send SIGTERM to swap
9732
+ // containers. Without a handler, Node exits immediately — in-flight requests
9733
+ // are killed and the orchestrator may mark the container as "crashed" (instead
9734
+ // of "gracefully stopped"), wasting its restart-policy budget on a healthy
9735
+ // shutdown. Drain HTTP, give a deadline, then force-exit if anything hangs.
9736
+ function registerGracefulShutdown(server, { gracePeriodMs = 25_000 } = {}) {
9737
+ if (server[GRACEFUL_SHUTDOWN_KEY]) return;
9738
+ server[GRACEFUL_SHUTDOWN_KEY] = true;
9739
+ let shuttingDown = false;
9740
+ const stop = (signal) => {
9741
+ if (shuttingDown) return;
9742
+ shuttingDown = true;
9743
+ console.log(`[shutdown] ${signal} received — draining connections (deadline ${gracePeriodMs}ms)`);
9744
+ const forceTimer = setTimeout(() => {
9745
+ console.error('[shutdown] grace period elapsed — forcing exit');
9746
+ process.exit(1);
9747
+ }, gracePeriodMs);
9748
+ if (typeof forceTimer.unref === 'function') forceTimer.unref();
9749
+ server.close((err) => {
9750
+ if (err) {
9751
+ console.error('[shutdown] server.close error:', err.message);
9752
+ process.exit(1);
9753
+ }
9754
+ console.log('[shutdown] drained cleanly');
9755
+ process.exit(0);
9756
+ });
9757
+ };
9758
+ process.on('SIGTERM', () => stop('SIGTERM'));
9759
+ process.on('SIGINT', () => stop('SIGINT'));
9760
+ }
9761
+
9762
+ const GRACEFUL_SHUTDOWN_KEY = Symbol.for('thumbgate.gracefulShutdownRegistered');
9763
+
7409
9764
  module.exports = {
7410
9765
  createApiServer,
7411
9766
  startServer,
@@ -7423,6 +9778,15 @@ module.exports = {
7423
9778
  renderPackagedLessonsHtml,
7424
9779
  readOptionalPublicTemplate,
7425
9780
  resolveLocalPageBootstrap,
9781
+ getPublicMcpTools,
9782
+ getServerCardTools,
9783
+ buildEnterpriseDataChatStatus,
9784
+ buildEnterpriseDialogflowStatus,
9785
+ buildEnterpriseChatAnswer,
9786
+ answerEnterpriseDataChat,
9787
+ answerEnterpriseDialogflowChat,
9788
+ buildLossAnalyticsResponse,
9789
+ sanitizeHtmlUnsafeJsonValue,
7426
9790
  },
7427
9791
  };
7428
9792