thumbgate 1.21.2 → 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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +1 -0
- package/adapters/chatgpt/openapi.yaml +10 -0
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +109 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +247 -30
- package/config/mcp-allowlists.json +12 -6
- package/openapi/openapi.yaml +10 -0
- package/package.json +29 -5
- package/public/agent-manager.html +1 -1
- package/public/agents-cost-savings.html +151 -0
- package/public/ai-malpractice-prevention.html +183 -0
- package/public/codex-enterprise.html +123 -0
- package/public/codex-plugin.html +1 -1
- package/public/dashboard.html +18 -5
- package/public/index.html +13 -6
- package/public/lessons.html +34 -0
- package/public/numbers.html +2 -2
- package/public/pricing.html +1 -1
- package/scripts/auto-wire-hooks.js +14 -0
- package/scripts/build-metadata.js +32 -13
- package/scripts/cli-telemetry.js +6 -1
- package/scripts/gate-stats.js +89 -0
- package/scripts/gates-engine.js +133 -6
- package/scripts/hook-runtime.js +9 -3
- package/scripts/meta-agent-loop.js +32 -0
- package/scripts/pro-local-dashboard.js +4 -4
- package/scripts/rate-limiter.js +7 -1
- package/scripts/self-healing-check.js +193 -0
- package/scripts/silent-failure-cluster.js +512 -0
- package/scripts/telemetry-analytics.js +38 -0
- package/scripts/tool-registry.js +18 -0
- package/scripts/workflow-sentinel.js +6 -1
- package/src/api/server.js +311 -36
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, '/
|
|
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, '/
|
|
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, '/
|
|
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, '/
|
|
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, '/
|
|
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, '/
|
|
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, '/
|
|
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, '/
|
|
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, '/
|
|
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
|
-
|
|
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 <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';
|
|
@@ -2504,6 +2559,9 @@ function renderSitemapXml(runtimeConfig) {
|
|
|
2504
2559
|
{ path: '/agent-manager', changefreq: 'weekly', priority: '0.9' },
|
|
2505
2560
|
{ path: '/llm-context.md', changefreq: 'weekly', priority: '0.8' },
|
|
2506
2561
|
{ path: '/codex-plugin', changefreq: 'weekly', priority: '0.75' },
|
|
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' },
|
|
2507
2565
|
...THUMBGATE_SEO_SITEMAP_ENTRIES,
|
|
2508
2566
|
];
|
|
2509
2567
|
return [
|
|
@@ -4520,6 +4578,71 @@ async function addContext(){
|
|
|
4520
4578
|
return;
|
|
4521
4579
|
}
|
|
4522
4580
|
|
|
4581
|
+
if (isGetLikeRequest && (pathname === '/codex-enterprise' || pathname === '/codex-enterprise.html')) {
|
|
4582
|
+
// Landing page riding the 2026-05-20 OpenAI×Dell Codex Enterprise
|
|
4583
|
+
// partnership announcement. Dell-distributed Codex expands the TAM
|
|
4584
|
+
// for ThumbGate's governance layer — capture every agent decision,
|
|
4585
|
+
// promote repeat failures to PreToolUse gates, ship the audit trail
|
|
4586
|
+
// procurement requires. Routed through servePublicMarketingPage so
|
|
4587
|
+
// arrivals via the partnership news cycle capture UTM attribution
|
|
4588
|
+
// and landing_page_view telemetry with pageType: 'codex_enterprise'.
|
|
4589
|
+
try {
|
|
4590
|
+
servePublicMarketingPage({
|
|
4591
|
+
req,
|
|
4592
|
+
res,
|
|
4593
|
+
parsed,
|
|
4594
|
+
hostedConfig,
|
|
4595
|
+
isHeadRequest,
|
|
4596
|
+
renderHtml: () => fs.readFileSync(path.join(PUBLIC_DIR, 'codex-enterprise.html'), 'utf-8'),
|
|
4597
|
+
extraTelemetry: { pageType: 'codex_enterprise' },
|
|
4598
|
+
});
|
|
4599
|
+
} catch {
|
|
4600
|
+
sendJson(res, 404, { error: 'Codex Enterprise page not found' });
|
|
4601
|
+
}
|
|
4602
|
+
return;
|
|
4603
|
+
}
|
|
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
|
+
|
|
4523
4646
|
if (isGetLikeRequest && pathname === '/learn/learn.css') {
|
|
4524
4647
|
try {
|
|
4525
4648
|
const cssPath = path.join(LEARN_DIR, 'learn.css');
|
|
@@ -4638,7 +4761,7 @@ async function addContext(){
|
|
|
4638
4761
|
|
|
4639
4762
|
if (isGetLikeRequest && pathname === '/checkout/pro') {
|
|
4640
4763
|
if (isHeadRequest) {
|
|
4641
|
-
|
|
4764
|
+
sendHtml(res, 200, '', {}, {
|
|
4642
4765
|
headOnly: true,
|
|
4643
4766
|
});
|
|
4644
4767
|
return;
|
|
@@ -4736,10 +4859,15 @@ async function addContext(){
|
|
|
4736
4859
|
ctaPlacement: 'checkout_interstitial',
|
|
4737
4860
|
planId: 'team',
|
|
4738
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
|
+
}
|
|
4739
4868
|
const html = renderCheckoutIntentPage({
|
|
4740
|
-
confirmHref: buildCheckoutConfirmHref(parsed),
|
|
4741
4869
|
workflowIntakeHref,
|
|
4742
|
-
|
|
4870
|
+
confirmHiddenParams,
|
|
4743
4871
|
});
|
|
4744
4872
|
sendHtml(res, 200, html, responseHeaders);
|
|
4745
4873
|
return;
|
|
@@ -5140,40 +5268,57 @@ async function addContext(){
|
|
|
5140
5268
|
// was down, when feedback-dir was unwritable, when env was misconfigured.
|
|
5141
5269
|
// The fix is shallow but meaningful: probe each critical subsystem and
|
|
5142
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.
|
|
5143
5280
|
const checks = {};
|
|
5144
|
-
let
|
|
5281
|
+
let failing = false; // any service-failing check → 503
|
|
5282
|
+
let degraded = false; // any telemetry-degraded check → 200 + flag
|
|
5145
5283
|
|
|
5146
5284
|
// Check 1: feedback dir exists and is writable.
|
|
5285
|
+
// SERVICE-FAILING — if we can't write feedback, the API can't function.
|
|
5147
5286
|
try {
|
|
5148
5287
|
const { FEEDBACK_DIR } = getFeedbackPaths();
|
|
5149
5288
|
fs.accessSync(FEEDBACK_DIR, fs.constants.W_OK);
|
|
5150
5289
|
checks.feedbackDir = { ok: true };
|
|
5151
5290
|
} catch (err) {
|
|
5152
|
-
checks.feedbackDir = { ok: false, error: err?.code || 'inaccessible' };
|
|
5153
|
-
|
|
5291
|
+
checks.feedbackDir = { ok: false, error: err?.code || 'inaccessible', severity: 'failing' };
|
|
5292
|
+
failing = true;
|
|
5154
5293
|
}
|
|
5155
5294
|
|
|
5156
5295
|
// Check 2: hosted config resolves the canonical app origin.
|
|
5157
|
-
//
|
|
5296
|
+
// SERVICE-FAILING — if appOrigin is missing/empty, redirects + checkout
|
|
5297
|
+
// flow break silently.
|
|
5158
5298
|
if (hostedConfig?.appOrigin) {
|
|
5159
5299
|
checks.hostedConfig = { ok: true };
|
|
5160
5300
|
} else {
|
|
5161
|
-
checks.hostedConfig = { ok: false, error: 'missing_appOrigin' };
|
|
5162
|
-
|
|
5301
|
+
checks.hostedConfig = { ok: false, error: 'missing_appOrigin', severity: 'failing' };
|
|
5302
|
+
failing = true;
|
|
5163
5303
|
}
|
|
5164
5304
|
|
|
5165
|
-
// Check 3: build metadata loaded.
|
|
5166
|
-
//
|
|
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.
|
|
5167
5310
|
if (BUILD_METADATA?.buildSha) {
|
|
5168
5311
|
checks.buildMetadata = { ok: true };
|
|
5169
5312
|
} else {
|
|
5170
|
-
checks.buildMetadata = { ok: false, error: 'missing_buildSha' };
|
|
5171
|
-
|
|
5313
|
+
checks.buildMetadata = { ok: false, error: 'missing_buildSha', severity: 'degraded' };
|
|
5314
|
+
degraded = true;
|
|
5172
5315
|
}
|
|
5173
5316
|
|
|
5174
|
-
const statusCode =
|
|
5317
|
+
const statusCode = failing ? 503 : 200;
|
|
5318
|
+
const status = failing ? 'failing' : (degraded ? 'degraded' : 'ok');
|
|
5175
5319
|
sendJson(res, statusCode, {
|
|
5176
|
-
status
|
|
5320
|
+
status,
|
|
5321
|
+
degraded: degraded || failing,
|
|
5177
5322
|
version: pkg.version,
|
|
5178
5323
|
buildSha: BUILD_METADATA.buildSha,
|
|
5179
5324
|
uptime: process.uptime(),
|
|
@@ -5271,15 +5416,25 @@ async function addContext(){
|
|
|
5271
5416
|
const bd = require('../../scripts/bot-detector');
|
|
5272
5417
|
const { FEEDBACK_DIR: metricsDir } = getFeedbackPaths();
|
|
5273
5418
|
const telemetryPath = path.join(metricsDir, 'telemetry-pings.jsonl');
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
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
|
+
}
|
|
5283
5438
|
|
|
5284
5439
|
const now = Date.now();
|
|
5285
5440
|
const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000;
|
|
@@ -5308,6 +5463,52 @@ async function addContext(){
|
|
|
5308
5463
|
byVisitorType[e.visitorType] = (byVisitorType[e.visitorType] || 0) + 1;
|
|
5309
5464
|
});
|
|
5310
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
|
+
|
|
5311
5512
|
sendJson(res, 200, {
|
|
5312
5513
|
allTime: {
|
|
5313
5514
|
total: classified.length,
|
|
@@ -5316,6 +5517,12 @@ async function addContext(){
|
|
|
5316
5517
|
owner: classified.filter(e => e.visitorType === 'owner').length,
|
|
5317
5518
|
ci: classified.filter(e => e.visitorType === 'ci').length,
|
|
5318
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,
|
|
5319
5526
|
},
|
|
5320
5527
|
last7Days: {
|
|
5321
5528
|
total: recent.length,
|
|
@@ -5324,6 +5531,8 @@ async function addContext(){
|
|
|
5324
5531
|
owner: recent.filter(e => e.visitorType === 'owner').length,
|
|
5325
5532
|
ci: recent.filter(e => e.visitorType === 'ci').length,
|
|
5326
5533
|
uniqueInstalls: recentInstallIds.size,
|
|
5534
|
+
activeInstalls: activeInstalls7d.size,
|
|
5535
|
+
uniqueSessions: uniqueSessions7d.size,
|
|
5327
5536
|
},
|
|
5328
5537
|
byEventType,
|
|
5329
5538
|
byVisitorType,
|
|
@@ -6234,6 +6443,14 @@ async function addContext(){
|
|
|
6234
6443
|
return;
|
|
6235
6444
|
}
|
|
6236
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
|
+
|
|
6237
6454
|
// Operator key is allowed to bypass the general admin gate for its dedicated endpoint
|
|
6238
6455
|
const _reqToken = extractApiKey(req);
|
|
6239
6456
|
const isOperatorBillingRequest = Boolean(expectedOperatorKey)
|
|
@@ -6815,6 +7032,18 @@ async function addContext(){
|
|
|
6815
7032
|
tags: extractTags(body.tags),
|
|
6816
7033
|
skill: body.skill,
|
|
6817
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');
|
|
6818
7047
|
if (result?.accepted) {
|
|
6819
7048
|
// Fan out to any connected dashboard clients so they re-render
|
|
6820
7049
|
// without polling. Non-sensitive summary only (no chat history,
|
|
@@ -7625,6 +7854,18 @@ async function addContext(){
|
|
|
7625
7854
|
});
|
|
7626
7855
|
report.actionId = evaluation.actionId;
|
|
7627
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');
|
|
7628
7869
|
sendJson(res, 200, report);
|
|
7629
7870
|
return;
|
|
7630
7871
|
}
|
|
@@ -7729,6 +7970,7 @@ function startServer({ port, host } = {}) {
|
|
|
7729
7970
|
const listenPort = Number(port ?? process.env.PORT ?? 8787);
|
|
7730
7971
|
const listenHost = String(host ?? process.env.HOST ?? '0.0.0.0').trim() || '0.0.0.0';
|
|
7731
7972
|
const server = createApiServer();
|
|
7973
|
+
registerGracefulShutdown(server);
|
|
7732
7974
|
return new Promise((resolve) => {
|
|
7733
7975
|
server.listen(listenPort, listenHost, () => {
|
|
7734
7976
|
const address = server.address();
|
|
@@ -7744,6 +7986,39 @@ function startServer({ port, host } = {}) {
|
|
|
7744
7986
|
});
|
|
7745
7987
|
}
|
|
7746
7988
|
|
|
7989
|
+
// Railway / Cloud Run / Kubernetes deploy rotations send SIGTERM to swap
|
|
7990
|
+
// containers. Without a handler, Node exits immediately — in-flight requests
|
|
7991
|
+
// are killed and the orchestrator may mark the container as "crashed" (instead
|
|
7992
|
+
// of "gracefully stopped"), wasting its restart-policy budget on a healthy
|
|
7993
|
+
// shutdown. Drain HTTP, give a deadline, then force-exit if anything hangs.
|
|
7994
|
+
function registerGracefulShutdown(server, { gracePeriodMs = 25_000 } = {}) {
|
|
7995
|
+
if (server[GRACEFUL_SHUTDOWN_KEY]) return;
|
|
7996
|
+
server[GRACEFUL_SHUTDOWN_KEY] = true;
|
|
7997
|
+
let shuttingDown = false;
|
|
7998
|
+
const stop = (signal) => {
|
|
7999
|
+
if (shuttingDown) return;
|
|
8000
|
+
shuttingDown = true;
|
|
8001
|
+
console.log(`[shutdown] ${signal} received — draining connections (deadline ${gracePeriodMs}ms)`);
|
|
8002
|
+
const forceTimer = setTimeout(() => {
|
|
8003
|
+
console.error('[shutdown] grace period elapsed — forcing exit');
|
|
8004
|
+
process.exit(1);
|
|
8005
|
+
}, gracePeriodMs);
|
|
8006
|
+
if (typeof forceTimer.unref === 'function') forceTimer.unref();
|
|
8007
|
+
server.close((err) => {
|
|
8008
|
+
if (err) {
|
|
8009
|
+
console.error('[shutdown] server.close error:', err.message);
|
|
8010
|
+
process.exit(1);
|
|
8011
|
+
}
|
|
8012
|
+
console.log('[shutdown] drained cleanly');
|
|
8013
|
+
process.exit(0);
|
|
8014
|
+
});
|
|
8015
|
+
};
|
|
8016
|
+
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
8017
|
+
process.on('SIGINT', () => stop('SIGINT'));
|
|
8018
|
+
}
|
|
8019
|
+
|
|
8020
|
+
const GRACEFUL_SHUTDOWN_KEY = Symbol.for('thumbgate.gracefulShutdownRegistered');
|
|
8021
|
+
|
|
7747
8022
|
module.exports = {
|
|
7748
8023
|
createApiServer,
|
|
7749
8024
|
startServer,
|