thumbgate 1.23.1 → 1.25.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 +5 -5
- package/.claude-plugin/plugin.json +2 -2
- package/.well-known/llms.txt +26 -11
- package/.well-known/mcp/server-card.json +8 -8
- package/README.md +84 -38
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +1 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +39 -16
- package/bin/postinstall.js +11 -22
- package/config/gate-templates.json +72 -0
- package/config/github-about.json +1 -1
- package/config/post-deploy-marketing-pages.json +6 -1
- package/package.json +20 -8
- package/public/agent-manager.html +3 -3
- package/public/agents-cost-savings.html +3 -3
- package/public/ai-malpractice-prevention.html +335 -7
- package/public/blog.html +3 -3
- package/public/codex-enterprise.html +3 -3
- package/public/codex-plugin.html +4 -4
- package/public/compare.html +6 -6
- package/public/dashboard.html +211 -126
- package/public/guide.html +5 -5
- package/public/index.html +156 -47
- package/public/learn.html +24 -10
- package/public/lessons.html +2 -2
- package/public/numbers.html +6 -6
- package/public/pricing.html +6 -5
- package/public/pro.html +1 -0
- package/scripts/billing.js +17 -0
- package/scripts/commercial-offer.js +4 -1
- package/scripts/dashboard.js +53 -1
- package/scripts/gates-engine.js +101 -16
- package/scripts/mcp-oauth.js +293 -0
- package/scripts/plausible-server-events.js +2 -1
- package/scripts/rate-limiter.js +16 -12
- package/scripts/security-scanner.js +80 -10
- package/scripts/seo-gsd.js +167 -1
- package/scripts/telemetry-analytics.js +310 -0
- package/scripts/tool-registry.js +35 -1
- package/scripts/vector-store.js +1 -0
- package/scripts/visitor-journey.js +172 -0
- package/src/api/server.js +226 -31
- package/adapters/chatgpt/openapi.yaml +0 -1705
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const STAGE_ORDER = [
|
|
4
|
+
'landing',
|
|
5
|
+
'pricing',
|
|
6
|
+
'checkout_viewed',
|
|
7
|
+
'email_submitted',
|
|
8
|
+
'stripe_redirect',
|
|
9
|
+
'purchase',
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
function parseTimestamp(row) {
|
|
13
|
+
const raw = row && (row.timestamp || row.receivedAt || row.ts);
|
|
14
|
+
const ms = raw ? Date.parse(raw) : NaN;
|
|
15
|
+
return Number.isFinite(ms) ? ms : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function pickText(...values) {
|
|
19
|
+
for (const value of values) {
|
|
20
|
+
const text = String(value || '').trim();
|
|
21
|
+
if (text) return text;
|
|
22
|
+
}
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function extractSessionId(row) {
|
|
27
|
+
return pickText(
|
|
28
|
+
row.sessionId,
|
|
29
|
+
row.visitorSessionId,
|
|
30
|
+
row.visitor_session_id,
|
|
31
|
+
row.attribution && row.attribution.sessionId,
|
|
32
|
+
row.metadata && row.metadata.sessionId,
|
|
33
|
+
row.stripeSessionId,
|
|
34
|
+
row.traceId,
|
|
35
|
+
row.acquisitionId,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function extractVisitorId(row) {
|
|
40
|
+
return pickText(
|
|
41
|
+
row.visitorId,
|
|
42
|
+
row.visitor_id,
|
|
43
|
+
row.installId,
|
|
44
|
+
row.attribution && row.attribution.visitorId,
|
|
45
|
+
row.metadata && row.metadata.visitorId,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function extractPath(row) {
|
|
50
|
+
return pickText(
|
|
51
|
+
row.path,
|
|
52
|
+
row.page,
|
|
53
|
+
row.landingPath,
|
|
54
|
+
row.landing_path,
|
|
55
|
+
row.attribution && row.attribution.landingPath,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function classifyStage(row) {
|
|
60
|
+
const event = String(row.eventType || row.event || '').toLowerCase();
|
|
61
|
+
const stage = String(row.stage || '').toLowerCase();
|
|
62
|
+
const path = extractPath(row).toLowerCase();
|
|
63
|
+
const cta = String(row.ctaId || row.cta_id || '').toLowerCase();
|
|
64
|
+
|
|
65
|
+
if (/purchase|paid|checkout\.session\.completed/.test(event) || stage === 'purchase') return 'purchase';
|
|
66
|
+
if (/stripe_redirect|stripe redirect|redirect started/.test(event) || stage === 'stripe_redirect') return 'stripe_redirect';
|
|
67
|
+
if (/email_submitted|email submitted|checkout_interstitial_cta_clicked/.test(event)) return 'email_submitted';
|
|
68
|
+
if (/checkout/.test(path) || /checkout.*view|checkout pro viewed/.test(event)) return 'checkout_viewed';
|
|
69
|
+
if (/pricing/.test(path) || /pricing/.test(cta) || /pricing_cta/.test(event)) return 'pricing';
|
|
70
|
+
if (/landing|page_view|landing_page_view|discovery/.test(event) || stage === 'discovery') return 'landing';
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function stageRank(stage) {
|
|
75
|
+
const index = STAGE_ORDER.indexOf(stage);
|
|
76
|
+
return index === -1 ? -1 : index;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function nextMissingStage(maxStage) {
|
|
80
|
+
const index = stageRank(maxStage);
|
|
81
|
+
if (index < 0) return 'unknown';
|
|
82
|
+
if (maxStage === 'purchase') return 'converted';
|
|
83
|
+
return STAGE_ORDER[index + 1] || 'unknown';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildVisitorJourneySummary({ telemetryRows = [], funnelRows = [], limit = 100 } = {}) {
|
|
87
|
+
const sessions = new Map();
|
|
88
|
+
const stageCounts = Object.fromEntries(STAGE_ORDER.map((stage) => [stage, 0]));
|
|
89
|
+
|
|
90
|
+
function ingest(row, source) {
|
|
91
|
+
const ts = parseTimestamp(row);
|
|
92
|
+
if (ts == null) return;
|
|
93
|
+
const sessionId = extractSessionId(row) || `anonymous:${extractVisitorId(row) || ts}`;
|
|
94
|
+
const visitorId = extractVisitorId(row) || null;
|
|
95
|
+
const stage = classifyStage(row);
|
|
96
|
+
const path = extractPath(row);
|
|
97
|
+
const existing = sessions.get(sessionId) || {
|
|
98
|
+
sessionId,
|
|
99
|
+
visitorId,
|
|
100
|
+
firstSeen: new Date(ts).toISOString(),
|
|
101
|
+
lastSeen: new Date(ts).toISOString(),
|
|
102
|
+
maxStage: null,
|
|
103
|
+
dropoffStage: 'unknown',
|
|
104
|
+
events: [],
|
|
105
|
+
paths: [],
|
|
106
|
+
ctas: [],
|
|
107
|
+
sources: [],
|
|
108
|
+
visitorType: row.visitorType || null,
|
|
109
|
+
utm: {},
|
|
110
|
+
referrerHost: pickText(row.referrerHost, row.referrer_host, row.referrer),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
existing.visitorId = existing.visitorId || visitorId;
|
|
114
|
+
existing.firstSeen = new Date(Math.min(Date.parse(existing.firstSeen), ts)).toISOString();
|
|
115
|
+
existing.lastSeen = new Date(Math.max(Date.parse(existing.lastSeen), ts)).toISOString();
|
|
116
|
+
existing.sources.push(source);
|
|
117
|
+
if (stage) {
|
|
118
|
+
stageCounts[stage] += 1;
|
|
119
|
+
if (stageRank(stage) > stageRank(existing.maxStage)) existing.maxStage = stage;
|
|
120
|
+
}
|
|
121
|
+
if (path && !existing.paths.includes(path)) existing.paths.push(path);
|
|
122
|
+
const cta = pickText(row.ctaId, row.cta_id);
|
|
123
|
+
if (cta && !existing.ctas.includes(cta)) existing.ctas.push(cta);
|
|
124
|
+
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']) {
|
|
125
|
+
const value = pickText(row[key], row.attribution && row.attribution[key]);
|
|
126
|
+
if (value && !existing.utm[key]) existing.utm[key] = value;
|
|
127
|
+
}
|
|
128
|
+
existing.events.push({
|
|
129
|
+
timestamp: new Date(ts).toISOString(),
|
|
130
|
+
source,
|
|
131
|
+
eventType: pickText(row.eventType, row.event, row.stage, 'unknown'),
|
|
132
|
+
stage,
|
|
133
|
+
path: path || null,
|
|
134
|
+
ctaId: cta || null,
|
|
135
|
+
});
|
|
136
|
+
sessions.set(sessionId, existing);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const row of telemetryRows) ingest(row, 'telemetry');
|
|
140
|
+
for (const row of funnelRows) ingest(row, 'funnel');
|
|
141
|
+
|
|
142
|
+
const journeys = [...sessions.values()].map((session) => {
|
|
143
|
+
session.sources = [...new Set(session.sources)];
|
|
144
|
+
session.events.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
|
145
|
+
session.maxStage = session.maxStage || 'unknown';
|
|
146
|
+
session.dropoffStage = nextMissingStage(session.maxStage);
|
|
147
|
+
session.eventCount = session.events.length;
|
|
148
|
+
return session;
|
|
149
|
+
}).sort((a, b) => Date.parse(b.lastSeen) - Date.parse(a.lastSeen));
|
|
150
|
+
|
|
151
|
+
const dropoffCounts = {};
|
|
152
|
+
for (const journey of journeys) {
|
|
153
|
+
dropoffCounts[journey.dropoffStage] = (dropoffCounts[journey.dropoffStage] || 0) + 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
generatedAt: new Date().toISOString(),
|
|
158
|
+
sessionCount: journeys.length,
|
|
159
|
+
stageCounts,
|
|
160
|
+
dropoffCounts,
|
|
161
|
+
journeys: journeys.slice(0, Math.max(1, Math.min(Number(limit) || 100, 500))),
|
|
162
|
+
truncated: journeys.length > Math.max(1, Math.min(Number(limit) || 100, 500)),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
STAGE_ORDER,
|
|
168
|
+
buildVisitorJourneySummary,
|
|
169
|
+
classifyStage,
|
|
170
|
+
extractSessionId,
|
|
171
|
+
extractVisitorId,
|
|
172
|
+
};
|
package/src/api/server.js
CHANGED
|
@@ -199,6 +199,10 @@ const {
|
|
|
199
199
|
} = require('../../scripts/rate-limiter');
|
|
200
200
|
const { sendProblem, PROBLEM_TYPES } = require('../../scripts/problem-detail');
|
|
201
201
|
const { TOOLS: MCP_TOOLS } = require('../../scripts/tool-registry');
|
|
202
|
+
const mcpOauth = require('../../scripts/mcp-oauth');
|
|
203
|
+
// OAuth 2.1 (PKCE) authorization-server state for the remote MCP connector
|
|
204
|
+
// (Claude Connectors Directory requires OAuth for authenticated services).
|
|
205
|
+
const oauthStore = mcpOauth.createStore();
|
|
202
206
|
const resendMailer = require('../../scripts/mailer/resend-mailer');
|
|
203
207
|
const {
|
|
204
208
|
buildContextFootprintReport,
|
|
@@ -748,16 +752,23 @@ function updateLessonRecord(feedbackDir, lessonId, updater) {
|
|
|
748
752
|
function getPublicMcpTools() {
|
|
749
753
|
return MCP_TOOLS.map((tool) => ({
|
|
750
754
|
name: tool.name,
|
|
755
|
+
...(tool.title ? { title: tool.title } : {}),
|
|
751
756
|
description: tool.description,
|
|
752
757
|
inputSchema: tool.inputSchema,
|
|
758
|
+
// Serve the tool-registry annotations (readOnlyHint/destructiveHint). Required
|
|
759
|
+
// by the Claude Connectors Directory (missing annotations = the #1 rejection
|
|
760
|
+
// cause) and used by MCP clients for permission prompts. Was being dropped here.
|
|
761
|
+
...(tool.annotations ? { annotations: tool.annotations } : {}),
|
|
753
762
|
}));
|
|
754
763
|
}
|
|
755
764
|
|
|
756
765
|
function getServerCardTools() {
|
|
757
766
|
return MCP_TOOLS.map((tool) => ({
|
|
758
767
|
name: tool.name,
|
|
768
|
+
...(tool.title ? { title: tool.title } : {}),
|
|
759
769
|
description: tool.description,
|
|
760
770
|
inputSchema: tool.inputSchema,
|
|
771
|
+
...(tool.annotations ? { annotations: tool.annotations } : {}),
|
|
761
772
|
}));
|
|
762
773
|
}
|
|
763
774
|
|
|
@@ -1532,15 +1543,8 @@ function buildCheckoutIntentHref(baseUrl, metadata = {}, overrides = {}) {
|
|
|
1532
1543
|
});
|
|
1533
1544
|
}
|
|
1534
1545
|
|
|
1535
|
-
function renderCheckoutIntentPage({
|
|
1536
|
-
|
|
1537
|
-
confirmHiddenParams,
|
|
1538
|
-
}) {
|
|
1539
|
-
const safeWorkflowIntakeHref = escapeHtmlAttribute(workflowIntakeHref);
|
|
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>`;
|
|
1546
|
+
function renderCheckoutIntentPage() {
|
|
1547
|
+
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><script defer data-domain="thumbgate.ai" src="https://plausible.io/js/script.tagged-events.js"></script><script>window.plausible=window.plausible||function(){(window.plausible.q=window.plausible.q||[]).push(arguments)};</script><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"><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="/#workflow-sprint-intake">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'}));try{window.plausible&&window.plausible('Checkout Pro Email Submitted',{props:{page:'/checkout/pro',source:'interstitial'}})}catch(_){}})</script></html>`;
|
|
1544
1548
|
}
|
|
1545
1549
|
|
|
1546
1550
|
function buildCheckoutBootstrapBody(parsed, req, journeyState = resolveJourneyState(req, parsed)) {
|
|
@@ -2522,6 +2526,16 @@ function renderRobotsTxt(runtimeConfig) {
|
|
|
2522
2526
|
'Disallow: /v1/billing/',
|
|
2523
2527
|
'',
|
|
2524
2528
|
'# AI crawler access — allow all major LLM crawlers',
|
|
2529
|
+
'User-agent: OAI-SearchBot',
|
|
2530
|
+
'Allow: /',
|
|
2531
|
+
'Disallow: /checkout/',
|
|
2532
|
+
'Disallow: /v1/billing/',
|
|
2533
|
+
'',
|
|
2534
|
+
'User-agent: ChatGPT-User',
|
|
2535
|
+
'Allow: /',
|
|
2536
|
+
'Disallow: /checkout/',
|
|
2537
|
+
'Disallow: /v1/billing/',
|
|
2538
|
+
'',
|
|
2525
2539
|
'User-agent: GPTBot',
|
|
2526
2540
|
'Allow: /',
|
|
2527
2541
|
'Disallow: /checkout/',
|
|
@@ -2530,9 +2544,18 @@ function renderRobotsTxt(runtimeConfig) {
|
|
|
2530
2544
|
'User-agent: ClaudeBot',
|
|
2531
2545
|
'Allow: /',
|
|
2532
2546
|
'',
|
|
2547
|
+
'User-agent: Claude-SearchBot',
|
|
2548
|
+
'Allow: /',
|
|
2549
|
+
'',
|
|
2550
|
+
'User-agent: Claude-User',
|
|
2551
|
+
'Allow: /',
|
|
2552
|
+
'',
|
|
2533
2553
|
'User-agent: PerplexityBot',
|
|
2534
2554
|
'Allow: /',
|
|
2535
2555
|
'',
|
|
2556
|
+
'User-agent: Perplexity-User',
|
|
2557
|
+
'Allow: /',
|
|
2558
|
+
'',
|
|
2536
2559
|
'User-agent: Googlebot',
|
|
2537
2560
|
'Allow: /',
|
|
2538
2561
|
'',
|
|
@@ -2547,6 +2570,7 @@ function renderRobotsTxt(runtimeConfig) {
|
|
|
2547
2570
|
'',
|
|
2548
2571
|
'# LLM context document — clean declarative content for AI retrieval',
|
|
2549
2572
|
`# ${runtimeConfig.appOrigin}/llm-context.md`,
|
|
2573
|
+
`# ${runtimeConfig.appOrigin}/llms.txt`,
|
|
2550
2574
|
'',
|
|
2551
2575
|
`Sitemap: ${runtimeConfig.appOrigin}/sitemap.xml`,
|
|
2552
2576
|
].join('\n');
|
|
@@ -2562,6 +2586,15 @@ function renderSitemapXml(runtimeConfig) {
|
|
|
2562
2586
|
{ path: '/codex-enterprise', changefreq: 'weekly', priority: '0.85' },
|
|
2563
2587
|
{ path: '/agents-cost-savings', changefreq: 'weekly', priority: '0.85' },
|
|
2564
2588
|
{ path: '/ai-malpractice-prevention', changefreq: 'weekly', priority: '0.9' },
|
|
2589
|
+
{ path: '/learn/background-agent-control-layer', changefreq: 'weekly', priority: '0.85' },
|
|
2590
|
+
{ path: '/learn/ac-dc-runtime-enforcement', changefreq: 'weekly', priority: '0.85' },
|
|
2591
|
+
{ path: '/learn/feedback-loop-vs-decision-layer', changefreq: 'weekly', priority: '0.9' },
|
|
2592
|
+
{ path: '/compare/claude-code-hooks', changefreq: 'weekly', priority: '0.85' },
|
|
2593
|
+
{ path: '/compare/bumblebee', changefreq: 'weekly', priority: '0.85' },
|
|
2594
|
+
{ path: '/compare/anthropic-containment', changefreq: 'weekly', priority: '0.85' },
|
|
2595
|
+
{ path: '/compare/oak-and-sparrow-gatekeeper', changefreq: 'weekly', priority: '0.85' },
|
|
2596
|
+
{ path: '/compare/arcjet', changefreq: 'weekly', priority: '0.85' },
|
|
2597
|
+
{ path: '/compare/anthropic-claude-for-legal', changefreq: 'weekly', priority: '0.9' },
|
|
2565
2598
|
...THUMBGATE_SEO_SITEMAP_ENTRIES,
|
|
2566
2599
|
];
|
|
2567
2600
|
return [
|
|
@@ -2833,7 +2866,8 @@ function renderCheckoutSuccessPage(runtimeConfig) {
|
|
|
2833
2866
|
</style>
|
|
2834
2867
|
<link rel="icon" type="image/png" href="/thumbgate-icon.png">
|
|
2835
2868
|
<link rel="apple-touch-icon" href="/assets/brand/thumbgate-mark.svg">
|
|
2836
|
-
<script defer data-domain="thumbgate-production.up.railway.app" src="https://plausible.io/js/script.js"></script>
|
|
2869
|
+
<script defer data-domain="thumbgate-production.up.railway.app" src="https://plausible.io/js/script.tagged-events.js"></script>
|
|
2870
|
+
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments); };</script>
|
|
2837
2871
|
</head>
|
|
2838
2872
|
<body>
|
|
2839
2873
|
<main>
|
|
@@ -3011,6 +3045,7 @@ function renderCheckoutSuccessPage(runtimeConfig) {
|
|
|
3011
3045
|
}
|
|
3012
3046
|
|
|
3013
3047
|
sendTelemetryOnce('checkout_paid_confirmed');
|
|
3048
|
+
try { window.plausible && window.plausible('Checkout Pro Success Page Confirmed', { props: { sessionId: sessionId || '', traceId: traceId || '', source: 'success_page' } }); } catch (_) {}
|
|
3014
3049
|
statusEl.textContent = 'ThumbGate Pro activated.';
|
|
3015
3050
|
const resolvedTraceId = body.traceId || traceId || '';
|
|
3016
3051
|
const emailStatus = body.trialEmail || {};
|
|
@@ -3836,12 +3871,56 @@ function createApiServer() {
|
|
|
3836
3871
|
tools: getPublicMcpTools(),
|
|
3837
3872
|
},
|
|
3838
3873
|
});
|
|
3874
|
+
} else if (msg.method === 'tools/call') {
|
|
3875
|
+
// Authenticated tool execution. Accept either an OAuth 2.1 access
|
|
3876
|
+
// token (audience-bound to this MCP server, RFC 8707) or a raw
|
|
3877
|
+
// ThumbGate API key, both via the Bearer header.
|
|
3878
|
+
const bearer = extractBearerToken(req);
|
|
3879
|
+
const resourceUrl = buildPublicUrl(hostedConfig, '/mcp');
|
|
3880
|
+
const oauthSession = mcpOauth.resolveAccessToken(oauthStore, bearer);
|
|
3881
|
+
// OAuth path: token must resolve AND be audience-bound to this server
|
|
3882
|
+
// (RFC 8707). Raw-key path: only an exact match to a configured
|
|
3883
|
+
// operator/admin key — never "any non-empty bearer".
|
|
3884
|
+
const adminKey = String(process.env.THUMBGATE_API_KEY || '').trim();
|
|
3885
|
+
const operatorKey = String(process.env.THUMBGATE_OPERATOR_KEY || '').trim();
|
|
3886
|
+
const rawKeyValid = Boolean(bearer) && ((adminKey && bearer === adminKey) || (operatorKey && bearer === operatorKey));
|
|
3887
|
+
const authed = oauthSession
|
|
3888
|
+
? mcpOauth.tokenAudienceValid(oauthSession, resourceUrl)
|
|
3889
|
+
: rawKeyValid;
|
|
3890
|
+
if (!authed) {
|
|
3891
|
+
res.writeHead(401, {
|
|
3892
|
+
'Content-Type': 'application/json',
|
|
3893
|
+
// RFC 9728: point unauthenticated clients at the resource metadata.
|
|
3894
|
+
'WWW-Authenticate': `Bearer resource_metadata="${buildPublicUrl(hostedConfig, '/.well-known/oauth-protected-resource')}"`,
|
|
3895
|
+
});
|
|
3896
|
+
res.end(JSON.stringify({
|
|
3897
|
+
jsonrpc: '2.0', id: msg.id,
|
|
3898
|
+
error: { code: -32001, message: 'Authentication required. Use OAuth 2.1 (see /.well-known/oauth-protected-resource) or a ThumbGate API key.' },
|
|
3899
|
+
}));
|
|
3900
|
+
return;
|
|
3901
|
+
}
|
|
3902
|
+
(async () => {
|
|
3903
|
+
try {
|
|
3904
|
+
const { callTool } = require('../../adapters/mcp/server-stdio');
|
|
3905
|
+
const name = msg.params && msg.params.name;
|
|
3906
|
+
const args = (msg.params && msg.params.arguments) || {};
|
|
3907
|
+
const result = await callTool(name, args);
|
|
3908
|
+
sendJson(res, 200, {
|
|
3909
|
+
jsonrpc: '2.0', id: msg.id,
|
|
3910
|
+
result: { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }] },
|
|
3911
|
+
});
|
|
3912
|
+
} catch (err) {
|
|
3913
|
+
sendJson(res, 200, {
|
|
3914
|
+
jsonrpc: '2.0', id: msg.id,
|
|
3915
|
+
result: { isError: true, content: [{ type: 'text', text: String(err && err.message || err) }] },
|
|
3916
|
+
});
|
|
3917
|
+
}
|
|
3918
|
+
})();
|
|
3839
3919
|
} else {
|
|
3840
|
-
// All other tool calls require auth — return method not found for unauthenticated
|
|
3841
3920
|
sendJson(res, 200, {
|
|
3842
3921
|
jsonrpc: '2.0',
|
|
3843
3922
|
id: msg.id,
|
|
3844
|
-
error: { code: -32601, message:
|
|
3923
|
+
error: { code: -32601, message: `Method not found: ${msg.method}` },
|
|
3845
3924
|
});
|
|
3846
3925
|
}
|
|
3847
3926
|
} catch (_e) {
|
|
@@ -4118,7 +4197,7 @@ function createApiServer() {
|
|
|
4118
4197
|
return;
|
|
4119
4198
|
}
|
|
4120
4199
|
|
|
4121
|
-
if (isGetLikeRequest && pathname === '/.well-known/llms.txt') {
|
|
4200
|
+
if (isGetLikeRequest && (pathname === '/.well-known/llms.txt' || pathname === '/llms.txt')) {
|
|
4122
4201
|
const llmsTxtPath = path.join(__dirname, '..', '..', '.well-known', 'llms.txt');
|
|
4123
4202
|
try {
|
|
4124
4203
|
const content = fs.readFileSync(llmsTxtPath, 'utf8');
|
|
@@ -4849,26 +4928,12 @@ async function addContext(){
|
|
|
4849
4928
|
ctaId: analyticsMetadata.ctaId,
|
|
4850
4929
|
ctaPlacement: analyticsMetadata.ctaPlacement,
|
|
4851
4930
|
planId: analyticsMetadata.planId,
|
|
4931
|
+
billingCycle: analyticsMetadata.billingCycle,
|
|
4932
|
+
landingPath: analyticsMetadata.landingPath,
|
|
4852
4933
|
isBot: botClassification.isBot ? 'true' : 'false',
|
|
4853
4934
|
reason: botClassification.reason,
|
|
4854
4935
|
}, req.headers, eventType);
|
|
4855
|
-
const
|
|
4856
|
-
utmMedium: 'checkout_interstitial_recovery',
|
|
4857
|
-
utmCampaign: analyticsMetadata.utmCampaign || 'checkout_interstitial_workflow_sprint',
|
|
4858
|
-
ctaId: 'checkout_interstitial_workflow_sprint_intake',
|
|
4859
|
-
ctaPlacement: 'checkout_interstitial',
|
|
4860
|
-
planId: 'team',
|
|
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
|
-
}
|
|
4868
|
-
const html = renderCheckoutIntentPage({
|
|
4869
|
-
workflowIntakeHref,
|
|
4870
|
-
confirmHiddenParams,
|
|
4871
|
-
});
|
|
4936
|
+
const html = renderCheckoutIntentPage();
|
|
4872
4937
|
sendHtml(res, 200, html, responseHeaders);
|
|
4873
4938
|
return;
|
|
4874
4939
|
}
|
|
@@ -5156,6 +5221,108 @@ async function addContext(){
|
|
|
5156
5221
|
return;
|
|
5157
5222
|
}
|
|
5158
5223
|
|
|
5224
|
+
// OAuth 2.1 discovery metadata for the remote MCP connector (RFC 9728 / RFC 8414).
|
|
5225
|
+
// Lets Claude discover how to authenticate before calling authenticated tools.
|
|
5226
|
+
if (isGetLikeRequest && pathname === '/.well-known/oauth-protected-resource') {
|
|
5227
|
+
sendJson(res, 200, mcpOauth.buildProtectedResourceMetadata(buildPublicUrl(hostedConfig, '')), {}, {
|
|
5228
|
+
headOnly: isHeadRequest,
|
|
5229
|
+
});
|
|
5230
|
+
return;
|
|
5231
|
+
}
|
|
5232
|
+
if (isGetLikeRequest && (pathname === '/.well-known/oauth-authorization-server' || pathname === '/.well-known/openid-configuration')) {
|
|
5233
|
+
sendJson(res, 200, mcpOauth.buildAuthServerMetadata(buildPublicUrl(hostedConfig, '')), {}, {
|
|
5234
|
+
headOnly: isHeadRequest,
|
|
5235
|
+
});
|
|
5236
|
+
return;
|
|
5237
|
+
}
|
|
5238
|
+
|
|
5239
|
+
// --- OAuth 2.1 (PKCE) endpoints for the remote MCP connector ---
|
|
5240
|
+
// RFC 7591 Dynamic Client Registration.
|
|
5241
|
+
if (req.method === 'POST' && pathname === '/oauth/register') {
|
|
5242
|
+
let body = '';
|
|
5243
|
+
req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
|
|
5244
|
+
req.on('end', () => {
|
|
5245
|
+
let parsed = {};
|
|
5246
|
+
try { parsed = body ? JSON.parse(body) : {}; } catch { /* ignore */ }
|
|
5247
|
+
const reg = mcpOauth.registerClient(oauthStore, parsed);
|
|
5248
|
+
if (reg.error) { sendJson(res, 400, reg); return; }
|
|
5249
|
+
sendJson(res, 201, reg);
|
|
5250
|
+
});
|
|
5251
|
+
return;
|
|
5252
|
+
}
|
|
5253
|
+
// Authorization endpoint: GET renders consent, POST issues the code.
|
|
5254
|
+
if (pathname === '/oauth/authorize') {
|
|
5255
|
+
if (isGetLikeRequest) {
|
|
5256
|
+
const q = parsed.searchParams;
|
|
5257
|
+
const fields = ['client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'resource'];
|
|
5258
|
+
const hidden = fields.map((f) => `<input type="hidden" name="${f}" value="${escapeHtmlAttribute(q.get(f) || '')}">`).join('\n');
|
|
5259
|
+
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Authorize ThumbGate</title>
|
|
5260
|
+
<style>body{font:15px system-ui;margin:0;background:#0b0b0c;color:#eee;display:flex;min-height:100vh;align-items:center;justify-content:center}
|
|
5261
|
+
.card{background:#161618;border:1px solid #2a2a2e;border-radius:12px;padding:28px;max-width:420px}
|
|
5262
|
+
input[type=password]{width:100%;padding:10px;margin:8px 0 16px;border-radius:8px;border:1px solid #2a2a2e;background:#0b0b0c;color:#eee}
|
|
5263
|
+
button{width:100%;padding:11px;border-radius:8px;border:0;background:#10b981;color:#04120c;font-weight:600;cursor:pointer}
|
|
5264
|
+
a{color:#8b9}</style></head><body><form class="card" method="post" action="/oauth/authorize">
|
|
5265
|
+
<h2>Authorize Claude → ThumbGate</h2>
|
|
5266
|
+
<p>Paste your ThumbGate API key to let this connector act as you. Get one with <code>npx thumbgate init</code> or from your <a href="/dashboard">dashboard</a>.</p>
|
|
5267
|
+
${hidden}
|
|
5268
|
+
<input type="password" name="api_key" placeholder="ThumbGate API key" autocomplete="off" required>
|
|
5269
|
+
<button type="submit" name="approve" value="yes">Approve</button>
|
|
5270
|
+
</form></body></html>`;
|
|
5271
|
+
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
5272
|
+
return;
|
|
5273
|
+
}
|
|
5274
|
+
if (req.method === 'POST') {
|
|
5275
|
+
let body = '';
|
|
5276
|
+
req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
|
|
5277
|
+
req.on('end', () => {
|
|
5278
|
+
const form = new URLSearchParams(body);
|
|
5279
|
+
const redirectUri = form.get('redirect_uri') || '';
|
|
5280
|
+
const state = form.get('state') || '';
|
|
5281
|
+
const issued = mcpOauth.createAuthorizationCode(oauthStore, {
|
|
5282
|
+
clientId: form.get('client_id') || '',
|
|
5283
|
+
redirectUri,
|
|
5284
|
+
codeChallenge: form.get('code_challenge') || '',
|
|
5285
|
+
codeChallengeMethod: form.get('code_challenge_method') || '',
|
|
5286
|
+
scope: form.get('scope') || undefined,
|
|
5287
|
+
resource: form.get('resource') || buildPublicUrl(hostedConfig, '/mcp'),
|
|
5288
|
+
boundKey: form.get('api_key') || '',
|
|
5289
|
+
state,
|
|
5290
|
+
});
|
|
5291
|
+
if (issued.error) {
|
|
5292
|
+
sendJson(res, 400, { error: issued.error, error_description: issued.error_description });
|
|
5293
|
+
return;
|
|
5294
|
+
}
|
|
5295
|
+
const sep = redirectUri.includes('?') ? '&' : '?';
|
|
5296
|
+
const loc = `${redirectUri}${sep}code=${encodeURIComponent(issued.code)}${state ? `&state=${encodeURIComponent(state)}` : ''}`;
|
|
5297
|
+
res.writeHead(302, { Location: loc });
|
|
5298
|
+
res.end();
|
|
5299
|
+
});
|
|
5300
|
+
return;
|
|
5301
|
+
}
|
|
5302
|
+
}
|
|
5303
|
+
// Token endpoint (authorization_code + PKCE).
|
|
5304
|
+
if (req.method === 'POST' && pathname === '/oauth/token') {
|
|
5305
|
+
let body = '';
|
|
5306
|
+
req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
|
|
5307
|
+
req.on('end', () => {
|
|
5308
|
+
const form = new URLSearchParams(body);
|
|
5309
|
+
if (form.get('grant_type') !== 'authorization_code') {
|
|
5310
|
+
sendJson(res, 400, { error: 'unsupported_grant_type' });
|
|
5311
|
+
return;
|
|
5312
|
+
}
|
|
5313
|
+
const tok = mcpOauth.exchangeCode(oauthStore, {
|
|
5314
|
+
code: form.get('code') || '',
|
|
5315
|
+
codeVerifier: form.get('code_verifier') || '',
|
|
5316
|
+
clientId: form.get('client_id') || '',
|
|
5317
|
+
redirectUri: form.get('redirect_uri') || '',
|
|
5318
|
+
resource: form.get('resource') || undefined,
|
|
5319
|
+
});
|
|
5320
|
+
if (tok.error) { sendJson(res, 400, tok); return; }
|
|
5321
|
+
sendJson(res, 200, tok, { 'Cache-Control': 'no-store' });
|
|
5322
|
+
});
|
|
5323
|
+
return;
|
|
5324
|
+
}
|
|
5325
|
+
|
|
5159
5326
|
if (isGetLikeRequest && pathname === '/.well-known/mcp/tools.json') {
|
|
5160
5327
|
sendJson(res, 200, {
|
|
5161
5328
|
name: 'thumbgate',
|
|
@@ -5597,6 +5764,7 @@ async function addContext(){
|
|
|
5597
5764
|
source,
|
|
5598
5765
|
telemetry: { rows: [], truncated: false, totalAfterSince: 0 },
|
|
5599
5766
|
funnel: { rows: [], truncated: false, totalAfterSince: 0 },
|
|
5767
|
+
journeySummary: null,
|
|
5600
5768
|
};
|
|
5601
5769
|
|
|
5602
5770
|
function readJsonlSince(p) {
|
|
@@ -5644,6 +5812,19 @@ async function addContext(){
|
|
|
5644
5812
|
result.funnel.truncated = all.length > limit;
|
|
5645
5813
|
}
|
|
5646
5814
|
|
|
5815
|
+
try {
|
|
5816
|
+
const { buildVisitorJourneySummary } = require('../../scripts/visitor-journey');
|
|
5817
|
+
result.journeySummary = buildVisitorJourneySummary({
|
|
5818
|
+
telemetryRows: wantTelemetry ? result.telemetry.rows : [],
|
|
5819
|
+
funnelRows: wantFunnel ? result.funnel.rows : [],
|
|
5820
|
+
limit: Math.min(limit, 500),
|
|
5821
|
+
});
|
|
5822
|
+
} catch (err) {
|
|
5823
|
+
result.journeySummary = {
|
|
5824
|
+
error: err && err.message ? err.message : 'journey_summary_unavailable',
|
|
5825
|
+
};
|
|
5826
|
+
}
|
|
5827
|
+
|
|
5647
5828
|
sendJson(res, 200, result);
|
|
5648
5829
|
return;
|
|
5649
5830
|
}
|
|
@@ -6059,7 +6240,19 @@ async function addContext(){
|
|
|
6059
6240
|
|
|
6060
6241
|
// Public OpenAPI spec — no auth required (needed for ChatGPT GPT Store import)
|
|
6061
6242
|
if (isGetLikeRequest && (pathname === '/openapi.json' || pathname === '/openapi.yaml')) {
|
|
6062
|
-
const specPath =
|
|
6243
|
+
const specPath = [
|
|
6244
|
+
path.join(__dirname, '../../openapi/openapi.yaml'),
|
|
6245
|
+
path.join(__dirname, '../../adapters/chatgpt/openapi.yaml'),
|
|
6246
|
+
].find((candidate) => fs.existsSync(candidate));
|
|
6247
|
+
if (!specPath) {
|
|
6248
|
+
sendProblem(res, {
|
|
6249
|
+
type: PROBLEM_TYPES.NOT_FOUND,
|
|
6250
|
+
title: 'Not Found',
|
|
6251
|
+
status: 404,
|
|
6252
|
+
detail: 'OpenAPI spec not found.',
|
|
6253
|
+
});
|
|
6254
|
+
return;
|
|
6255
|
+
}
|
|
6063
6256
|
try {
|
|
6064
6257
|
const yaml = renderOpenApiYamlForRequest(fs.readFileSync(specPath, 'utf8'), req);
|
|
6065
6258
|
if (pathname === '/openapi.yaml') {
|
|
@@ -8036,6 +8229,8 @@ module.exports = {
|
|
|
8036
8229
|
renderPackagedLessonsHtml,
|
|
8037
8230
|
readOptionalPublicTemplate,
|
|
8038
8231
|
resolveLocalPageBootstrap,
|
|
8232
|
+
getPublicMcpTools,
|
|
8233
|
+
getServerCardTools,
|
|
8039
8234
|
},
|
|
8040
8235
|
};
|
|
8041
8236
|
|