thumbgate 1.22.0 → 1.23.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.
package/src/api/server.js CHANGED
@@ -429,6 +429,46 @@ const TRACKED_LINK_TARGETS = Object.freeze({
429
429
  plan_id: 'team',
430
430
  },
431
431
  },
432
+ // Aliases: /go/team → same as /go/teams, /go/checkout → same as /go/pro,
433
+ // /go/trial → install guide (trial starts on init)
434
+ team: {
435
+ path: '/#workflow-sprint-intake',
436
+ ctaId: 'go_team',
437
+ ctaPlacement: 'link_router',
438
+ eventType: 'team_intake_started',
439
+ defaults: {
440
+ utm_source: 'website',
441
+ utm_medium: 'link_router',
442
+ utm_campaign: 'team_intake',
443
+ plan_id: 'team',
444
+ },
445
+ },
446
+ checkout: {
447
+ path: '/checkout/pro',
448
+ ctaId: 'go_checkout',
449
+ ctaPlacement: 'link_router',
450
+ eventType: 'cta_click',
451
+ defaults: {
452
+ utm_source: 'website',
453
+ utm_medium: 'link_router',
454
+ utm_campaign: 'pro_upgrade',
455
+ plan_id: 'pro',
456
+ billing_cycle: 'monthly',
457
+ },
458
+ allowCustomerEmail: true,
459
+ },
460
+ trial: {
461
+ path: '/guide',
462
+ ctaId: 'go_trial',
463
+ ctaPlacement: 'link_router',
464
+ eventType: 'trial_start_click',
465
+ defaults: {
466
+ utm_source: 'website',
467
+ utm_medium: 'link_router',
468
+ utm_campaign: 'trial_start',
469
+ plan_id: 'free_trial',
470
+ },
471
+ },
432
472
  install: {
433
473
  path: '/guide',
434
474
  ctaId: 'go_install',
@@ -757,7 +797,7 @@ function getMcpSkillManifests(hostedConfig) {
757
797
  'Inspect prevention_rules after repeats.',
758
798
  ],
759
799
  installCommand: 'npx thumbgate init',
760
- contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
800
+ contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
761
801
  proofUrl: VERIFICATION_EVIDENCE_URL,
762
802
  },
763
803
  {
@@ -784,7 +824,7 @@ function getMcpSkillManifests(hostedConfig) {
784
824
  'Evaluate NDCG@10 on visual hard negatives.',
785
825
  'Require artifact links before using retrieved evidence in claims.',
786
826
  ],
787
- contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
827
+ contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
788
828
  proofUrl: VERIFICATION_EVIDENCE_URL,
789
829
  },
790
830
  {
@@ -798,7 +838,7 @@ function getMcpSkillManifests(hostedConfig) {
798
838
  'Compact feedback context with anchors for proof-critical lessons.',
799
839
  'Record estimated token savings next to the workflow evidence.',
800
840
  ],
801
- contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
841
+ contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
802
842
  footprintUrl: buildPublicUrl(hostedConfig, '/.well-known/mcp/footprint.json'),
803
843
  proofUrl: VERIFICATION_EVIDENCE_URL,
804
844
  },
@@ -813,7 +853,7 @@ function getMcpSkillManifests(hostedConfig) {
813
853
  'Require baseline evals before adding autonomy or subagents.',
814
854
  'Classify tool risk before allowing writes, money movement, production changes, or outbound actions.',
815
855
  ],
816
- contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
856
+ contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
817
857
  proofUrl: VERIFICATION_EVIDENCE_URL,
818
858
  },
819
859
  {
@@ -827,7 +867,7 @@ function getMcpSkillManifests(hostedConfig) {
827
867
  'Grade goal inference separately from intervention timing.',
828
868
  'Block multi-app proactive writes until rollback and orchestration evidence exists.',
829
869
  ],
830
- contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
870
+ contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
831
871
  proofUrl: VERIFICATION_EVIDENCE_URL,
832
872
  },
833
873
  {
@@ -841,7 +881,7 @@ function getMcpSkillManifests(hostedConfig) {
841
881
  'Map every proxy metric to the real user objective.',
842
882
  'Require holdout or regression proof before treating benchmark gains as product gains.',
843
883
  ],
844
- contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
884
+ contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
845
885
  proofUrl: VERIFICATION_EVIDENCE_URL,
846
886
  },
847
887
  {
@@ -855,7 +895,7 @@ function getMcpSkillManifests(hostedConfig) {
855
895
  'Reproduce locally before claiming a fix.',
856
896
  'Open one focused PR with tests, proof, and transparent ThumbGate context only when relevant.',
857
897
  ],
858
- contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
898
+ contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
859
899
  proofUrl: VERIFICATION_EVIDENCE_URL,
860
900
  },
861
901
  {
@@ -869,7 +909,7 @@ function getMcpSkillManifests(hostedConfig) {
869
909
  'Route self-serve intent to the guide and team pain to Workflow Hardening Sprint intake.',
870
910
  'Block unsupported ad and landing-page claims before spend scales.',
871
911
  ],
872
- contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
912
+ contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
873
913
  proofUrl: VERIFICATION_EVIDENCE_URL,
874
914
  },
875
915
  ];
@@ -1003,7 +1043,7 @@ function getMcpDiscoveryManifest(hostedConfig) {
1003
1043
  footprint: getContextFootprintReport(hostedConfig),
1004
1044
  proof: {
1005
1045
  verificationEvidenceUrl: VERIFICATION_EVIDENCE_URL,
1006
- llmContextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
1046
+ llmContextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
1007
1047
  },
1008
1048
  };
1009
1049
  }
@@ -1493,12 +1533,14 @@ function buildCheckoutIntentHref(baseUrl, metadata = {}, overrides = {}) {
1493
1533
  }
1494
1534
 
1495
1535
  function renderCheckoutIntentPage({
1496
- confirmHref,
1497
1536
  workflowIntakeHref,
1537
+ confirmHiddenParams,
1498
1538
  }) {
1499
- const safeConfirmHref = escapeHtmlAttribute(confirmHref);
1500
1539
  const safeWorkflowIntakeHref = escapeHtmlAttribute(workflowIntakeHref);
1501
- 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>`;
1540
+ const hiddenFields = (confirmHiddenParams || [])
1541
+ .map(([k, v]) => `<input type="hidden" name="${escapeHtmlAttribute(k)}" value="${escapeHtmlAttribute(v)}">`)
1542
+ .join('');
1543
+ 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}form{margin:0}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}input[type=email]:focus{border-color:#22d3ee}input[type=email]::placeholder{color:#64748b}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%}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}.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}.email-note{font-size:12px;color:#64748b;margin:4px 0 0}</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><form action="/checkout/pro" method="GET" data-i="pro_checkout_confirmed">${hiddenFields}<input type="hidden" name="confirm" value="1"><input type="email" name="customer_email" placeholder="you@company.com" required autocomplete="email"><p class="email-note">Pre-fills your Stripe receipt. We only email if you ask.</p><button type="submit" class="primary">Pay $19/mo with Stripe →</button></form><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>document.querySelector('form').addEventListener('submit',e=>{if(navigator.sendBeacon)navigator.sendBeacon('/v1/telemetry/ping',new Blob([JSON.stringify({eventType:'checkout_interstitial_cta_clicked',clientType:'web',page:'/checkout/pro',ctaId:'pro_checkout_confirmed',ctaPlacement:'checkout_interstitial',customerEmail:document.querySelector('input[name=customer_email]').value})],{type:'application/json'}))})</script></html>`;
1502
1544
  }
1503
1545
 
1504
1546
  function buildCheckoutBootstrapBody(parsed, req, journeyState = resolveJourneyState(req, parsed)) {
@@ -1825,6 +1867,19 @@ function appendBestEffortTelemetry(feedbackDir, payload, headers, context) {
1825
1867
  }
1826
1868
  }
1827
1869
 
1870
+ function inferActionIntegration(body = {}, headers = {}) {
1871
+ const explicit = pickFirstText(body.integration, body.source, body.actionIntegration, body.provider);
1872
+ const userAgent = String(headers['user-agent'] || '').toLowerCase();
1873
+ if (/chatgpt|gpt[_-]?actions?|openai/.test(String(explicit || '').toLowerCase()) || /chatgpt|openai/.test(userAgent)) {
1874
+ return 'chatgpt_gpt';
1875
+ }
1876
+ return explicit || 'api';
1877
+ }
1878
+
1879
+ function chatgptActionEventType(integration, suffix) {
1880
+ return integration === 'chatgpt_gpt' ? `chatgpt_action_${suffix}` : `api_action_${suffix}`;
1881
+ }
1882
+
1828
1883
  function getPublicOrigin(req) {
1829
1884
  const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() || 'http';
1830
1885
  const host = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim() || 'localhost';
@@ -2505,6 +2560,8 @@ function renderSitemapXml(runtimeConfig) {
2505
2560
  { path: '/llm-context.md', changefreq: 'weekly', priority: '0.8' },
2506
2561
  { path: '/codex-plugin', changefreq: 'weekly', priority: '0.75' },
2507
2562
  { path: '/codex-enterprise', changefreq: 'weekly', priority: '0.85' },
2563
+ { path: '/agents-cost-savings', changefreq: 'weekly', priority: '0.85' },
2564
+ { path: '/ai-malpractice-prevention', changefreq: 'weekly', priority: '0.9' },
2508
2565
  ...THUMBGATE_SEO_SITEMAP_ENTRIES,
2509
2566
  ];
2510
2567
  return [
@@ -4545,6 +4602,47 @@ async function addContext(){
4545
4602
  return;
4546
4603
  }
4547
4604
 
4605
+ if (isGetLikeRequest && (pathname === '/agents-cost-savings' || pathname === '/agents-cost-savings.html')) {
4606
+ // FinOps-for-AI positioning page. Pairs with the `thumbgate cost` CLI
4607
+ // shipped in #2281: the CLI prints the dollar amount, this page is
4608
+ // the public-facing explanation of why "prevention" (ThumbGate's
4609
+ // PreToolUse gates) is a distinct category from "reporting" (Finout,
4610
+ // Helicone, Vantage, AgentOps). Reply-to-pitch surface for buyers
4611
+ // who get a FinOps-for-AI marketing email and need a frame.
4612
+ try {
4613
+ servePublicMarketingPage({
4614
+ req,
4615
+ res,
4616
+ parsed,
4617
+ hostedConfig,
4618
+ isHeadRequest,
4619
+ renderHtml: () => fs.readFileSync(path.join(PUBLIC_DIR, 'agents-cost-savings.html'), 'utf-8'),
4620
+ extraTelemetry: { pageType: 'agents_cost_savings' },
4621
+ });
4622
+ } catch {
4623
+ sendJson(res, 404, { error: 'Agents Cost Savings page not found' });
4624
+ }
4625
+ return;
4626
+ }
4627
+
4628
+ if (isGetLikeRequest && (pathname === '/ai-malpractice-prevention' || pathname === '/ai-malpractice-prevention.html')) {
4629
+ // Legal-vertical landing page (2026-05-21).
4630
+ try {
4631
+ servePublicMarketingPage({
4632
+ req,
4633
+ res,
4634
+ parsed,
4635
+ hostedConfig,
4636
+ isHeadRequest,
4637
+ renderHtml: () => fs.readFileSync(path.join(PUBLIC_DIR, 'ai-malpractice-prevention.html'), 'utf-8'),
4638
+ extraTelemetry: { pageType: 'ai_malpractice_prevention' },
4639
+ });
4640
+ } catch {
4641
+ sendJson(res, 404, { error: 'AI Malpractice Prevention page not found' });
4642
+ }
4643
+ return;
4644
+ }
4645
+
4548
4646
  if (isGetLikeRequest && pathname === '/learn/learn.css') {
4549
4647
  try {
4550
4648
  const cssPath = path.join(LEARN_DIR, 'learn.css');
@@ -4663,7 +4761,7 @@ async function addContext(){
4663
4761
 
4664
4762
  if (isGetLikeRequest && pathname === '/checkout/pro') {
4665
4763
  if (isHeadRequest) {
4666
- sendText(res, 200, '', {}, {
4764
+ sendHtml(res, 200, '', {}, {
4667
4765
  headOnly: true,
4668
4766
  });
4669
4767
  return;
@@ -4761,10 +4859,15 @@ async function addContext(){
4761
4859
  ctaPlacement: 'checkout_interstitial',
4762
4860
  planId: 'team',
4763
4861
  });
4862
+ const confirmHiddenParams = [];
4863
+ for (const [key, value] of parsed.searchParams.entries()) {
4864
+ if (key !== 'confirm' && key !== 'customer_email') {
4865
+ confirmHiddenParams.push([key, value]);
4866
+ }
4867
+ }
4764
4868
  const html = renderCheckoutIntentPage({
4765
- confirmHref: buildCheckoutConfirmHref(parsed),
4766
4869
  workflowIntakeHref,
4767
- botClassification,
4870
+ confirmHiddenParams,
4768
4871
  });
4769
4872
  sendHtml(res, 200, html, responseHeaders);
4770
4873
  return;
@@ -5165,40 +5268,57 @@ async function addContext(){
5165
5268
  // was down, when feedback-dir was unwritable, when env was misconfigured.
5166
5269
  // The fix is shallow but meaningful: probe each critical subsystem and
5167
5270
  // surface failures with HTTP 503 + a per-check breakdown.
5271
+ // Tiered failure classification — not all degradations should kill the
5272
+ // container. A *service-failing* check (feedback dir unwritable,
5273
+ // appOrigin missing) returns 503 → Railway's healthcheck fails → SIGTERM
5274
+ // → restart loop → outage (this exact failure mode took prod down
5275
+ // 2026-05-21 18:21Z → 19:30Z when BUILD_METADATA.buildSha came up empty
5276
+ // after a Railway env-var cleanup). A *telemetry-degraded* check (missing
5277
+ // buildSha) returns 200 with `degraded: true` so observability stays
5278
+ // visible but Railway doesn't kill an otherwise-healthy container over
5279
+ // a SHA gap.
5168
5280
  const checks = {};
5169
- let allOk = true;
5281
+ let failing = false; // any service-failing check → 503
5282
+ let degraded = false; // any telemetry-degraded check → 200 + flag
5170
5283
 
5171
5284
  // Check 1: feedback dir exists and is writable.
5285
+ // SERVICE-FAILING — if we can't write feedback, the API can't function.
5172
5286
  try {
5173
5287
  const { FEEDBACK_DIR } = getFeedbackPaths();
5174
5288
  fs.accessSync(FEEDBACK_DIR, fs.constants.W_OK);
5175
5289
  checks.feedbackDir = { ok: true };
5176
5290
  } catch (err) {
5177
- checks.feedbackDir = { ok: false, error: err?.code || 'inaccessible' };
5178
- allOk = false;
5291
+ checks.feedbackDir = { ok: false, error: err?.code || 'inaccessible', severity: 'failing' };
5292
+ failing = true;
5179
5293
  }
5180
5294
 
5181
5295
  // Check 2: hosted config resolves the canonical app origin.
5182
- // If appOrigin is missing/empty, redirects + checkout flow break silently.
5296
+ // SERVICE-FAILING — if appOrigin is missing/empty, redirects + checkout
5297
+ // flow break silently.
5183
5298
  if (hostedConfig?.appOrigin) {
5184
5299
  checks.hostedConfig = { ok: true };
5185
5300
  } else {
5186
- checks.hostedConfig = { ok: false, error: 'missing_appOrigin' };
5187
- allOk = false;
5301
+ checks.hostedConfig = { ok: false, error: 'missing_appOrigin', severity: 'failing' };
5302
+ failing = true;
5188
5303
  }
5189
5304
 
5190
- // Check 3: build metadata loaded. If BUILD_METADATA.buildSha is empty,
5191
- // Railway didn't inject the deploy SHA observability is degraded.
5305
+ // Check 3: build metadata loaded.
5306
+ // TELEMETRY-DEGRADED observability gap, not a runtime outage. The
5307
+ // container still serves requests fine; we just can't tag responses with
5308
+ // the deployed SHA. Surfaces the gap to monitors via `degraded: true`
5309
+ // without triggering Railway's SIGTERM-on-503 loop.
5192
5310
  if (BUILD_METADATA?.buildSha) {
5193
5311
  checks.buildMetadata = { ok: true };
5194
5312
  } else {
5195
- checks.buildMetadata = { ok: false, error: 'missing_buildSha' };
5196
- allOk = false;
5313
+ checks.buildMetadata = { ok: false, error: 'missing_buildSha', severity: 'degraded' };
5314
+ degraded = true;
5197
5315
  }
5198
5316
 
5199
- const statusCode = allOk ? 200 : 503;
5317
+ const statusCode = failing ? 503 : 200;
5318
+ const status = failing ? 'failing' : (degraded ? 'degraded' : 'ok');
5200
5319
  sendJson(res, statusCode, {
5201
- status: allOk ? 'ok' : 'degraded',
5320
+ status,
5321
+ degraded: degraded || failing,
5202
5322
  version: pkg.version,
5203
5323
  buildSha: BUILD_METADATA.buildSha,
5204
5324
  uptime: process.uptime(),
@@ -5296,15 +5416,25 @@ async function addContext(){
5296
5416
  const bd = require('../../scripts/bot-detector');
5297
5417
  const { FEEDBACK_DIR: metricsDir } = getFeedbackPaths();
5298
5418
  const telemetryPath = path.join(metricsDir, 'telemetry-pings.jsonl');
5299
- let entries = [];
5300
- try {
5301
- if (fs.existsSync(telemetryPath)) {
5302
- entries = fs.readFileSync(telemetryPath, 'utf8')
5303
- .split('\n').filter(Boolean)
5304
- .map(l => { try { return JSON.parse(l); } catch(_e) { return null; } })
5305
- .filter(Boolean);
5306
- }
5307
- } catch { entries = []; }
5419
+
5420
+ // Cache parsed entries for 60s to avoid re-parsing 72K+ JSON lines per request
5421
+ const cacheKey = '_metricsRealCache';
5422
+ const cacheTTL = 60_000;
5423
+ let entries;
5424
+ if (global[cacheKey] && (Date.now() - global[cacheKey].ts) < cacheTTL) {
5425
+ entries = global[cacheKey].entries;
5426
+ } else {
5427
+ entries = [];
5428
+ try {
5429
+ if (fs.existsSync(telemetryPath)) {
5430
+ entries = fs.readFileSync(telemetryPath, 'utf8')
5431
+ .split('\n').filter(Boolean)
5432
+ .map(l => { try { return JSON.parse(l); } catch(_e) { return null; } })
5433
+ .filter(Boolean);
5434
+ }
5435
+ } catch { entries = []; }
5436
+ global[cacheKey] = { entries, ts: Date.now() };
5437
+ }
5308
5438
 
5309
5439
  const now = Date.now();
5310
5440
  const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000;
@@ -5333,6 +5463,52 @@ async function addContext(){
5333
5463
  byVisitorType[e.visitorType] = (byVisitorType[e.visitorType] || 0) + 1;
5334
5464
  });
5335
5465
 
5466
+ // ---------------------------------------------------------------
5467
+ // Active user analytics: group by installId to distinguish real
5468
+ // users from bots/mirrors. "Active" = performed a meaningful CLI
5469
+ // action (capture, recall, search, gate-check, stats) — not just init.
5470
+ // ---------------------------------------------------------------
5471
+ // Only events that prove a human used the product beyond init.
5472
+ // cli_pro_view excluded: browsing the upgrade page is not "using" the product.
5473
+ // cli_gate_check excluded: runs as subprocess, never fires trackEvent().
5474
+ // cli_search excluded: event name doesn't exist in production telemetry.
5475
+ const ACTIVE_EVENTS = new Set([
5476
+ 'cli_capture', 'cli_recall', 'cli_stats',
5477
+ 'activation_first_rule_promoted',
5478
+ ]);
5479
+ const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
5480
+ const last30 = classified.filter(e => {
5481
+ const ts = e.timestamp || e.receivedAt;
5482
+ return ts && new Date(ts).getTime() > thirtyDaysAgo;
5483
+ });
5484
+
5485
+ // Per-install activity (all-time and recent windows)
5486
+ const activeInstalls7d = new Set();
5487
+ const activeInstalls30d = new Set();
5488
+ const allTimeActiveInstalls = new Set();
5489
+ const uniqueSessions7d = new Set();
5490
+ const uniqueSessions30d = new Set();
5491
+
5492
+ for (const e of classified) {
5493
+ if (!e.installId) continue;
5494
+ const et = e.eventType || '';
5495
+ if (!ACTIVE_EVENTS.has(et)) continue;
5496
+ if (e.visitorType === 'bot' || e.visitorType === 'ci') continue;
5497
+ allTimeActiveInstalls.add(e.installId);
5498
+ }
5499
+ for (const e of recent) {
5500
+ if (!e.installId) continue;
5501
+ if (e.visitorType === 'bot' || e.visitorType === 'ci') continue;
5502
+ if (ACTIVE_EVENTS.has(e.eventType || '')) activeInstalls7d.add(e.installId);
5503
+ if (e.sessionId) uniqueSessions7d.add(e.sessionId);
5504
+ }
5505
+ for (const e of last30) {
5506
+ if (!e.installId) continue;
5507
+ if (e.visitorType === 'bot' || e.visitorType === 'ci') continue;
5508
+ if (ACTIVE_EVENTS.has(e.eventType || '')) activeInstalls30d.add(e.installId);
5509
+ if (e.sessionId) uniqueSessions30d.add(e.sessionId);
5510
+ }
5511
+
5336
5512
  sendJson(res, 200, {
5337
5513
  allTime: {
5338
5514
  total: classified.length,
@@ -5341,6 +5517,12 @@ async function addContext(){
5341
5517
  owner: classified.filter(e => e.visitorType === 'owner').length,
5342
5518
  ci: classified.filter(e => e.visitorType === 'ci').length,
5343
5519
  uniqueInstalls: uniqueInstallIds.size,
5520
+ activeInstalls: allTimeActiveInstalls.size,
5521
+ },
5522
+ last30Days: {
5523
+ uniqueInstalls: new Set(last30.filter(e => e.installId).map(e => e.installId)).size,
5524
+ activeInstalls: activeInstalls30d.size,
5525
+ uniqueSessions: uniqueSessions30d.size,
5344
5526
  },
5345
5527
  last7Days: {
5346
5528
  total: recent.length,
@@ -5349,6 +5531,8 @@ async function addContext(){
5349
5531
  owner: recent.filter(e => e.visitorType === 'owner').length,
5350
5532
  ci: recent.filter(e => e.visitorType === 'ci').length,
5351
5533
  uniqueInstalls: recentInstallIds.size,
5534
+ activeInstalls: activeInstalls7d.size,
5535
+ uniqueSessions: uniqueSessions7d.size,
5352
5536
  },
5353
5537
  byEventType,
5354
5538
  byVisitorType,
@@ -6259,6 +6443,14 @@ async function addContext(){
6259
6443
  return;
6260
6444
  }
6261
6445
 
6446
+ // Catch non-API GET requests that didn't match any public page route above.
6447
+ // Without this, /about, /docs, /demo etc. fall through to the API auth gate
6448
+ // and return a raw JSON 401 instead of a user-friendly 404.
6449
+ if (isGetLikeRequest && !pathname.startsWith('/v1/') && !pathname.startsWith('/api/')) {
6450
+ 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>`);
6451
+ return;
6452
+ }
6453
+
6262
6454
  // Operator key is allowed to bypass the general admin gate for its dedicated endpoint
6263
6455
  const _reqToken = extractApiKey(req);
6264
6456
  const isOperatorBillingRequest = Boolean(expectedOperatorKey)
@@ -6840,6 +7032,18 @@ async function addContext(){
6840
7032
  tags: extractTags(body.tags),
6841
7033
  skill: body.skill,
6842
7034
  });
7035
+ const actionIntegration = inferActionIntegration(body, req.headers);
7036
+ appendBestEffortTelemetry(requestFeedbackDir, {
7037
+ eventType: chatgptActionEventType(actionIntegration, 'capture_feedback'),
7038
+ clientType: 'api',
7039
+ source: actionIntegration,
7040
+ integration: actionIntegration,
7041
+ actionOperation: 'captureFeedback',
7042
+ endpoint: '/v1/feedback/capture',
7043
+ actionStatus: result.accepted ? 'accepted' : 'clarification_required',
7044
+ accepted: Boolean(result.accepted),
7045
+ failureCode: result.accepted ? null : 'clarification_required',
7046
+ }, req.headers, 'api_action_capture_feedback');
6843
7047
  if (result?.accepted) {
6844
7048
  // Fan out to any connected dashboard clients so they re-render
6845
7049
  // without polling. Non-sensitive summary only (no chat history,
@@ -7650,6 +7854,18 @@ async function addContext(){
7650
7854
  });
7651
7855
  report.actionId = evaluation.actionId;
7652
7856
  if (report.decisionControl) report.decisionControl.actionId = evaluation.actionId;
7857
+ const actionIntegration = inferActionIntegration(body, req.headers);
7858
+ appendBestEffortTelemetry(requestFeedbackDir, {
7859
+ eventType: chatgptActionEventType(actionIntegration, 'evaluate_decision'),
7860
+ clientType: 'api',
7861
+ source: actionIntegration,
7862
+ integration: actionIntegration,
7863
+ actionOperation: 'evaluateDecision',
7864
+ endpoint: '/v1/decisions/evaluate',
7865
+ actionStatus: 'accepted',
7866
+ accepted: true,
7867
+ decisionMode: report.decisionControl && report.decisionControl.executionMode,
7868
+ }, req.headers, 'api_action_evaluate_decision');
7653
7869
  sendJson(res, 200, report);
7654
7870
  return;
7655
7871
  }