thumbgate 1.29.2 → 1.31.0

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 (111) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +54 -16
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/forge/forge.yaml +3 -3
  6. package/adapters/mcp/server-stdio.js +143 -14
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +154 -36
  10. package/config/evals/generation-quality-golden.json +95 -0
  11. package/config/evals/rag-answer-quality-golden.json +91 -0
  12. package/config/evals/retrieval-hybrid-ablation.json +66 -0
  13. package/config/evals/retrieval-ranking-golden.json +522 -0
  14. package/config/gates/default.json +217 -50
  15. package/config/mcp-allowlists.json +215 -185
  16. package/config/model-tiers.json +7 -2
  17. package/config/post-deploy-marketing-pages.json +26 -1
  18. package/glama.json +6 -0
  19. package/package.json +94 -11
  20. package/public/architecture.html +130 -0
  21. package/public/assets/diagrams/agent-integration.png +0 -0
  22. package/public/assets/diagrams/before-after.svg +22 -0
  23. package/public/assets/diagrams/decision.svg +36 -0
  24. package/public/assets/diagrams/feedback-pipeline.png +0 -0
  25. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  26. package/public/assets/diagrams/loop.svg +40 -0
  27. package/public/assets/diagrams/plugin-topology.png +0 -0
  28. package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
  29. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  30. package/public/assets/diagrams/stack.svg +18 -0
  31. package/public/assets/diagrams/thumbgate-architecture.png +0 -0
  32. package/public/case-studies.html +151 -0
  33. package/public/compare.html +1 -0
  34. package/public/dashboard.html +126 -28
  35. package/public/eval-scorecard.html +195 -0
  36. package/public/eval-scorecard.json +18 -0
  37. package/public/evaluations.html +168 -0
  38. package/public/index.html +143 -13
  39. package/public/numbers.html +3 -2
  40. package/public/pricing.html +143 -30
  41. package/public/whitepaper.html +189 -0
  42. package/scripts/a-plus-evidence-scorecard.js +303 -0
  43. package/scripts/activation-quickstart.js +1 -0
  44. package/scripts/agent-outcome-monitor.js +71 -1
  45. package/scripts/async-eval-observability.js +36 -11
  46. package/scripts/audit-trail.js +37 -1
  47. package/scripts/auto-promote-gates.js +149 -34
  48. package/scripts/billing.js +3 -1
  49. package/scripts/claude-feedback-sync.js +3 -2
  50. package/scripts/cli-feedback.js +13 -7
  51. package/scripts/colbert-style-maxsim.js +236 -0
  52. package/scripts/cross-encoder-reranker.js +359 -126
  53. package/scripts/dashboard-chat.js +350 -17
  54. package/scripts/document-intake.js +283 -7
  55. package/scripts/eval-quality-suite.js +204 -0
  56. package/scripts/feedback-aggregate.js +5 -2
  57. package/scripts/feedback-loop.js +359 -189
  58. package/scripts/feedback-paths.js +32 -13
  59. package/scripts/feedback-quality.js +53 -0
  60. package/scripts/filesystem-search.js +17 -7
  61. package/scripts/gates-engine.js +98 -4
  62. package/scripts/generate-case-study-outreach.js +253 -0
  63. package/scripts/generate-eval-scorecard.js +276 -0
  64. package/scripts/growth-campaigns.js +183 -0
  65. package/scripts/harness-tool-names.js +70 -0
  66. package/scripts/hook-runtime.js +10 -3
  67. package/scripts/jsonl-watcher.js +1 -0
  68. package/scripts/lesson-db.js +16 -5
  69. package/scripts/lesson-embedding-index.js +67 -20
  70. package/scripts/lesson-embedding-maintenance.js +177 -0
  71. package/scripts/lesson-inference.js +23 -4
  72. package/scripts/lesson-reranker.js +55 -9
  73. package/scripts/lesson-retrieval.js +375 -32
  74. package/scripts/lesson-search.js +48 -11
  75. package/scripts/llm-client.js +304 -15
  76. package/scripts/mcp-config.js +26 -5
  77. package/scripts/mcp-oauth.js +37 -2
  78. package/scripts/model-eval.js +308 -0
  79. package/scripts/model-tier-router.js +593 -0
  80. package/scripts/parallel-workflow-orchestrator.js +86 -22
  81. package/scripts/pragmatic-hybrid-search.js +379 -0
  82. package/scripts/published-cli.js +11 -1
  83. package/scripts/rag-document-pipeline.js +461 -0
  84. package/scripts/rag-structured-output.js +441 -0
  85. package/scripts/ragas-style-metrics.js +351 -0
  86. package/scripts/refresh-proof-pack.js +261 -0
  87. package/scripts/request-envelope.js +178 -0
  88. package/scripts/rerank-pipeline.js +370 -0
  89. package/scripts/rerank-quality-eval.js +155 -0
  90. package/scripts/retrieval-hybrid-ablation.js +120 -0
  91. package/scripts/retrieval-quality-tier.js +118 -0
  92. package/scripts/risk-scorer.js +144 -15
  93. package/scripts/secret-scanner.js +395 -4
  94. package/scripts/self-distill-agent.js +7 -1
  95. package/scripts/self-healing-check.js +25 -0
  96. package/scripts/skill-packs.js +183 -0
  97. package/scripts/slow-loop.js +72 -0
  98. package/scripts/statusline-links.js +1 -1
  99. package/scripts/statusline-local-stats.js +1 -1
  100. package/scripts/statusline.sh +8 -1
  101. package/scripts/telemetry-analytics.js +13 -1
  102. package/scripts/thumbgate-bench.js +13 -0
  103. package/scripts/thumbgate-search.js +98 -6
  104. package/scripts/tier-budget-guard.js +186 -0
  105. package/scripts/tool-kpi-tracker.js +124 -0
  106. package/scripts/tool-registry.js +95 -1
  107. package/scripts/vector-store.js +108 -4
  108. package/scripts/verify-marketing-pages-deployed.js +85 -3
  109. package/server.json +44 -0
  110. package/smithery.yaml +17 -0
  111. package/src/api/server.js +424 -99
package/src/api/server.js CHANGED
@@ -172,7 +172,8 @@ const {
172
172
  readTaskOutcomes,
173
173
  recordTaskOutcome,
174
174
  } = require('../../scripts/task-outcomes');
175
- const { monitorTaskOutcomes } = require('../../scripts/agent-outcome-monitor');
175
+ const { monitorProductionSignals } = require('../../scripts/agent-outcome-monitor');
176
+ const { computeToolKpis, recordToolCall } = require('../../scripts/tool-kpi-tracker');
176
177
  const {
177
178
  calculateEscalationMetrics,
178
179
  decideEscalation,
@@ -209,7 +210,7 @@ const {
209
210
  getSettingsStatus,
210
211
  } = require('../../scripts/settings-hierarchy');
211
212
  const {
212
- searchThumbgate,
213
+ searchThumbgateAsync,
213
214
  } = require('../../scripts/thumbgate-search');
214
215
  const {
215
216
  appendTelemetryPing,
@@ -237,6 +238,7 @@ const { sendProblem, PROBLEM_TYPES } = require('../../scripts/problem-detail');
237
238
  const { TOOLS: MCP_TOOLS } = require('../../scripts/tool-registry');
238
239
  const mcpOauth = require('../../scripts/mcp-oauth');
239
240
  const { packCheckoutReference } = require('../../scripts/checkout-attribution-reference');
241
+ const { normalizeCampaignId } = require('../../scripts/growth-campaigns');
240
242
  // OAuth 2.1 (PKCE) authorization-server state for the remote MCP connector
241
243
  // (Claude Connectors Directory requires OAuth for authenticated services).
242
244
  const oauthStore = mcpOauth.createStore();
@@ -266,6 +268,11 @@ const CODEX_PLUGIN_PAGE_PATH = path.resolve(__dirname, '../../public/codex-plugi
266
268
  const COMPARE_PAGE_PATH = path.resolve(__dirname, '../../public/compare.html');
267
269
  const LEARN_PAGE_PATH = path.resolve(__dirname, '../../public/learn.html');
268
270
  const NUMBERS_PAGE_PATH = path.resolve(__dirname, '../../public/numbers.html');
271
+ const ARCHITECTURE_PAGE_PATH = path.resolve(__dirname, '../../public/architecture.html');
272
+ const WHITEPAPER_PAGE_PATH = path.resolve(__dirname, '../../public/whitepaper.html');
273
+ const EVAL_SCORECARD_PAGE_PATH = path.resolve(__dirname, '../../public/eval-scorecard.html');
274
+ const EVALUATIONS_PAGE_PATH = path.resolve(__dirname, '../../public/evaluations.html');
275
+ const CASE_STUDIES_PAGE_PATH = path.resolve(__dirname, '../../public/case-studies.html');
269
276
  const FEDERAL_PAGE_PATH = path.resolve(__dirname, '../../public/federal.html');
270
277
  const PRICING_PAGE_PATH = path.resolve(__dirname, '../../public/pricing.html');
271
278
  const ABOUT_PAGE_PATH = path.resolve(__dirname, '../../public/about.html');
@@ -417,7 +424,7 @@ const BUILD_METADATA = resolveBuildMetadata();
417
424
  const TERMINAL_JOB_STATUSES = new Set(['completed', 'failed', 'cancelled']);
418
425
  const IDLE_JOB_STATUSES = new Set(['queued', 'paused', 'resume_requested']);
419
426
  const JOB_CONTROL_ACTIONS = new Set(['pause', 'cancel', 'resume']);
420
- const TRACKED_LINK_QUERY_KEYS = [
427
+ const TRACKED_LINK_QUERY_KEYS = new Set([
421
428
  'utm_source',
422
429
  'utm_medium',
423
430
  'utm_campaign',
@@ -445,7 +452,7 @@ const TRACKED_LINK_QUERY_KEYS = [
445
452
  'seat_count',
446
453
  'landing_path',
447
454
  'referrer_host',
448
- ];
455
+ ]);
449
456
  const TRACKED_LINK_TARGETS = Object.freeze({
450
457
  gpt: {
451
458
  href: 'https://chatgpt.com/g/g-69dcfd1cd5f881918ae31874631d6f08-thumbgate',
@@ -473,6 +480,25 @@ const TRACKED_LINK_TARGETS = Object.freeze({
473
480
  },
474
481
  allowCustomerEmail: true,
475
482
  },
483
+ // Human skip from Pro interstitial: same-origin hop so crawlers never see buy.stripe.com
484
+ // in the interstitial HTML (bot-guard contract). Bots are deflected to /checkout/pro.
485
+ 'pro-direct': {
486
+ fallbackHref: PRO_CHECKOUT_URL,
487
+ external: true,
488
+ botDeflectPath: '/checkout/pro',
489
+ ctaId: 'pro_checkout_direct_stripe',
490
+ ctaPlacement: 'checkout_interstitial',
491
+ eventType: 'pro_checkout_direct_stripe',
492
+ defaults: {
493
+ utm_source: 'website',
494
+ utm_medium: 'checkout_interstitial',
495
+ utm_campaign: 'pro_upgrade',
496
+ plan_id: 'pro',
497
+ billing_cycle: 'monthly',
498
+ },
499
+ allowCustomerEmail: true,
500
+ prefillStripeEmail: true,
501
+ },
476
502
  // 2026-06-02: Teams/Aiventyx deprecated. Redirect legacy links to Pro.
477
503
  teams: {
478
504
  path: '/go/pro?utm_source=legacy_teams&utm_medium=redirect',
@@ -830,6 +856,67 @@ function getRequestFeedbackPaths(req, parsed) {
830
856
  });
831
857
  }
832
858
 
859
+ const DATA_IDENTITY_CACHE = new Map();
860
+ const DATA_IDENTITY_CACHE_LIMIT = 2048;
861
+
862
+ function hashDataIdentity(prefix, value) {
863
+ const normalized = String(value || '');
864
+ const cacheKey = `${prefix}\0${normalized}`;
865
+ const cached = DATA_IDENTITY_CACHE.get(cacheKey);
866
+ if (cached) return cached;
867
+
868
+ // Billing identifiers can be email-like and API keys must never be made
869
+ // guessable through a fast digest. A memory-hard KDF gives us a stable,
870
+ // path-safe pseudonym without persisting the source identifier.
871
+ const derived = `${prefix}_${crypto.scryptSync(
872
+ normalized,
873
+ `thumbgate-${prefix}-data-identity-v1`,
874
+ 16,
875
+ { N: 16384, r: 8, p: 1 }
876
+ ).toString('hex').slice(0, 24)}`;
877
+
878
+ if (DATA_IDENTITY_CACHE.size >= DATA_IDENTITY_CACHE_LIMIT) {
879
+ DATA_IDENTITY_CACHE.clear();
880
+ }
881
+ DATA_IDENTITY_CACHE.set(cacheKey, derived);
882
+ return derived;
883
+ }
884
+
885
+ function buildHostedTenantIdentity(validation = {}) {
886
+ if (!validation || validation.valid !== true || !validation.customerId) return null;
887
+ const tenantSeed = validation.customerId;
888
+ const principalSeed = validation.installId || tenantSeed;
889
+ const tenantId = hashDataIdentity('tenant', tenantSeed);
890
+ return {
891
+ partition: tenantId,
892
+ accessContext: {
893
+ tenantId,
894
+ principalId: hashDataIdentity('principal', principalSeed),
895
+ isAdmin: false,
896
+ },
897
+ };
898
+ }
899
+
900
+ function resolveRequestDataIdentity(req, expectedApiKey) {
901
+ const token = extractApiKey(req);
902
+ if (!token || (expectedApiKey && safeKeyEqual(token, expectedApiKey))) {
903
+ return { partition: null, accessContext: null };
904
+ }
905
+ const validation = validateApiKey(token);
906
+ const identity = buildHostedTenantIdentity(validation);
907
+ if (validation.valid === true && !identity) {
908
+ return { partition: null, accessContext: null, invalidHostedIdentity: true };
909
+ }
910
+ return identity || { partition: null, accessContext: null };
911
+ }
912
+
913
+ function partitionFeedbackPaths(paths, dataIdentity) {
914
+ if (!dataIdentity?.partition) return paths;
915
+ return getFeedbackPaths({
916
+ feedbackDir: path.join(paths.FEEDBACK_DIR, 'tenants', dataIdentity.partition),
917
+ });
918
+ }
919
+
833
920
  function getSafeDataDir(req, parsed) {
834
921
  const { FEEDBACK_LOG_PATH } = getRequestFeedbackPaths(req, parsed);
835
922
  return path.resolve(path.dirname(FEEDBACK_LOG_PATH));
@@ -876,11 +963,13 @@ function updateLessonRecord(feedbackDir, lessonId, updater) {
876
963
  }
877
964
 
878
965
  function getPublicMcpTools() {
879
- return MCP_TOOLS.map((tool) => ({
966
+ const { getExposedTools } = require('../../adapters/mcp/server-stdio');
967
+ return getExposedTools().map((tool) => ({
880
968
  name: tool.name,
881
969
  ...(tool.title ? { title: tool.title } : {}),
882
970
  description: tool.description,
883
971
  inputSchema: tool.inputSchema,
972
+ ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}),
884
973
  // Serve the tool-registry annotations (readOnlyHint/destructiveHint). Required
885
974
  // by the Claude Connectors Directory (missing annotations = the #1 rejection
886
975
  // cause) and used by MCP clients for permission prompts. Was being dropped here.
@@ -889,13 +978,7 @@ function getPublicMcpTools() {
889
978
  }
890
979
 
891
980
  function getServerCardTools() {
892
- return MCP_TOOLS.map((tool) => ({
893
- name: tool.name,
894
- ...(tool.title ? { title: tool.title } : {}),
895
- description: tool.description,
896
- inputSchema: tool.inputSchema,
897
- ...(tool.annotations ? { annotations: tool.annotations } : {}),
898
- }));
981
+ return getPublicMcpTools();
899
982
  }
900
983
 
901
984
  function buildPublicUrl(hostedConfig, pathname) {
@@ -905,7 +988,7 @@ function buildPublicUrl(hostedConfig, pathname) {
905
988
  const VERIFICATION_EVIDENCE_URL = 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md';
906
989
 
907
990
  function getToolDiscoveryIndex(hostedConfig) {
908
- return MCP_TOOLS.map((tool) => ({
991
+ return getPublicMcpTools().map((tool) => ({
909
992
  name: tool.name,
910
993
  description: tool.description,
911
994
  annotations: tool.annotations || {},
@@ -915,7 +998,7 @@ function getToolDiscoveryIndex(hostedConfig) {
915
998
 
916
999
  function getContextFootprintReport(hostedConfig) {
917
1000
  return buildContextFootprintReport({
918
- tools: MCP_TOOLS,
1001
+ tools: getPublicMcpTools(),
919
1002
  schemaUrlTemplate: buildPublicUrl(hostedConfig, '/.well-known/mcp/tools/{name}.json'),
920
1003
  });
921
1004
  }
@@ -1273,7 +1356,8 @@ function inferSearchQuery(referrer) {
1273
1356
 
1274
1357
  function getAttributionValue(params, key, fallbackValue) {
1275
1358
  const value = params.get(key);
1276
- return value && value.trim() ? value.trim() : fallbackValue;
1359
+ const resolved = value?.trim() || fallbackValue;
1360
+ return key === 'utm_campaign' ? normalizeCampaignId(resolved) : resolved;
1277
1361
  }
1278
1362
 
1279
1363
  function parseUrlSearchParams(urlValue) {
@@ -1645,17 +1729,28 @@ function projectWorkflowIntakeQueueLead(lead = {}, options = {}) {
1645
1729
  page: attribution.page || null,
1646
1730
  landingPath: attribution.landingPath || null,
1647
1731
  },
1648
- nextOperatorStep: options.closePacket?.status === 'approval_ready_not_authorized'
1649
- ? 'request_action_time_approval'
1650
- : options.discoveryPacket?.status === 'approval_ready_not_authorized'
1651
- ? 'request_action_time_approval_for_discovery'
1652
- : lead.status === 'new' ? 'review_and_qualify' : 'prepare_scope_or_hold',
1732
+ nextOperatorStep: resolveNextOperatorStep(lead, options),
1653
1733
  qualificationCard: options.qualificationCard || buildIntakeQualificationCard(lead, options),
1654
1734
  discoveryPacket: options.discoveryPacket || buildIntakeDiscoveryPacket(lead, options),
1655
1735
  closePacket: options.closePacket || buildIntakeClosePacket(lead, options),
1656
1736
  };
1657
1737
  }
1658
1738
 
1739
+ /**
1740
+ * Which action the operator should take next on an intake lead. Extracted from a three-level
1741
+ * nested ternary: the precedence (close packet, then discovery packet, then lead status) is the
1742
+ * actual logic and is far easier to check when it reads top to bottom.
1743
+ */
1744
+ function resolveNextOperatorStep(lead, options) {
1745
+ if (options.closePacket?.status === 'approval_ready_not_authorized') {
1746
+ return 'request_action_time_approval';
1747
+ }
1748
+ if (options.discoveryPacket?.status === 'approval_ready_not_authorized') {
1749
+ return 'request_action_time_approval_for_discovery';
1750
+ }
1751
+ return lead.status === 'new' ? 'review_and_qualify' : 'prepare_scope_or_hold';
1752
+ }
1753
+
1659
1754
  function buildWorkflowIntakeQueue(parsed, workflowSprintIntake, feedbackDir, options = {}) {
1660
1755
  const { limit, statuses } = resolveWorkflowIntakeQueueOptions(parsed, workflowSprintIntake);
1661
1756
  const leads = workflowSprintIntake.loadWorkflowSprintLeads(feedbackDir);
@@ -1734,7 +1829,7 @@ function sendInvalidAnalyticsWindowProblem(res, title, err) {
1734
1829
  }
1735
1830
 
1736
1831
  function sendWorkflowIntakeQueueProblem(res, err) {
1737
- const status = Number(err && err.statusCode) || 400;
1832
+ const status = Number(err?.statusCode) || 400;
1738
1833
  sendProblem(res, {
1739
1834
  type: status >= 500 ? PROBLEM_TYPES.INTERNAL : PROBLEM_TYPES.INVALID_REQUEST,
1740
1835
  title: status >= 500
@@ -2486,8 +2581,9 @@ ${hiddenInputs}
2486
2581
  <p class="email-note">Required so the checkout has attributable buyer intent and your receipt can be recovered.</p>
2487
2582
  <button type="submit" class="primary">Pay $19/mo with Stripe →</button>
2488
2583
  </form>
2584
+ <a class="secondary" data-i="pro_checkout_direct_stripe" rel="nofollow" href="/go/pro-direct?utm_source=website&amp;utm_medium=checkout_interstitial&amp;utm_campaign=pro_upgrade&amp;utm_content=skip_form&amp;cta_id=pro_checkout_direct_stripe&amp;cta_placement=checkout_interstitial">Skip form — open Stripe checkout directly →</a>
2489
2585
  <a class="secondary" data-i="workflow_sprint_intake" href="/#workflow-sprint-intake">Not sure yet? Send the workflow first</a>
2490
- <p class="choice-note">Cancel anytime. 7-day refund, no questions. Diagnostics and sprints have their own pages.</p>
2586
+ <p class="choice-note">Cancel anytime. 7-day refund, no questions. Diagnostics and sprints have their own pages. Email path keeps receipts attributable; skip uses a same-origin hop that bot-deflects crawlers.</p>
2491
2587
  <div class="objection-box" aria-label="Checkout feedback">
2492
2588
  <strong>Not buying today?</strong>
2493
2589
  <p class="objection-note">Tap one reason so we know what to fix. This does not sign you up.</p>
@@ -2689,7 +2785,10 @@ function appendTrackedLinkQueryParams(destinationUrl, parsed, target) {
2689
2785
  for (const key of TRACKED_LINK_QUERY_KEYS) {
2690
2786
  const value = params.get(key);
2691
2787
  if (value && value.trim()) {
2692
- destinationUrl.searchParams.set(key, value.trim());
2788
+ const normalizedValue = key === 'utm_campaign'
2789
+ ? normalizeCampaignId(value)
2790
+ : value.trim();
2791
+ destinationUrl.searchParams.set(key, normalizedValue);
2693
2792
  }
2694
2793
  }
2695
2794
  if (target.allowCustomerEmail) {
@@ -2730,7 +2829,7 @@ function buildTrackedLinkAttribution(target, parsed, req, journeyState, destinat
2730
2829
  const source = pickFirstText(
2731
2830
  params.get('source'),
2732
2831
  params.get('utm_source'),
2733
- target.defaults && target.defaults.utm_source,
2832
+ target.defaults?.utm_source,
2734
2833
  inferSource(referrerHost)
2735
2834
  );
2736
2835
 
@@ -2744,19 +2843,21 @@ function buildTrackedLinkAttribution(target, parsed, req, journeyState, destinat
2744
2843
  traceId: pickFirstText(params.get('trace_id')),
2745
2844
  source,
2746
2845
  utmSource: pickFirstText(params.get('utm_source'), source),
2747
- utmMedium: pickFirstText(params.get('utm_medium'), target.defaults && target.defaults.utm_medium, 'link_router'),
2748
- utmCampaign: pickFirstText(params.get('utm_campaign'), target.defaults && target.defaults.utm_campaign, 'first_party_redirect'),
2846
+ utmMedium: pickFirstText(params.get('utm_medium'), target.defaults?.utm_medium, 'link_router'),
2847
+ utmCampaign: normalizeCampaignId(
2848
+ pickFirstText(params.get('utm_campaign'), target.defaults?.utm_campaign, 'first_party_redirect')
2849
+ ),
2749
2850
  utmContent: pickFirstText(params.get('utm_content')),
2750
2851
  utmTerm: pickFirstText(params.get('utm_term')),
2751
2852
  creator: pickFirstText(params.get('creator'), params.get('creator_handle')),
2752
2853
  community: pickFirstText(params.get('community'), params.get('subreddit')),
2753
2854
  postId: pickFirstText(params.get('post_id')),
2754
2855
  commentId: pickFirstText(params.get('comment_id')),
2755
- campaignVariant: pickFirstText(params.get('campaign_variant'), target.defaults && target.defaults.campaign_variant),
2856
+ campaignVariant: pickFirstText(params.get('campaign_variant'), target.defaults?.campaign_variant),
2756
2857
  offerCode: pickFirstText(params.get('offer_code')),
2757
2858
  ctaId: pickFirstText(params.get('cta_id'), target.ctaId),
2758
2859
  ctaPlacement: pickFirstText(params.get('cta_placement'), target.ctaPlacement),
2759
- planId: pickFirstText(params.get('plan_id'), target.defaults && target.defaults.plan_id),
2860
+ planId: pickFirstText(params.get('plan_id'), target.defaults?.plan_id),
2760
2861
  landingPath: pickFirstText(params.get('landing_path'), `/go/${target.slug}`),
2761
2862
  page: `/go/${target.slug}`,
2762
2863
  referrer,
@@ -2805,6 +2906,37 @@ function serveTrackedLinkRedirect({ req, res, parsed, hostedConfig, isHeadReques
2805
2906
 
2806
2907
  const { FEEDBACK_DIR } = getFeedbackPaths();
2807
2908
  const journeyState = resolveJourneyState(req, parsed);
2909
+ const botClassification = classifyRequester(req.headers);
2910
+ if (target.botDeflectPath && botClassification.isBot) {
2911
+ const deflectUrl = new URL(target.botDeflectPath, hostedConfig.appOrigin);
2912
+ appendTrackedLinkQueryParams(deflectUrl, parsed, target);
2913
+ if (!isHeadRequest) {
2914
+ appendBestEffortTelemetry(
2915
+ FEEDBACK_DIR,
2916
+ {
2917
+ eventType: 'checkout_bot_deflected',
2918
+ clientType: 'web',
2919
+ page: `/go/${target.slug}`,
2920
+ reason: botClassification.reason || 'bot',
2921
+ destinationSlug: target.slug,
2922
+ acquisitionId: journeyState.acquisitionId,
2923
+ visitorId: journeyState.visitorId,
2924
+ sessionId: journeyState.sessionId,
2925
+ },
2926
+ req.headers,
2927
+ `tracked_link_bot_deflect:${target.slug}`
2928
+ );
2929
+ }
2930
+ res.writeHead(302, {
2931
+ ...(journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}),
2932
+ 'Cache-Control': 'no-store',
2933
+ 'X-Robots-Tag': 'noindex,nofollow',
2934
+ 'X-ThumbGate-Link-Slug': target.slug,
2935
+ Location: deflectUrl.toString(),
2936
+ });
2937
+ res.end();
2938
+ return;
2939
+ }
2808
2940
  const destinationUrl = buildTrackedLinkDestination(target, hostedConfig, parsed);
2809
2941
  const attribution = buildTrackedLinkAttribution(target, parsed, req, journeyState, destinationUrl);
2810
2942
  if (target.external) {
@@ -3822,6 +3954,12 @@ function renderSitemapXml(runtimeConfig) {
3822
3954
  { path: '/codex-enterprise', changefreq: 'weekly', priority: '0.85' },
3823
3955
  { path: '/agents-cost-savings', changefreq: 'weekly', priority: '0.85' },
3824
3956
  { path: '/ai-malpractice-prevention', changefreq: 'weekly', priority: '0.9' },
3957
+ { path: '/whitepaper', changefreq: 'weekly', priority: '0.9' },
3958
+ { path: '/architecture', changefreq: 'weekly', priority: '0.85' },
3959
+ { path: '/eval-scorecard', changefreq: 'weekly', priority: '0.9' },
3960
+ { path: '/evaluations', changefreq: 'weekly', priority: '0.85' },
3961
+ { path: '/case-studies', changefreq: 'weekly', priority: '0.9' },
3962
+ { path: '/numbers', changefreq: 'weekly', priority: '0.8' },
3825
3963
  { path: '/learn/background-agent-control-layer', changefreq: 'weekly', priority: '0.85' },
3826
3964
  { path: '/learn/ac-dc-runtime-enforcement', changefreq: 'weekly', priority: '0.85' },
3827
3965
  { path: '/learn/feedback-loop-vs-decision-layer', changefreq: 'weekly', priority: '0.9' },
@@ -5198,9 +5336,10 @@ function createApiServer() {
5198
5336
  sendJson(res, 403, { error: 'project selection is only available on localhost requests' });
5199
5337
  return;
5200
5338
  }
5201
- const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
5202
- const requestFeedbackDir = requestFeedbackPaths.FEEDBACK_DIR;
5203
- const requestSafeDataDir = getSafeDataDir(req, parsed);
5339
+ let requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
5340
+ let requestFeedbackDir = requestFeedbackPaths.FEEDBACK_DIR;
5341
+ let requestSafeDataDir = getSafeDataDir(req, parsed);
5342
+ let requestDocumentAccessContext = null;
5204
5343
 
5205
5344
  // PostHog reverse proxy -- bypasses ad blockers.
5206
5345
  // Only allow known PostHog API paths to prevent SSRF (CodeQL js/request-forgery).
@@ -5273,6 +5412,14 @@ function createApiServer() {
5273
5412
  ? mcpOauth.tokenAudienceValid(oauthSession, resourceUrl)
5274
5413
  : rawKeyValid;
5275
5414
  if (!authed) {
5415
+ recordToolCall({
5416
+ toolName: msg.params?.name || 'unknown',
5417
+ serverName: 'mcp-http',
5418
+ latencyMs: 0,
5419
+ success: false,
5420
+ agentId: 'unauthenticated',
5421
+ metadata: { denied: true, deniedReason: 'authentication_required' },
5422
+ });
5276
5423
  res.writeHead(401, {
5277
5424
  'Content-Type': 'application/json',
5278
5425
  // RFC 9728: point unauthenticated clients at the resource metadata.
@@ -5295,6 +5442,14 @@ function createApiServer() {
5295
5442
  const tool = MCP_TOOLS.find((t) => t.name === name);
5296
5443
  const readOnly = Boolean(tool && tool.annotations && tool.annotations.readOnlyHint === true);
5297
5444
  if (!readOnly) {
5445
+ recordToolCall({
5446
+ toolName: name || 'unknown',
5447
+ serverName: 'mcp-http',
5448
+ latencyMs: 0,
5449
+ success: false,
5450
+ agentId: 'reviewer',
5451
+ metadata: { denied: true, deniedReason: 'reviewer_read_only' },
5452
+ });
5298
5453
  sendJson(res, 200, {
5299
5454
  jsonrpc: '2.0', id: msg.id,
5300
5455
  error: { code: -32002, message: `Tool "${name}" requires write access; the reviewer credential is read-only.` },
@@ -5302,15 +5457,39 @@ function createApiServer() {
5302
5457
  return;
5303
5458
  }
5304
5459
  }
5460
+ if (oauthSession) {
5461
+ const name = msg.params && msg.params.name;
5462
+ const tool = MCP_TOOLS.find((candidate) => candidate.name === name);
5463
+ const requiredScope = tool?.annotations?.readOnlyHint === true ? 'mcp:read' : 'mcp:write';
5464
+ if (!mcpOauth.scopeAllows(oauthSession, requiredScope)) {
5465
+ recordToolCall({
5466
+ toolName: name || 'unknown',
5467
+ serverName: 'mcp-http',
5468
+ latencyMs: 0,
5469
+ success: false,
5470
+ agentId: oauthSession.clientId || 'oauth-client',
5471
+ metadata: {
5472
+ denied: true,
5473
+ deniedReason: 'insufficient_scope',
5474
+ requiredScope,
5475
+ },
5476
+ });
5477
+ sendJson(res, 200, {
5478
+ jsonrpc: '2.0', id: msg.id,
5479
+ error: { code: -32003, message: `OAuth token is missing required scope "${requiredScope}".` },
5480
+ });
5481
+ return;
5482
+ }
5483
+ }
5305
5484
  (async () => {
5306
5485
  try {
5307
5486
  const { callTool } = require('../../adapters/mcp/server-stdio');
5308
- const name = msg.params && msg.params.name;
5487
+ const name = msg.params?.name;
5309
5488
  const args = (msg.params && msg.params.arguments) || {};
5310
5489
  const result = await callTool(name, args);
5311
5490
  sendJson(res, 200, {
5312
5491
  jsonrpc: '2.0', id: msg.id,
5313
- result: { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }] },
5492
+ result,
5314
5493
  });
5315
5494
  } catch (err) {
5316
5495
  sendJson(res, 200, {
@@ -5593,7 +5772,7 @@ function createApiServer() {
5593
5772
  let trackedParsed = parsed;
5594
5773
  if (req.method === 'POST') {
5595
5774
  const target = getTrackedLinkTarget(trackedLinkMatch[1]);
5596
- if (!target || !target.requiresPost) {
5775
+ if (!target?.requiresPost) {
5597
5776
  sendJson(res, target ? 405 : 404, target
5598
5777
  ? { error: 'POST is not supported for this tracked link' }
5599
5778
  : { error: 'Tracked link not found', allowed: Object.keys(TRACKED_LINK_TARGETS) }, {
@@ -5612,7 +5791,7 @@ function createApiServer() {
5612
5791
  const form = await parseFormBody(req, 16 * 1024);
5613
5792
  trackedParsed = new URL(parsed.toString());
5614
5793
  for (const [key, value] of Object.entries(form)) {
5615
- if (key === 'customer_email' || TRACKED_LINK_QUERY_KEYS.includes(key)) {
5794
+ if (key === 'customer_email' || TRACKED_LINK_QUERY_KEYS.has(key)) {
5616
5795
  trackedParsed.searchParams.set(key, String(value || '').trim());
5617
5796
  }
5618
5797
  }
@@ -5684,10 +5863,31 @@ function createApiServer() {
5684
5863
  return;
5685
5864
  }
5686
5865
 
5687
- // Quick feedback capture via GET for statusline clickable links
5688
- if (isGetLikeRequest && pathname === '/feedback/quick') {
5866
+ // GET/HEAD only render a confirmation. Capture requires an authenticated
5867
+ // POST so prefetchers and link checkers cannot create human feedback.
5868
+ if ((isGetLikeRequest || req.method === 'POST') && pathname === '/feedback/quick') {
5689
5869
  const signal = parsed.searchParams.get('signal');
5690
5870
  if (signal === 'up' || signal === 'down') {
5871
+ const emoji = signal === 'up' ? '👍' : '👎';
5872
+ const label = signal === 'up' ? 'Positive' : 'Negative';
5873
+ if (isHeadRequest) {
5874
+ sendHtml(res, 200, '', {}, { headOnly: true });
5875
+ return;
5876
+ }
5877
+ if (req.method === 'GET') {
5878
+ sendHtml(res, 200, `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ThumbGate feedback</title></head>
5879
+ <body style="background:#0a0a0a;color:#fff;font-family:system-ui;display:grid;place-items:center;height:100vh;margin:0">
5880
+ <form method="post" action="/feedback/quick?signal=${signal}" style="text-align:center;background:#141414;padding:40px;border-radius:16px">
5881
+ <div style="font-size:64px">${emoji}</div><h1>Record ${label.toLowerCase()} feedback?</h1>
5882
+ <p>This confirmation prevents previews and crawlers from recording feedback.</p>
5883
+ <button type="submit" style="padding:10px 20px;font-weight:700">Record ${emoji} feedback</button>
5884
+ </form></body></html>`);
5885
+ return;
5886
+ }
5887
+ if (!isAuthorized(req, expectedApiKey)) {
5888
+ sendJson(res, 401, { error: 'Authentication required to record human feedback' });
5889
+ return;
5890
+ }
5691
5891
  const chatHistory = readRecentConversationWindow({
5692
5892
  feedbackDir: requestSafeDataDir,
5693
5893
  limit: 10,
@@ -5697,35 +5897,17 @@ function createApiServer() {
5697
5897
  context: 'Quick capture from Claude Code statusline',
5698
5898
  chatHistory,
5699
5899
  tags: ['statusline', 'quick-capture'],
5900
+ reviewOrigin: 'human',
5700
5901
  });
5701
- const emoji = signal === 'up' ? '👍' : '👎';
5702
- const color = signal === 'up' ? '#22c55e' : '#ef4444';
5703
- const label = signal === 'up' ? 'Positive' : 'Negative';
5704
5902
  const opposite = signal === 'up' ? 'down' : 'up';
5705
5903
  const oppEmoji = signal === 'up' ? '👎' : '👍';
5706
5904
  const feedbackId = result.feedbackEvent?.id || 'saved';
5707
5905
  const promoted = result.accepted ? 'Promoted to memory' : 'Stored';
5708
5906
  sendHtml(res, 200, `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ThumbGate — ${label} feedback</title>
5709
5907
  <style>
5710
- *{box-sizing:border-box}
5711
5908
  body{background:#0a0a0a;color:#fff;font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
5712
5909
  .card{text-align:center;background:#141414;border:1px solid #222;border-radius:16px;padding:48px 40px;max-width:420px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,.5)}
5713
- .emoji{font-size:80px;margin-bottom:12px;animation:pop .4s ease-out}
5714
- @keyframes pop{0%{transform:scale(0)}50%{transform:scale(1.2)}100%{transform:scale(1)}}
5715
- .msg{font-size:22px;color:${color};font-weight:700;margin-bottom:4px}
5716
- .sub{font-size:13px;color:#666;margin-top:6px;font-family:ui-monospace,monospace}
5717
- .context-form{margin-top:20px;text-align:left}
5718
- .context-form label{font-size:12px;color:#888;display:block;margin-bottom:6px}
5719
- .context-form textarea{width:100%;background:#1a1a1a;border:1px solid #333;border-radius:8px;color:#ccc;padding:10px;font-size:13px;resize:vertical;min-height:60px;font-family:system-ui}
5720
- .context-form textarea:focus{outline:none;border-color:${color}}
5721
- .context-form button{margin-top:8px;background:${color};color:#000;border:none;border-radius:8px;padding:8px 20px;font-size:13px;font-weight:600;cursor:pointer}
5722
- .context-form button:hover{opacity:.85}
5723
- .actions{margin-top:24px;display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
5724
- .actions a{color:#22d3ee;text-decoration:none;font-size:13px;padding:8px 16px;border:1px solid #333;border-radius:8px;transition:all .15s}
5725
- .actions a:hover{background:#1a2a2e;border-color:#22d3ee}
5726
- .toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#22c55e;color:#000;padding:10px 24px;border-radius:8px;font-size:14px;font-weight:600;display:none;animation:slideUp .3s ease-out}
5727
- @keyframes slideUp{from{opacity:0;transform:translateX(-50%) translateY(12px)}to{opacity:1;transform:translateX(-50%) translateY(0)}}
5728
- .badge{display:inline-block;font-size:11px;padding:2px 8px;border-radius:4px;background:#1a1a1a;border:1px solid #333;color:#888;margin-top:8px}
5910
+ .emoji{font-size:64px}.sub,label{color:#888}textarea{width:100%;min-height:60px}.actions{margin-top:20px}.actions a{color:#22d3ee;margin:8px}
5729
5911
  </style></head><body>
5730
5912
  <div class="card">
5731
5913
  <div class="emoji">${emoji}</div>
@@ -5738,7 +5920,7 @@ body{background:#0a0a0a;color:#fff;font-family:system-ui,-apple-system,sans-seri
5738
5920
  </div>
5739
5921
  <div class="actions">
5740
5922
  <a href="/lessons/${feedbackId}" title="View the full lesson and edit it">📋 View Lesson</a>
5741
- <a href="/feedback/quick?signal=${opposite}" title="Meant to click ${oppEmoji}?">Undo → send ${oppEmoji} instead</a>
5923
+ <a href="/feedback/quick?signal=${opposite}" title="Meant to click ${oppEmoji}?">Record ${oppEmoji} instead</a>
5742
5924
  <a href="/dashboard">Dashboard →</a>
5743
5925
  </div>
5744
5926
  </div>
@@ -5762,6 +5944,10 @@ async function addContext(){
5762
5944
  }
5763
5945
 
5764
5946
  if (req.method === 'POST' && pathname === '/feedback/quick/context') {
5947
+ if (!isAuthorized(req, expectedApiKey)) {
5948
+ sendJson(res, 401, { error: 'Authentication required to update human feedback' });
5949
+ return;
5950
+ }
5765
5951
  const body = await parseJsonBody(req);
5766
5952
  const signal = body.signal;
5767
5953
  const context = typeof body.context === 'string' ? body.context.trim() : '';
@@ -6206,6 +6392,74 @@ async function addContext(){
6206
6392
  return;
6207
6393
  }
6208
6394
 
6395
+ if (isGetLikeRequest && (pathname === '/architecture' || pathname === '/architecture.html')) {
6396
+ try {
6397
+ servePublicMarketingPage({
6398
+ req,
6399
+ res,
6400
+ parsed,
6401
+ hostedConfig,
6402
+ isHeadRequest,
6403
+ renderHtml: () => fs.readFileSync(ARCHITECTURE_PAGE_PATH, 'utf-8'),
6404
+ extraTelemetry: { pageType: 'architecture' },
6405
+ });
6406
+ } catch {
6407
+ sendJson(res, 404, { error: 'Architecture page not found' });
6408
+ }
6409
+ return;
6410
+ }
6411
+
6412
+ if (isGetLikeRequest && (pathname === '/whitepaper' || pathname === '/whitepaper.html' || pathname === '/how-we-know' || pathname === '/how-we-know-it-works')) {
6413
+ try {
6414
+ servePublicMarketingPage({
6415
+ req,
6416
+ res,
6417
+ parsed,
6418
+ hostedConfig,
6419
+ isHeadRequest,
6420
+ renderHtml: () => fs.readFileSync(WHITEPAPER_PAGE_PATH, 'utf-8'),
6421
+ extraTelemetry: { pageType: 'whitepaper' },
6422
+ });
6423
+ } catch {
6424
+ sendJson(res, 404, { error: 'White paper page not found' });
6425
+ }
6426
+ return;
6427
+ }
6428
+
6429
+ if (isGetLikeRequest && (pathname === '/eval-scorecard' || pathname === '/eval-scorecard.html' || pathname === '/scorecard' || pathname === '/bench')) {
6430
+ try {
6431
+ servePublicMarketingPage({
6432
+ req,
6433
+ res,
6434
+ parsed,
6435
+ hostedConfig,
6436
+ isHeadRequest,
6437
+ renderHtml: () => fs.readFileSync(EVAL_SCORECARD_PAGE_PATH, 'utf-8'),
6438
+ extraTelemetry: { pageType: 'eval_scorecard' },
6439
+ });
6440
+ } catch {
6441
+ sendJson(res, 404, { error: 'Eval scorecard page not found' });
6442
+ }
6443
+ return;
6444
+ }
6445
+
6446
+ if (isGetLikeRequest && (pathname === '/evaluations' || pathname === '/evaluations.html')) {
6447
+ try {
6448
+ servePublicMarketingPage({
6449
+ req,
6450
+ res,
6451
+ parsed,
6452
+ hostedConfig,
6453
+ isHeadRequest,
6454
+ renderHtml: () => fs.readFileSync(EVALUATIONS_PAGE_PATH, 'utf-8'),
6455
+ extraTelemetry: { pageType: 'evaluations' },
6456
+ });
6457
+ } catch {
6458
+ sendJson(res, 404, { error: 'Evaluations page not found' });
6459
+ }
6460
+ return;
6461
+ }
6462
+
6209
6463
  if (isGetLikeRequest && (pathname === '/federal' || pathname === '/federal.html' || pathname === '/government' || pathname === '/gov')) {
6210
6464
  // Federal lead-gen page. Routed through servePublicMarketingPage so agency
6211
6465
  // arrivals via SBIR / GSA / outbound channels capture UTM attribution and
@@ -6470,7 +6724,7 @@ async function addContext(){
6470
6724
  if (
6471
6725
  key === 'confirm'
6472
6726
  || key === 'customer_email'
6473
- || TRACKED_LINK_QUERY_KEYS.includes(key)
6727
+ || TRACKED_LINK_QUERY_KEYS.has(key)
6474
6728
  ) {
6475
6729
  parsed.searchParams.set(key, String(value || '').trim());
6476
6730
  }
@@ -6986,7 +7240,8 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
6986
7240
  // are configured (production) the key MUST match a configured admin /
6987
7241
  // operator / reviewer key — otherwise OAuth would authenticate nobody.
6988
7242
  // In insecure/dev mode (no keys configured) any non-empty key is accepted.
6989
- if (!resolveKeyRole(form.get('api_key') || '')) {
7243
+ const keyRole = resolveKeyRole(form.get('api_key') || '');
7244
+ if (!keyRole) {
6990
7245
  sendJson(res, 400, { error: 'access_denied', error_description: 'invalid ThumbGate API key' });
6991
7246
  return;
6992
7247
  }
@@ -6996,6 +7251,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
6996
7251
  codeChallenge: authorizationParams.codeChallenge,
6997
7252
  codeChallengeMethod: authorizationParams.codeChallengeMethod,
6998
7253
  scope: authorizationParams.scope,
7254
+ allowedScopes: keyRole === 'reviewer' ? ['mcp:read'] : mcpOauth.SUPPORTED_SCOPES,
6999
7255
  resource: authorizationParams.resource || buildPublicUrl(hostedConfig, '/mcp'),
7000
7256
  boundKey: form.get('api_key') || '',
7001
7257
  state,
@@ -7039,7 +7295,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
7039
7295
  sendJson(res, 200, {
7040
7296
  name: 'thumbgate',
7041
7297
  version: pkg.version,
7042
- count: MCP_TOOLS.length,
7298
+ count: getPublicMcpTools().length,
7043
7299
  tools: getToolDiscoveryIndex(hostedConfig),
7044
7300
  }, {}, {
7045
7301
  headOnly: isHeadRequest,
@@ -7072,7 +7328,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
7072
7328
  });
7073
7329
  return;
7074
7330
  }
7075
- const tool = MCP_TOOLS.find((candidate) => candidate.name === toolName);
7331
+ const tool = getPublicMcpTools().find((candidate) => candidate.name === toolName);
7076
7332
  if (!tool) {
7077
7333
  sendJson(res, 404, {
7078
7334
  error: 'tool_not_found',
@@ -7088,6 +7344,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
7088
7344
  description: tool.description,
7089
7345
  annotations: tool.annotations || {},
7090
7346
  inputSchema: tool.inputSchema,
7347
+ ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}),
7091
7348
  }, {}, {
7092
7349
  headOnly: isHeadRequest,
7093
7350
  });
@@ -8078,29 +8335,25 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8078
8335
  return;
8079
8336
  }
8080
8337
 
8081
- // Public case studies — proof surface for buyers. Conversion-optimization
8082
- // surface that was missing: thumbgate.ai had no /case-studies, so visitors
8083
- // landed on CLI install commands without seeing whether anyone actually
8084
- // got value. First entry is the Aiventyx Teams listing integration: real
8085
- // third-party CTR signal (5/8 clicks before the /go/teams fix, now routed
8086
- // through team intake so scope happens before checkout).
8087
- if (isGetLikeRequest && pathname === '/case-studies') {
8088
- 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>
8089
- <h1>Case Studies</h1>
8090
- <p class="lede">Real integrations. No fabricated logos, no aspirational numbers — every claim below is reproducible.</p>
8091
-
8092
- <p><em>New case studies for individual Pro operators coming soon.</em></p>
8093
-
8094
- <footer>
8095
- <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>
8096
- <p><a href="https://thumbgate.ai">Home</a> · <a href="/pricing">Pricing</a> · <a href="/privacy">Privacy</a> · <a href="/terms">Terms</a> · <a href="/support">Support</a></p>
8097
- </footer>
8098
- </body></html>`, {}, {
8099
- headOnly: isHeadRequest,
8100
- });
8338
+ // Public case studies — dogfood proof narratives (static HTML; no fabricated logos).
8339
+ if (isGetLikeRequest && (pathname === '/case-studies' || pathname === '/case-studies.html')) {
8340
+ try {
8341
+ servePublicMarketingPage({
8342
+ req,
8343
+ res,
8344
+ parsed,
8345
+ hostedConfig,
8346
+ isHeadRequest,
8347
+ renderHtml: () => fs.readFileSync(CASE_STUDIES_PAGE_PATH, 'utf-8'),
8348
+ extraTelemetry: { pageType: 'case_studies' },
8349
+ });
8350
+ } catch {
8351
+ sendJson(res, 404, { error: 'Case studies page not found' });
8352
+ }
8101
8353
  return;
8102
8354
  }
8103
8355
 
8356
+
8104
8357
  // Public canonical pricing page. The audit flagged "pricing schizophrenia":
8105
8358
  // sales/pricing.json said $49 / $299, COMMERCIAL_TRUTH.md said $19 / $149,
8106
8359
  // and there was no buyer-facing surface to reconcile the two. This is now
@@ -8255,7 +8508,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8255
8508
  });
8256
8509
  return;
8257
8510
  }
8258
- if (result && result.retryable === true) {
8511
+ if (result?.retryable === true) {
8259
8512
  sendProblem(res, {
8260
8513
  type: PROBLEM_TYPES.INTERNAL,
8261
8514
  title: 'Fulfillment temporarily unavailable',
@@ -8443,14 +8696,15 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8443
8696
  }
8444
8697
 
8445
8698
  const resolvedTraceId = result.traceId || requestedTraceId;
8446
- const nextSteps = result.apiKey
8447
- ? {
8699
+ let nextSteps = {};
8700
+ if (result.apiKey) {
8701
+ nextSteps = {
8448
8702
  env: `THUMBGATE_API_KEY=${result.apiKey}\nTHUMBGATE_API_BASE_URL=${hostedConfig.billingApiBaseUrl}`,
8449
8703
  curl: `curl -X POST ${hostedConfig.billingApiBaseUrl}/v1/feedback/capture \\\n+ -H 'Authorization: Bearer ${result.apiKey}' \\\n+ -H 'Content-Type: application/json' \\\n+ -d '{"signal":"down","context":"example","whatWentWrong":"example","whatToChange":"example"}'`,
8450
- }
8451
- : result.offerKind === 'workflow_hardening_diagnostic'
8452
- ? { fulfillment: 'Payment confirmed. Diagnostic fulfillment is processing; an email will arrive when it completes.' }
8453
- : {};
8704
+ };
8705
+ } else if (result.offerKind === 'workflow_hardening_diagnostic') {
8706
+ nextSteps = { fulfillment: 'Payment confirmed. Diagnostic fulfillment is processing; an email will arrive when it completes.' };
8707
+ }
8454
8708
 
8455
8709
  sendJson(res, 200, {
8456
8710
  ...result,
@@ -8504,6 +8758,25 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8504
8758
  return;
8505
8759
  }
8506
8760
 
8761
+ // Provisioned hosted keys are authenticated identities, not merely valid
8762
+ // booleans. Bind every private data path to a hash of the billing customer
8763
+ // before any endpoint reads or writes feedback, documents, traces, or jobs.
8764
+ // Static admin/local operation retains the existing single-tenant path.
8765
+ const requestDataIdentity = resolveRequestDataIdentity(req, expectedApiKey);
8766
+ if (requestDataIdentity.invalidHostedIdentity) {
8767
+ sendProblem(res, {
8768
+ type: PROBLEM_TYPES.UNAUTHORIZED,
8769
+ title: 'Unauthorized',
8770
+ status: 403,
8771
+ detail: 'The hosted API key is not bound to a customer identity.',
8772
+ });
8773
+ return;
8774
+ }
8775
+ requestFeedbackPaths = partitionFeedbackPaths(requestFeedbackPaths, requestDataIdentity);
8776
+ requestFeedbackDir = requestFeedbackPaths.FEEDBACK_DIR;
8777
+ requestSafeDataDir = path.resolve(requestFeedbackDir);
8778
+ requestDocumentAccessContext = requestDataIdentity.accessContext;
8779
+
8507
8780
  // Usage metering — record request for billing keys (not static THUMBGATE_API_KEY)
8508
8781
  const _token = extractBearerToken(req);
8509
8782
  if (_token && _token !== expectedApiKey) {
@@ -8514,7 +8787,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8514
8787
  if (req.method === 'GET' && pathname === '/v1/feedback/stats') {
8515
8788
  const stats = shouldAggregateFeedback()
8516
8789
  ? computeAggregateFeedbackStats({ feedbackDir: requestFeedbackPaths.FEEDBACK_DIR })
8517
- : analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH);
8790
+ : analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH, { humanOnly: true });
8518
8791
  try {
8519
8792
  const { getStatuslineMeta } = require('../../scripts/statusline-meta');
8520
8793
  const meta = getStatuslineMeta({ env: process.env });
@@ -8524,6 +8797,33 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8524
8797
  stats.tier = 'Pro';
8525
8798
  }
8526
8799
 
8800
+ // Active gates for the dashboard hero cards. Connect only fetches
8801
+ // /v1/feedback/stats first; without these fields Active Gates stays
8802
+ // stuck on the HTML placeholder "—" until a later /v1/dashboard call
8803
+ // succeeds (and never updates if that call fails).
8804
+ try {
8805
+ const { computeGateStats } = require('../../scripts/dashboard');
8806
+ const gateStats = computeGateStats();
8807
+ stats.activeGateCount = Number(gateStats.totalGates) || 0;
8808
+ stats.gateStats = {
8809
+ totalGates: Number(gateStats.totalGates) || 0,
8810
+ manualCount: Number(gateStats.manualCount) || 0,
8811
+ autoCount: Number(gateStats.autoCount) || 0,
8812
+ blocked: Number(gateStats.blocked) || 0,
8813
+ warned: Number(gateStats.warned) || 0,
8814
+ };
8815
+ } catch (error) {
8816
+ debugApiFallback('gate stats unavailable for feedback/stats', error);
8817
+ stats.activeGateCount = 0;
8818
+ stats.gateStats = {
8819
+ totalGates: 0,
8820
+ manualCount: 0,
8821
+ autoCount: 0,
8822
+ blocked: 0,
8823
+ warned: 0,
8824
+ };
8825
+ }
8826
+
8527
8827
  const projectChatSettings = readProjectChatSettings(req, parsed);
8528
8828
 
8529
8829
  stats.geminiConfigured = Boolean(
@@ -9034,6 +9334,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9034
9334
  workflowContract: body.workflowContract,
9035
9335
  repoPath: body.repoPath,
9036
9336
  localOnly: body.localOnly === true,
9337
+ ttlMs: body.ttlMs,
9037
9338
  clear: body.clear === true,
9038
9339
  });
9039
9340
  sendJson(res, 200, { scope });
@@ -9116,7 +9417,8 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9116
9417
 
9117
9418
  if (req.method === 'GET' && pathname === '/v1/task-outcomes/monitor') {
9118
9419
  const outcomes = readTaskOutcomes({ feedbackDir: requestFeedbackDir });
9119
- sendJson(res, 200, monitorTaskOutcomes(outcomes));
9420
+ const toolKpis = computeToolKpis({ feedbackDir: requestFeedbackDir });
9421
+ sendJson(res, 200, monitorProductionSignals(outcomes, toolKpis));
9120
9422
  return;
9121
9423
  }
9122
9424
 
@@ -9221,13 +9523,19 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9221
9523
  const signal = parsed.searchParams.get('signal') || null;
9222
9524
  let results;
9223
9525
  try {
9224
- const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
9225
- results = searchThumbgate({
9526
+ results = await searchThumbgateAsync({
9226
9527
  query,
9227
9528
  limit: Number.isFinite(limit) ? limit : 10,
9228
9529
  source,
9229
9530
  signal,
9230
- feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
9531
+ feedbackDir: requestFeedbackDir,
9532
+ metadataFilters: {
9533
+ tags: (parsed.searchParams.get('tags') || '').split(',').map((tag) => tag.trim()).filter(Boolean),
9534
+ sourceFormat: parsed.searchParams.get('sourceFormat') || undefined,
9535
+ sourceType: parsed.searchParams.get('sourceType') || undefined,
9536
+ },
9537
+ queryRewrite: parsed.searchParams.get('queryRewrite') !== 'false',
9538
+ accessContext: requestDocumentAccessContext,
9231
9539
  });
9232
9540
  } catch (err) {
9233
9541
  throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
@@ -9245,13 +9553,15 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9245
9553
  const body = await parseJsonBody(req);
9246
9554
  let results;
9247
9555
  try {
9248
- const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
9249
- results = searchThumbgate({
9556
+ results = await searchThumbgateAsync({
9250
9557
  query: body.query || body.q || '',
9251
9558
  limit: body.limit,
9252
9559
  source: body.source,
9253
9560
  signal: body.signal,
9254
- feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
9561
+ feedbackDir: requestFeedbackDir,
9562
+ metadataFilters: body.filters,
9563
+ queryRewrite: body.queryRewrite !== false,
9564
+ accessContext: requestDocumentAccessContext,
9255
9565
  });
9256
9566
  } catch (err) {
9257
9567
  throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
@@ -9269,6 +9579,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9269
9579
  limit: Number.isFinite(limit) ? limit : 20,
9270
9580
  query,
9271
9581
  tag,
9582
+ accessContext: requestDocumentAccessContext,
9272
9583
  });
9273
9584
  sendJson(res, 200, results);
9274
9585
  return;
@@ -9282,6 +9593,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9282
9593
  }
9283
9594
  const document = readImportedDocument(documentId, {
9284
9595
  feedbackDir: requestFeedbackDir,
9596
+ accessContext: requestDocumentAccessContext,
9285
9597
  });
9286
9598
  if (!document) {
9287
9599
  throw createHttpError(404, `Imported document not found: ${escapeHtml(documentId)}`);
@@ -9329,6 +9641,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9329
9641
  guardrails: body.guardrails,
9330
9642
  tags: extractTags(body.tags),
9331
9643
  skill: body.skill,
9644
+ reviewOrigin: 'human',
9332
9645
  });
9333
9646
  const actionIntegration = inferActionIntegration(body, req.headers);
9334
9647
  appendBestEffortTelemetry(requestFeedbackDir, {
@@ -9413,6 +9726,11 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9413
9726
  tags: extractTags(body.tags),
9414
9727
  proposeGates: body.proposeGates !== false,
9415
9728
  feedbackDir: requestFeedbackDir,
9729
+ accessContext: requestDocumentAccessContext,
9730
+ visibility: body.visibility === 'private' ? 'private' : 'tenant',
9731
+ allowedPrincipals: Array.isArray(body.allowedPrincipals)
9732
+ ? body.allowedPrincipals.map((value) => String(value || '').trim()).filter(Boolean)
9733
+ : [],
9416
9734
  });
9417
9735
  sendJson(res, 201, {
9418
9736
  ok: true,
@@ -9463,14 +9781,19 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9463
9781
  fs.writeFileSync(paths.outputPath, result.jsonl);
9464
9782
  }
9465
9783
 
9466
- sendJson(res, 200, {
9784
+ const response = {
9467
9785
  pairs: result.pairs.length,
9786
+ pairCount: result.pairs.length,
9468
9787
  errors: result.errors.length,
9469
9788
  learnings: result.learnings.length,
9470
9789
  unpairedErrors: result.unpairedErrors.length,
9471
9790
  unpairedLearnings: result.unpairedLearnings.length,
9472
9791
  outputPath: paths.outputPath,
9473
- });
9792
+ };
9793
+ if (body.includePairs === true) {
9794
+ response.records = result.pairs;
9795
+ }
9796
+ sendJson(res, 200, response);
9474
9797
  return;
9475
9798
  }
9476
9799
 
@@ -10496,6 +10819,8 @@ module.exports = {
10496
10819
  buildLossAnalyticsResponse,
10497
10820
  buildIntakeAlertRateLimitKey,
10498
10821
  sanitizeHtmlUnsafeJsonValue,
10822
+ buildHostedTenantIdentity,
10823
+ partitionFeedbackPaths,
10499
10824
  },
10500
10825
  };
10501
10826