thumbgate 1.27.6 → 1.27.7
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/commands/thumbgate-blocked.md +27 -0
- package/.claude/commands/thumbgate-doctor.md +30 -0
- package/.claude/commands/thumbgate-guard.md +36 -0
- package/.claude/commands/thumbgate-protect.md +30 -0
- package/.claude/commands/thumbgate-rules.md +30 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/llms.txt +6 -2
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +49 -5
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/letta/README.md +41 -0
- package/adapters/letta/thumbgate-letta-adapter.js +133 -0
- package/adapters/mcp/server-stdio.js +16 -1
- package/adapters/opencode/opencode.json +1 -1
- package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
- package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
- package/bench/observability-eval-suite.json +26 -0
- package/bin/cli.js +180 -2
- package/bin/postinstall.js +1 -1
- package/config/gate-templates.json +84 -0
- package/config/gates/claim-verification.json +6 -0
- package/config/gates/default.json +20 -0
- package/config/github-about.json +1 -1
- package/config/model-candidates.json +50 -0
- package/package.json +65 -25
- package/public/agent-manager.html +41 -1
- package/public/agents-cost-savings.html +1 -1
- package/public/ai-malpractice-prevention.html +2 -1
- package/public/assets/brand/github-social-preview.png +0 -0
- package/public/assets/brand/thumbgate-icon-512.png +0 -0
- package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
- package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
- package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
- package/public/assets/brand/thumbgate-mark-inline.svg +15 -0
- package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
- package/public/assets/brand/thumbgate-mark-team.svg +26 -0
- package/public/assets/brand/thumbgate-mark.svg +15 -0
- package/public/assets/brand/thumbgate-wordmark.svg +20 -0
- package/public/assets/claude-thumbgate-statusbar.svg +8 -0
- package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
- package/public/assets/legal-intake-control-flow.svg +66 -0
- package/public/blog.html +1 -1
- package/public/brand/thumbgate-mark.svg +15 -0
- package/public/brand/thumbgate-og.svg +16 -0
- package/public/codex-enterprise.html +1 -1
- package/public/codex-plugin.html +1 -1
- package/public/compare.html +23 -3
- package/public/dashboard.html +312 -30
- package/public/federal.html +1 -1
- package/public/guide.html +5 -4
- package/public/index.html +167 -49
- package/public/js/buyer-intent.js +672 -0
- package/public/learn.html +74 -7
- package/public/lessons.html +2 -1
- package/public/numbers.html +3 -3
- package/public/pricing.html +63 -15
- package/public/pro.html +7 -7
- package/scripts/activation-quickstart.js +187 -0
- package/scripts/agent-memory-lifecycle.js +211 -0
- package/scripts/async-eval-observability.js +236 -0
- package/scripts/auto-promote-gates.js +75 -4
- package/scripts/build-metadata.js +24 -3
- package/scripts/cli-schema.js +22 -0
- package/scripts/dashboard-chat.js +2 -1
- package/scripts/dashboard.js +8 -0
- package/scripts/export-databricks-bundle.js +5 -1
- package/scripts/export-dpo-pairs.js +7 -2
- package/scripts/feedback-aggregate.js +281 -0
- package/scripts/feedback-loop.js +34 -0
- package/scripts/filesystem-search.js +35 -10
- package/scripts/gates-engine.js +198 -6
- package/scripts/gemini-embedding-policy.js +2 -1
- package/scripts/hook-stop-anti-claim.js +227 -0
- package/scripts/hook-thumbgate-cache-updater.js +18 -2
- package/scripts/lesson-inference.js +8 -3
- package/scripts/lesson-search.js +17 -1
- package/scripts/operational-integrity.js +39 -5
- package/scripts/plausible-domain-config.js +4 -2
- package/scripts/rate-limiter.js +12 -6
- package/scripts/secret-redaction.js +166 -0
- package/scripts/security-scanner.js +100 -0
- package/scripts/self-distill-agent.js +3 -1
- package/scripts/self-harness-optimizer.js +141 -0
- package/scripts/seo-gsd.js +635 -0
- package/scripts/statusline-cache-path.js +17 -2
- package/scripts/statusline-cache-read.js +57 -0
- package/scripts/statusline-local-stats.js +9 -1
- package/scripts/statusline-meta.js +5 -2
- package/scripts/statusline.sh +13 -1
- package/scripts/sync-telemetry-from-prod.js +374 -0
- package/scripts/telemetry-analytics.js +9 -0
- package/scripts/thumbgate-search.js +85 -19
- package/scripts/tool-contract-validator.js +76 -0
- package/scripts/vector-store.js +44 -0
- package/scripts/workspace-evolver.js +62 -2
- package/src/api/server.js +715 -86
package/src/api/server.js
CHANGED
|
@@ -3,6 +3,7 @@ const http = require('http');
|
|
|
3
3
|
const https = require('https');
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
6
7
|
const { EventEmitter } = require('node:events');
|
|
7
8
|
const pkg = require('../../package.json');
|
|
8
9
|
const {
|
|
@@ -168,7 +169,13 @@ const {
|
|
|
168
169
|
buildReviewSnapshot,
|
|
169
170
|
readDashboardReviewState,
|
|
170
171
|
writeDashboardReviewState,
|
|
172
|
+
collectAllFeedbackEntries,
|
|
171
173
|
} = require('../../scripts/dashboard');
|
|
174
|
+
const {
|
|
175
|
+
collectAggregateLogEntries,
|
|
176
|
+
computeAggregateFeedbackStats,
|
|
177
|
+
shouldAggregateFeedback,
|
|
178
|
+
} = require('../../scripts/feedback-aggregate');
|
|
172
179
|
const {
|
|
173
180
|
guardDfcxWebhook,
|
|
174
181
|
} = require('../../adapters/gcp/dfcx-webhook-gate');
|
|
@@ -209,6 +216,8 @@ const mcpOauth = require('../../scripts/mcp-oauth');
|
|
|
209
216
|
// OAuth 2.1 (PKCE) authorization-server state for the remote MCP connector
|
|
210
217
|
// (Claude Connectors Directory requires OAuth for authenticated services).
|
|
211
218
|
const oauthStore = mcpOauth.createStore();
|
|
219
|
+
const pendingOauthAuthorizeRequests = new Map();
|
|
220
|
+
const OAUTH_AUTHORIZE_REQUEST_TTL_MS = 10 * 60 * 1000;
|
|
212
221
|
const resendMailer = require('../../scripts/mailer/resend-mailer');
|
|
213
222
|
const {
|
|
214
223
|
buildContextFootprintReport,
|
|
@@ -231,12 +240,17 @@ const LEARN_PAGE_PATH = path.resolve(__dirname, '../../public/learn.html');
|
|
|
231
240
|
const NUMBERS_PAGE_PATH = path.resolve(__dirname, '../../public/numbers.html');
|
|
232
241
|
const FEDERAL_PAGE_PATH = path.resolve(__dirname, '../../public/federal.html');
|
|
233
242
|
const PRICING_PAGE_PATH = path.resolve(__dirname, '../../public/pricing.html');
|
|
243
|
+
const ABOUT_PAGE_PATH = path.resolve(__dirname, '../../public/about.html');
|
|
234
244
|
const LEARN_DIR = path.resolve(__dirname, '../../public/learn');
|
|
235
245
|
const GUIDES_DIR = path.resolve(__dirname, '../../public/guides');
|
|
236
246
|
const COMPARE_DIR = path.resolve(__dirname, '../../public/compare');
|
|
237
247
|
const USE_CASES_DIR = path.resolve(__dirname, '../../public/use-cases');
|
|
238
248
|
const PUBLIC_DIR = path.resolve(__dirname, '../../public');
|
|
239
249
|
const PUBLIC_ASSETS_DIR = path.resolve(__dirname, '../../public/assets');
|
|
250
|
+
const LEARN_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(LEARN_DIR);
|
|
251
|
+
const GUIDE_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(GUIDES_DIR);
|
|
252
|
+
const COMPARE_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(COMPARE_DIR);
|
|
253
|
+
const USE_CASE_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(USE_CASES_DIR);
|
|
240
254
|
const BUYER_INTENT_SCRIPT_PATH = path.resolve(__dirname, '../../public/js/buyer-intent.js');
|
|
241
255
|
const STATIC_MIME_BY_EXT = Object.freeze({
|
|
242
256
|
'.png': 'image/png',
|
|
@@ -355,6 +369,7 @@ function serveStaticFile(res, filePath, { headOnly = false, cacheSeconds = 86400
|
|
|
355
369
|
res.setHeader('Content-Type', contentType);
|
|
356
370
|
res.setHeader('Content-Length', stat.size);
|
|
357
371
|
res.setHeader('Cache-Control', `public, max-age=${cacheSeconds}, immutable`);
|
|
372
|
+
res.setHeader('Referrer-Policy', 'same-origin');
|
|
358
373
|
if (headOnly) {
|
|
359
374
|
res.end();
|
|
360
375
|
return;
|
|
@@ -452,6 +467,36 @@ const TRACKED_LINK_TARGETS = Object.freeze({
|
|
|
452
467
|
},
|
|
453
468
|
allowCustomerEmail: true,
|
|
454
469
|
},
|
|
470
|
+
diagnostic: {
|
|
471
|
+
configUrlKey: 'sprintDiagnosticCheckoutUrl',
|
|
472
|
+
fallbackHref: SPRINT_DIAGNOSTIC_CHECKOUT_URL,
|
|
473
|
+
external: true,
|
|
474
|
+
ctaId: 'go_diagnostic',
|
|
475
|
+
ctaPlacement: 'link_router',
|
|
476
|
+
eventType: 'cta_click',
|
|
477
|
+
defaults: {
|
|
478
|
+
utm_source: 'website',
|
|
479
|
+
utm_medium: 'link_router',
|
|
480
|
+
utm_campaign: 'sprint_diagnostic',
|
|
481
|
+
plan_id: 'sprint_diagnostic',
|
|
482
|
+
},
|
|
483
|
+
allowCustomerEmail: true,
|
|
484
|
+
},
|
|
485
|
+
sprint: {
|
|
486
|
+
configUrlKey: 'workflowSprintCheckoutUrl',
|
|
487
|
+
fallbackHref: WORKFLOW_SPRINT_CHECKOUT_URL,
|
|
488
|
+
external: true,
|
|
489
|
+
ctaId: 'go_sprint',
|
|
490
|
+
ctaPlacement: 'link_router',
|
|
491
|
+
eventType: 'cta_click',
|
|
492
|
+
defaults: {
|
|
493
|
+
utm_source: 'website',
|
|
494
|
+
utm_medium: 'link_router',
|
|
495
|
+
utm_campaign: 'workflow_sprint',
|
|
496
|
+
plan_id: 'workflow_sprint',
|
|
497
|
+
},
|
|
498
|
+
allowCustomerEmail: true,
|
|
499
|
+
},
|
|
455
500
|
trial: {
|
|
456
501
|
path: '/guide',
|
|
457
502
|
ctaId: 'go_trial',
|
|
@@ -1564,7 +1609,10 @@ function classifyEnterpriseChatTopic(prompt) {
|
|
|
1564
1609
|
// today?" with an actual filtered list instead of a canned total.
|
|
1565
1610
|
function parseChatIntent(prompt) {
|
|
1566
1611
|
const lower = String(prompt || '').toLowerCase();
|
|
1567
|
-
const
|
|
1612
|
+
const terms = new Set(lower.split(/[^a-z0-9]+/).filter(Boolean));
|
|
1613
|
+
const hasTerm = (term) => terms.has(term);
|
|
1614
|
+
const wantsList = lower.includes('tell me about') ||
|
|
1615
|
+
['what', 'which', 'list', 'show', 'example', 'examples'].some(hasTerm);
|
|
1568
1616
|
let windowMs = null;
|
|
1569
1617
|
let windowLabel = 'across all time';
|
|
1570
1618
|
if (/\btoday\b/.test(lower)) { windowMs = 24 * 60 * 60 * 1000; windowLabel = 'today'; }
|
|
@@ -1606,12 +1654,16 @@ function bestFeedbackDescription(row) {
|
|
|
1606
1654
|
function readRecentFeedbackEntries(feedbackDir, signal, windowMs, limit = 5, opts = {}) {
|
|
1607
1655
|
try {
|
|
1608
1656
|
if (!feedbackDir) return [];
|
|
1609
|
-
const
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1657
|
+
const rows = shouldAggregateFeedback()
|
|
1658
|
+
? collectAggregateLogEntries('feedback-log.jsonl', { feedbackDir }).entries
|
|
1659
|
+
: (() => {
|
|
1660
|
+
const fsLocal = require('node:fs');
|
|
1661
|
+
const pathLocal = require('node:path');
|
|
1662
|
+
const logPath = pathLocal.join(feedbackDir, 'feedback-log.jsonl');
|
|
1663
|
+
if (!fsLocal.existsSync(logPath)) return [];
|
|
1664
|
+
const { readJsonl } = require('../../scripts/fs-utils');
|
|
1665
|
+
return readJsonl(logPath) || [];
|
|
1666
|
+
})();
|
|
1615
1667
|
const cutoff = windowMs ? Date.now() - windowMs : 0;
|
|
1616
1668
|
const filtered = rows
|
|
1617
1669
|
.filter((r) => !signal || r.signal === signal)
|
|
@@ -1622,7 +1674,11 @@ function readRecentFeedbackEntries(feedbackDir, signal, windowMs, limit = 5, opt
|
|
|
1622
1674
|
})
|
|
1623
1675
|
.reverse()
|
|
1624
1676
|
.map((r) => {
|
|
1625
|
-
const out = {
|
|
1677
|
+
const out = {
|
|
1678
|
+
timestamp: r.timestamp,
|
|
1679
|
+
context: bestFeedbackDescription(r),
|
|
1680
|
+
tags: Array.isArray(r.tags) ? r.tags : []
|
|
1681
|
+
};
|
|
1626
1682
|
if (opts.includeSignal) out.signal = r.signal;
|
|
1627
1683
|
return out;
|
|
1628
1684
|
});
|
|
@@ -1661,9 +1717,53 @@ const FEEDBACK_LIST_LABELS = Object.freeze({
|
|
|
1661
1717
|
positive: 'Recent wins',
|
|
1662
1718
|
});
|
|
1663
1719
|
|
|
1720
|
+
const FEEDBACK_OMITTED_TAGS = Object.freeze(['audit-trail', 'auto-capture']);
|
|
1721
|
+
|
|
1722
|
+
function formatChatTimestamp(isoString) {
|
|
1723
|
+
if (!isoString) return 'unknown time';
|
|
1724
|
+
try {
|
|
1725
|
+
const d = new Date(isoString);
|
|
1726
|
+
if (Number.isNaN(d.getTime())) return isoString;
|
|
1727
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
1728
|
+
const yyyy = d.getFullYear();
|
|
1729
|
+
const mm = pad(d.getMonth() + 1);
|
|
1730
|
+
const dd = pad(d.getDate());
|
|
1731
|
+
const hh = pad(d.getHours());
|
|
1732
|
+
const min = pad(d.getMinutes());
|
|
1733
|
+
const ss = pad(d.getSeconds());
|
|
1734
|
+
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}`;
|
|
1735
|
+
} catch {
|
|
1736
|
+
return isoString;
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
function buildFeedbackEntries(windowed, signal) {
|
|
1741
|
+
return (signal ? windowed.filter((r) => r.signal === signal) : windowed)
|
|
1742
|
+
.filter((r) => r.context && !isPlaceholder(r.context))
|
|
1743
|
+
.slice(0, 5);
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
function formatFeedbackEntry(entry) {
|
|
1747
|
+
const tsFormatted = formatChatTimestamp(entry.timestamp);
|
|
1748
|
+
const signalLabel = entry.signal ? ` [${entry.signal}]` : '';
|
|
1749
|
+
const tagsList = Array.isArray(entry.tags)
|
|
1750
|
+
? entry.tags.filter((tag) => !FEEDBACK_OMITTED_TAGS.includes(tag))
|
|
1751
|
+
: [];
|
|
1752
|
+
const tagsStr = tagsList.length ? ` (${tagsList.join(', ')})` : '';
|
|
1753
|
+
return ` • ${tsFormatted}${signalLabel}${tagsStr} — ${entry.context}`;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function appendFeedbackListLines(lines, { entries, signal, intent }) {
|
|
1757
|
+
if (!entries.length) {
|
|
1758
|
+
lines.push(`No ${signal || 'feedback'} entries found ${intent.windowLabel}.`);
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
lines.push(`${FEEDBACK_LIST_LABELS[signal] || 'Recent feedback'} (${intent.windowLabel}):`);
|
|
1762
|
+
lines.push(...entries.map(formatFeedbackEntry));
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1664
1765
|
function buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeline }) {
|
|
1665
1766
|
const signal = detectFeedbackSignalFromPrompt(ctx.prompt);
|
|
1666
|
-
|
|
1667
1767
|
// One read of the time-windowed log, then in-memory counts + (signal-filtered,
|
|
1668
1768
|
// placeholder-stripped) list. Counts include ALL entries (so "Feedback today: 5"
|
|
1669
1769
|
// matches the dashboard tile); the list drops vague entries like literal "thumbs
|
|
@@ -1671,24 +1771,16 @@ function buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeli
|
|
|
1671
1771
|
const windowed = readRecentFeedbackEntries(feedbackDir, null, intent.windowMs, 10000, { includeSignal: true });
|
|
1672
1772
|
const windowPos = windowed.filter((r) => r.signal === 'positive').length;
|
|
1673
1773
|
const windowNeg = windowed.filter((r) => r.signal === 'negative').length;
|
|
1674
|
-
const entries = (
|
|
1675
|
-
.filter((r) => r.context && !isPlaceholder(r.context))
|
|
1676
|
-
.slice(0, 5);
|
|
1774
|
+
const entries = buildFeedbackEntries(windowed, signal);
|
|
1677
1775
|
|
|
1678
|
-
const lines = [
|
|
1679
|
-
|
|
1776
|
+
const lines = [
|
|
1777
|
+
intent.windowMs
|
|
1680
1778
|
? `Feedback ${intent.windowLabel}: ${windowed.length} (${windowPos} positive, ${windowNeg} negative).`
|
|
1681
|
-
: `Feedback total: ${compactNumber(approval.total)} (${compactNumber(approval.positive)} positive, ${compactNumber(approval.negative)} negative)
|
|
1779
|
+
: `Feedback total: ${compactNumber(approval.total)} (${compactNumber(approval.positive)} positive, ${compactNumber(approval.negative)} negative).`,
|
|
1780
|
+
];
|
|
1682
1781
|
|
|
1683
1782
|
if (intent.wantsList) {
|
|
1684
|
-
|
|
1685
|
-
lines.push(`${FEEDBACK_LIST_LABELS[signal] || 'Recent feedback'} (${intent.windowLabel}):`);
|
|
1686
|
-
for (const e of entries) {
|
|
1687
|
-
lines.push(` • ${(e.timestamp || '').slice(0, 10)} — ${e.context}`);
|
|
1688
|
-
}
|
|
1689
|
-
} else {
|
|
1690
|
-
lines.push(`No ${signal || 'feedback'} entries found ${intent.windowLabel}.`);
|
|
1691
|
-
}
|
|
1783
|
+
appendFeedbackListLines(lines, { entries, signal, intent });
|
|
1692
1784
|
} else {
|
|
1693
1785
|
lines.push(`Lesson pipeline: ${compactNumber(lessonPipeline.lessons || lessonPipeline.generated || 0)} lessons visible in the current dashboard snapshot.`);
|
|
1694
1786
|
}
|
|
@@ -1896,7 +1988,7 @@ async function answerEnterpriseDataChat({ prompt, feedbackDir, parsed }) {
|
|
|
1896
1988
|
const answerEnterpriseDialogflowChat = answerEnterpriseDataChat;
|
|
1897
1989
|
|
|
1898
1990
|
function buildLossAnalyticsResponse(data, summaryOptions) {
|
|
1899
|
-
return {
|
|
1991
|
+
return sanitizeHtmlUnsafeJsonValue({
|
|
1900
1992
|
window: data.analytics.window || summaryOptions,
|
|
1901
1993
|
lossAnalysis: data.analytics.lossAnalysis || null,
|
|
1902
1994
|
buyerLoss: data.analytics.buyerLoss || null,
|
|
@@ -1908,13 +2000,50 @@ function buildLossAnalyticsResponse(data, summaryOptions) {
|
|
|
1908
2000
|
ctas: data.analytics.telemetry && data.analytics.telemetry.ctas,
|
|
1909
2001
|
visitors: data.analytics.telemetry && data.analytics.telemetry.visitors,
|
|
1910
2002
|
},
|
|
1911
|
-
};
|
|
2003
|
+
});
|
|
1912
2004
|
}
|
|
1913
2005
|
|
|
1914
2006
|
function createJourneyId(prefix) {
|
|
1915
2007
|
return createTraceId(prefix).replace(/^trace_/, `${prefix}_`);
|
|
1916
2008
|
}
|
|
1917
2009
|
|
|
2010
|
+
function normalizeCheckoutInterstitialSampleRate(value) {
|
|
2011
|
+
const parsed = Number.parseFloat(String(value || '').trim());
|
|
2012
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
2013
|
+
return 0;
|
|
2014
|
+
}
|
|
2015
|
+
if (parsed > 1) {
|
|
2016
|
+
return Math.min(parsed / 100, 1);
|
|
2017
|
+
}
|
|
2018
|
+
return Math.min(parsed, 1);
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
function stableUnitInterval(value) {
|
|
2022
|
+
const text = String(value || '');
|
|
2023
|
+
let hash = 2166136261;
|
|
2024
|
+
for (const character of text) {
|
|
2025
|
+
hash ^= character.codePointAt(0);
|
|
2026
|
+
hash = Math.imul(hash, 16777619);
|
|
2027
|
+
}
|
|
2028
|
+
return (hash >>> 0) / 4294967296;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
function shouldSampleCheckoutInterstitial({ sampleRate, traceId, analyticsMetadata }) {
|
|
2032
|
+
if (sampleRate <= 0) {
|
|
2033
|
+
return false;
|
|
2034
|
+
}
|
|
2035
|
+
if (sampleRate >= 1) {
|
|
2036
|
+
return true;
|
|
2037
|
+
}
|
|
2038
|
+
const seed = [
|
|
2039
|
+
analyticsMetadata?.visitorId,
|
|
2040
|
+
analyticsMetadata?.sessionId,
|
|
2041
|
+
analyticsMetadata?.acquisitionId,
|
|
2042
|
+
traceId,
|
|
2043
|
+
].filter(Boolean).join(':');
|
|
2044
|
+
return stableUnitInterval(seed || traceId) < sampleRate;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
1918
2047
|
function appendQueryParam(url, key, value) {
|
|
1919
2048
|
const normalized = normalizeNullableText(value);
|
|
1920
2049
|
if (normalized) {
|
|
@@ -1974,9 +2103,245 @@ function buildCheckoutIntentHref(baseUrl, metadata = {}, overrides = {}) {
|
|
|
1974
2103
|
});
|
|
1975
2104
|
}
|
|
1976
2105
|
|
|
1977
|
-
|
|
2106
|
+
const CHECKOUT_HIDDEN_ATTRIBUTION_KEYS = Object.freeze([
|
|
2107
|
+
'trace_id',
|
|
2108
|
+
'acquisition_id',
|
|
2109
|
+
'visitor_id',
|
|
2110
|
+
'session_id',
|
|
2111
|
+
'visitor_session_id',
|
|
2112
|
+
'install_id',
|
|
2113
|
+
'utm_source',
|
|
2114
|
+
'utm_medium',
|
|
2115
|
+
'utm_campaign',
|
|
2116
|
+
'utm_content',
|
|
2117
|
+
'utm_term',
|
|
2118
|
+
'creator',
|
|
2119
|
+
'community',
|
|
2120
|
+
'post_id',
|
|
2121
|
+
'comment_id',
|
|
2122
|
+
'campaign_variant',
|
|
2123
|
+
'offer_code',
|
|
2124
|
+
'cta_id',
|
|
2125
|
+
'cta_placement',
|
|
2126
|
+
'plan_id',
|
|
2127
|
+
'billing_cycle',
|
|
2128
|
+
'seat_count',
|
|
2129
|
+
'landing_path',
|
|
2130
|
+
'referrer_host',
|
|
2131
|
+
]);
|
|
2132
|
+
|
|
2133
|
+
function normalizeHiddenAttributionValue(value) {
|
|
2134
|
+
const normalized = normalizeNullableText(value);
|
|
2135
|
+
if (!normalized) return '';
|
|
2136
|
+
return normalized.slice(0, 512);
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
function buildCheckoutHiddenAttributionInputs(parsed = null) {
|
|
2140
|
+
if (!parsed?.searchParams) return '';
|
|
2141
|
+
|
|
2142
|
+
const inputs = [];
|
|
2143
|
+
for (const key of CHECKOUT_HIDDEN_ATTRIBUTION_KEYS) {
|
|
2144
|
+
const value = normalizeHiddenAttributionValue(parsed.searchParams.get(key));
|
|
2145
|
+
if (value) {
|
|
2146
|
+
inputs.push(`<input type="hidden" name="${key}" value="${escapeHtmlAttribute(value)}">`);
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
return inputs.join('');
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
function prunePendingOauthAuthorizeRequests(now = Date.now()) {
|
|
2153
|
+
for (const [token, entry] of pendingOauthAuthorizeRequests.entries()) {
|
|
2154
|
+
if (!entry || entry.expiresAt <= now) {
|
|
2155
|
+
pendingOauthAuthorizeRequests.delete(token);
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
function createPendingOauthAuthorizeRequest(params, now = Date.now()) {
|
|
2161
|
+
prunePendingOauthAuthorizeRequests(now);
|
|
2162
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
2163
|
+
pendingOauthAuthorizeRequests.set(token, {
|
|
2164
|
+
params,
|
|
2165
|
+
expiresAt: now + OAUTH_AUTHORIZE_REQUEST_TTL_MS,
|
|
2166
|
+
});
|
|
2167
|
+
return token;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
function consumePendingOauthAuthorizeRequest(token, now = Date.now()) {
|
|
2171
|
+
if (!token) return null;
|
|
2172
|
+
prunePendingOauthAuthorizeRequests(now);
|
|
2173
|
+
const entry = pendingOauthAuthorizeRequests.get(token);
|
|
2174
|
+
if (!entry) return null;
|
|
2175
|
+
pendingOauthAuthorizeRequests.delete(token);
|
|
2176
|
+
return entry.params;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
function getOauthAuthorizeParamsFromQuery(searchParams) {
|
|
2180
|
+
return {
|
|
2181
|
+
clientId: searchParams.get('client_id') || '',
|
|
2182
|
+
redirectUri: searchParams.get('redirect_uri') || '',
|
|
2183
|
+
codeChallenge: searchParams.get('code_challenge') || '',
|
|
2184
|
+
codeChallengeMethod: searchParams.get('code_challenge_method') || '',
|
|
2185
|
+
scope: searchParams.get('scope') || undefined,
|
|
2186
|
+
state: searchParams.get('state') || '',
|
|
2187
|
+
resource: searchParams.get('resource') || '',
|
|
2188
|
+
};
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
function getOauthAuthorizeParamsFromForm(form, hostedConfig) {
|
|
2192
|
+
const pending = consumePendingOauthAuthorizeRequest(form.get('auth_request_token') || '');
|
|
2193
|
+
if (pending) return pending;
|
|
2194
|
+
return {
|
|
2195
|
+
clientId: form.get('client_id') || '',
|
|
2196
|
+
redirectUri: form.get('redirect_uri') || '',
|
|
2197
|
+
codeChallenge: form.get('code_challenge') || '',
|
|
2198
|
+
codeChallengeMethod: form.get('code_challenge_method') || '',
|
|
2199
|
+
scope: form.get('scope') || undefined,
|
|
2200
|
+
state: form.get('state') || '',
|
|
2201
|
+
resource: form.get('resource') || buildPublicUrl(hostedConfig, '/mcp'),
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
function renderCheckoutIntentPage(prefilledEmail = '', parsed = null, options = {}) {
|
|
1978
2206
|
const plausibleDomain = escapeHtmlAttribute(resolvePlausibleDataDomain({ host: 'thumbgate.ai' }));
|
|
1979
|
-
|
|
2207
|
+
const includeHiddenAttribution = options.includeHiddenAttribution === true;
|
|
2208
|
+
const hiddenInputs = includeHiddenAttribution
|
|
2209
|
+
? buildCheckoutHiddenAttributionInputs(parsed)
|
|
2210
|
+
: '';
|
|
2211
|
+
return `<!doctype html>
|
|
2212
|
+
<html lang="en">
|
|
2213
|
+
<head>
|
|
2214
|
+
<meta charset="utf-8">
|
|
2215
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
2216
|
+
<title>Confirm - ThumbGate Pro</title>
|
|
2217
|
+
<script defer data-domain="${plausibleDomain}" src="https://plausible.io/js/script.tagged-events.js"></script>
|
|
2218
|
+
<script>window.plausible=window.plausible||function(){(window.plausible.q=window.plausible.q||[]).push(arguments)};</script>
|
|
2219
|
+
<style>
|
|
2220
|
+
body{background:#0a0a0a;color:#eee;font-family:system-ui,-apple-system,sans-serif;line-height:1.5}
|
|
2221
|
+
main{max-width:520px;margin:8vh auto;padding:0 20px}
|
|
2222
|
+
.brand{display:flex;align-items:center;gap:10px;margin-bottom:24px;font-size:14px;color:#94a3b8}
|
|
2223
|
+
.brand-mark{width:24px;height:24px;background:#22d3ee;border-radius:6px;display:inline-block}
|
|
2224
|
+
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}
|
|
2225
|
+
p{color:#cbd5e1;margin:8px 0}form{margin:0}
|
|
2226
|
+
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}
|
|
2227
|
+
input[type=email]:focus{border-color:#22d3ee}input[type=email]::placeholder{color:#64748b}
|
|
2228
|
+
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%}
|
|
2229
|
+
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}
|
|
2230
|
+
.trust,.objection-box{margin:24px 0;padding:16px;border:1px solid #1f2937;border-radius:8px;background:#0f172a}
|
|
2231
|
+
.trust-item{font-size:13px;color:#cbd5e1;padding:4px 0;display:flex;gap:8px}.trust-item::before{content:"✓";color:#22d3ee;font-weight:700}
|
|
2232
|
+
.choice-note,.email-note,.objection-note{font-size:13px;color:#94a3b8;margin-top:10px}
|
|
2233
|
+
.objection-grid{display:grid;gap:8px;margin-top:12px}
|
|
2234
|
+
.objection-grid button{border:1px solid #374151;background:#111827;color:#e5e7eb;border-radius:8px;padding:10px 12px;text-align:left;cursor:pointer}
|
|
2235
|
+
.objection-grid button:hover,.objection-grid button:focus{border-color:#22d3ee;outline:none}
|
|
2236
|
+
.feedback-saved{display:none;color:#22d3ee;font-size:13px;margin-top:10px}
|
|
2237
|
+
.back{text-align:center;color:#64748b;font-size:12px;margin-top:24px}.back a{color:#64748b;display:inline}
|
|
2238
|
+
</style>
|
|
2239
|
+
</head>
|
|
2240
|
+
<body>
|
|
2241
|
+
<main>
|
|
2242
|
+
<div class="brand"><span class="brand-mark"></span><span>ThumbGate</span></div>
|
|
2243
|
+
<h1>Start ThumbGate Pro</h1>
|
|
2244
|
+
<div class="price">$19<small>/mo</small></div>
|
|
2245
|
+
<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>
|
|
2246
|
+
<form action="/checkout/pro" method="GET" data-i="pro_checkout_confirmed">
|
|
2247
|
+
${hiddenInputs}
|
|
2248
|
+
<input type="hidden" name="confirm" value="1">
|
|
2249
|
+
<input type="email" name="customer_email" value="${escapeHtmlAttribute(prefilledEmail)}" placeholder="you@company.com" autocomplete="email">
|
|
2250
|
+
<p class="email-note">Optional. Stripe can collect your email on the secure checkout page.</p>
|
|
2251
|
+
<button type="submit" class="primary">Pay $19/mo with Stripe →</button>
|
|
2252
|
+
</form>
|
|
2253
|
+
<a class="secondary" data-i="workflow_sprint_intake" href="/#workflow-sprint-intake">Not sure yet? Send the workflow first</a>
|
|
2254
|
+
<p class="choice-note">Cancel anytime. 7-day refund, no questions. Diagnostics and sprints have their own pages.</p>
|
|
2255
|
+
<div class="objection-box" aria-label="Checkout feedback">
|
|
2256
|
+
<strong>Not buying today?</strong>
|
|
2257
|
+
<p class="objection-note">Tap one reason so we know what to fix. This does not sign you up.</p>
|
|
2258
|
+
<div class="objection-grid">
|
|
2259
|
+
<button type="button" data-reason="price_unclear">Price or scope is unclear</button>
|
|
2260
|
+
<button type="button" data-reason="need_more_proof">Need more proof first</button>
|
|
2261
|
+
<button type="button" data-reason="need_team_plan">Need a team/workflow plan instead</button>
|
|
2262
|
+
<button type="button" data-reason="not_urgent">Not urgent right now</button>
|
|
2263
|
+
</div>
|
|
2264
|
+
<div class="feedback-saved" id="feedback-saved">Feedback saved.</div>
|
|
2265
|
+
</div>
|
|
2266
|
+
<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>
|
|
2267
|
+
<p class="back"><a href="/">← Back to thumbgate.ai</a></p>
|
|
2268
|
+
</main>
|
|
2269
|
+
<script>
|
|
2270
|
+
(function(){
|
|
2271
|
+
var params = new URLSearchParams(window.location.search);
|
|
2272
|
+
var submitted = false;
|
|
2273
|
+
var feedbackSent = false;
|
|
2274
|
+
function pick(key, fallback) { return params.get(key) || fallback || null; }
|
|
2275
|
+
function sendTelemetry(eventType, extra) {
|
|
2276
|
+
var payload = Object.assign({
|
|
2277
|
+
eventType: eventType,
|
|
2278
|
+
clientType: 'web',
|
|
2279
|
+
page: '/checkout/pro',
|
|
2280
|
+
traceId: pick('trace_id'),
|
|
2281
|
+
acquisitionId: pick('acquisition_id'),
|
|
2282
|
+
visitorId: pick('visitor_id'),
|
|
2283
|
+
sessionId: pick('visitor_session_id') || pick('session_id'),
|
|
2284
|
+
installId: pick('install_id'),
|
|
2285
|
+
source: pick('utm_source') || pick('source') || 'website',
|
|
2286
|
+
utmSource: pick('utm_source') || pick('source') || 'website',
|
|
2287
|
+
utmMedium: pick('utm_medium') || 'checkout_interstitial',
|
|
2288
|
+
utmCampaign: pick('utm_campaign') || 'pro_pack',
|
|
2289
|
+
utmContent: pick('utm_content'),
|
|
2290
|
+
utmTerm: pick('utm_term'),
|
|
2291
|
+
creator: pick('creator') || pick('creator_handle'),
|
|
2292
|
+
community: pick('community') || pick('subreddit'),
|
|
2293
|
+
postId: pick('post_id'),
|
|
2294
|
+
commentId: pick('comment_id'),
|
|
2295
|
+
campaignVariant: pick('campaign_variant'),
|
|
2296
|
+
offerCode: pick('offer_code'),
|
|
2297
|
+
ctaId: pick('cta_id') || 'pricing_pro',
|
|
2298
|
+
ctaPlacement: pick('cta_placement') || 'checkout_interstitial',
|
|
2299
|
+
planId: pick('plan_id') || 'pro',
|
|
2300
|
+
billingCycle: pick('billing_cycle') || 'monthly',
|
|
2301
|
+
landingPath: pick('landing_path') || '/',
|
|
2302
|
+
referrerHost: pick('referrer_host'),
|
|
2303
|
+
referrer: document.referrer || null
|
|
2304
|
+
}, extra || {});
|
|
2305
|
+
var body = JSON.stringify(payload);
|
|
2306
|
+
if (navigator.sendBeacon) {
|
|
2307
|
+
navigator.sendBeacon('/v1/telemetry/ping', new Blob([body], { type: 'application/json' }));
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
fetch('/v1/telemetry/ping', { method:'POST', headers:{ 'content-type':'application/json' }, body: body, keepalive: true }).catch(function(){});
|
|
2311
|
+
}
|
|
2312
|
+
document.querySelector('form').addEventListener('submit', function(){
|
|
2313
|
+
submitted = true;
|
|
2314
|
+
var emailInput = document.querySelector('input[name=customer_email]');
|
|
2315
|
+
sendTelemetry('checkout_interstitial_cta_clicked', {
|
|
2316
|
+
ctaId: 'pro_checkout_confirmed',
|
|
2317
|
+
ctaPlacement: 'checkout_interstitial',
|
|
2318
|
+
customerEmail: emailInput ? emailInput.value : null
|
|
2319
|
+
});
|
|
2320
|
+
try { window.plausible && window.plausible('Checkout Pro Email Submitted', { props: { page:'/checkout/pro', source:'interstitial' } }); } catch(_) {}
|
|
2321
|
+
});
|
|
2322
|
+
document.querySelectorAll('[data-reason]').forEach(function(button) {
|
|
2323
|
+
button.addEventListener('click', function(){
|
|
2324
|
+
var reason = button.getAttribute('data-reason');
|
|
2325
|
+
feedbackSent = true;
|
|
2326
|
+
sendTelemetry('reason_not_buying', {
|
|
2327
|
+
reasonCode: reason,
|
|
2328
|
+
ctaId: 'checkout_interstitial_reason_' + reason,
|
|
2329
|
+
ctaPlacement: 'checkout_interstitial_feedback'
|
|
2330
|
+
});
|
|
2331
|
+
try { window.plausible && window.plausible('Checkout Pro Reason Not Buying', { props: { page:'/checkout/pro', reasonCode: reason } }); } catch(_) {}
|
|
2332
|
+
var saved = document.getElementById('feedback-saved');
|
|
2333
|
+
if (saved) saved.style.display = 'block';
|
|
2334
|
+
});
|
|
2335
|
+
});
|
|
2336
|
+
window.addEventListener('pagehide', function(){
|
|
2337
|
+
if (!submitted && !feedbackSent) {
|
|
2338
|
+
sendTelemetry('checkout_interstitial_abandoned', { reasonCode: 'left_without_confirming' });
|
|
2339
|
+
}
|
|
2340
|
+
});
|
|
2341
|
+
})();
|
|
2342
|
+
</script>
|
|
2343
|
+
</body>
|
|
2344
|
+
</html>`;
|
|
1980
2345
|
}
|
|
1981
2346
|
|
|
1982
2347
|
function buildCheckoutBootstrapBody(parsed, req, journeyState = resolveJourneyState(req, parsed)) {
|
|
@@ -2040,17 +2405,6 @@ function normalizeCheckoutCustomerEmail(value) {
|
|
|
2040
2405
|
return email;
|
|
2041
2406
|
}
|
|
2042
2407
|
|
|
2043
|
-
function renderCheckoutIntentGate(parsed, responseHeaders = {}) {
|
|
2044
|
-
let hiddenInputs = '';
|
|
2045
|
-
for (const [key, value] of parsed.searchParams.entries()) {
|
|
2046
|
-
if (key !== 'confirm' && key !== 'customer_email') hiddenInputs += `<input type=hidden name=${escapeHtmlAttribute(key)} value=${escapeHtmlAttribute(value)}>`;
|
|
2047
|
-
}
|
|
2048
|
-
return {
|
|
2049
|
-
html: `<!doctype html><h1>Email for Stripe receipt</h1><form action=/checkout/pro>${hiddenInputs}<input type=hidden name=confirm value=1><input name=customer_email type=email required><button>Continue</button></form>`,
|
|
2050
|
-
headers: responseHeaders,
|
|
2051
|
-
};
|
|
2052
|
-
}
|
|
2053
|
-
|
|
2054
2408
|
function normalizeTrackedLinkSlug(value) {
|
|
2055
2409
|
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
2056
2410
|
}
|
|
@@ -2061,6 +2415,27 @@ function normalizePublicPageSlug(value) {
|
|
|
2061
2415
|
.replace(/[^a-z0-9-]/g, '');
|
|
2062
2416
|
}
|
|
2063
2417
|
|
|
2418
|
+
function buildPublicHtmlFileMap(directory) {
|
|
2419
|
+
const entries = new Map();
|
|
2420
|
+
try {
|
|
2421
|
+
for (const fileName of fs.readdirSync(directory)) {
|
|
2422
|
+
if (!/^[a-z0-9-]+\.html$/i.test(fileName)) continue;
|
|
2423
|
+
const slug = normalizePublicPageSlug(fileName);
|
|
2424
|
+
if (!slug) continue;
|
|
2425
|
+
entries.set(slug, path.join(directory, fileName));
|
|
2426
|
+
}
|
|
2427
|
+
} catch (error) {
|
|
2428
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
2429
|
+
}
|
|
2430
|
+
return entries;
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
function resolvePublicHtmlFile(publicPageMap, rawSlug) {
|
|
2434
|
+
const slug = normalizePublicPageSlug(rawSlug);
|
|
2435
|
+
if (!slug) return null;
|
|
2436
|
+
return publicPageMap.get(slug) || null;
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2064
2439
|
function getTrackedLinkTarget(slug) {
|
|
2065
2440
|
const normalizedSlug = normalizeTrackedLinkSlug(slug);
|
|
2066
2441
|
return TRACKED_LINK_TARGETS[normalizedSlug]
|
|
@@ -2096,8 +2471,10 @@ function appendTrackedLinkQueryParams(destinationUrl, parsed, target) {
|
|
|
2096
2471
|
}
|
|
2097
2472
|
|
|
2098
2473
|
function buildTrackedLinkDestination(target, hostedConfig, parsed) {
|
|
2099
|
-
const
|
|
2100
|
-
|
|
2474
|
+
const configuredHref = target.configUrlKey ? hostedConfig[target.configUrlKey] : null;
|
|
2475
|
+
const href = target.href || configuredHref || target.fallbackHref;
|
|
2476
|
+
const destinationUrl = href
|
|
2477
|
+
? new URL(href)
|
|
2101
2478
|
: new URL(target.path || '/', hostedConfig.appOrigin);
|
|
2102
2479
|
appendTrackedLinkQueryParams(destinationUrl, parsed, target);
|
|
2103
2480
|
return destinationUrl;
|
|
@@ -2224,6 +2601,7 @@ function sendJson(res, statusCode, payload, extraHeaders = {}, options = {}) {
|
|
|
2224
2601
|
const body = JSON.stringify(payload);
|
|
2225
2602
|
res.writeHead(statusCode, {
|
|
2226
2603
|
'Content-Type': 'application/json; charset=utf-8',
|
|
2604
|
+
'X-Content-Type-Options': 'nosniff',
|
|
2227
2605
|
'Content-Length': Buffer.byteLength(body),
|
|
2228
2606
|
...extraHeaders,
|
|
2229
2607
|
});
|
|
@@ -2317,8 +2695,9 @@ function chatgptActionEventType(integration, suffix) {
|
|
|
2317
2695
|
}
|
|
2318
2696
|
|
|
2319
2697
|
function getPublicOrigin(req) {
|
|
2320
|
-
const
|
|
2321
|
-
const
|
|
2698
|
+
const rawProto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
|
|
2699
|
+
const proto = rawProto === 'https' ? 'https' : 'http';
|
|
2700
|
+
const host = getSafePublicRequestHost(req) || 'localhost';
|
|
2322
2701
|
return `${proto}://${host}`;
|
|
2323
2702
|
}
|
|
2324
2703
|
|
|
@@ -2337,6 +2716,47 @@ function getRequestHostHeader(req) {
|
|
|
2337
2716
|
return forwardedHost || req.headers.host || '';
|
|
2338
2717
|
}
|
|
2339
2718
|
|
|
2719
|
+
function normalizePublicRequestHost(value) {
|
|
2720
|
+
const rawHost = String(value || '').split(',')[0].trim().toLowerCase();
|
|
2721
|
+
if (!rawHost || rawHost.length > 253) return '';
|
|
2722
|
+
|
|
2723
|
+
const hostWithoutPort = rawHost.startsWith('[')
|
|
2724
|
+
? rawHost.slice(1).split(']')[0]
|
|
2725
|
+
: rawHost.split(':')[0];
|
|
2726
|
+
const port = rawHost.startsWith('[')
|
|
2727
|
+
? rawHost.split(']:')[1] || ''
|
|
2728
|
+
: rawHost.split(':')[1] || '';
|
|
2729
|
+
|
|
2730
|
+
if (!isAllowedPublicHostName(hostWithoutPort)) {
|
|
2731
|
+
return '';
|
|
2732
|
+
}
|
|
2733
|
+
if (port && !/^\d{1,5}$/.test(port)) return '';
|
|
2734
|
+
if (port && Number(port) > 65535) return '';
|
|
2735
|
+
return port ? `${hostWithoutPort}:${port}` : hostWithoutPort;
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
function isAllowedPublicHostName(hostname) {
|
|
2739
|
+
return hostname === 'localhost'
|
|
2740
|
+
|| hostname === '::1'
|
|
2741
|
+
|| isIpv4Host(hostname)
|
|
2742
|
+
|| isDnsHostName(hostname);
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
function isIpv4Host(hostname) {
|
|
2746
|
+
const parts = hostname.split('.');
|
|
2747
|
+
return parts.length === 4 && parts.every((part) => /^\d{1,3}$/.test(part));
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
function isDnsHostName(hostname) {
|
|
2751
|
+
return hostname
|
|
2752
|
+
.split('.')
|
|
2753
|
+
.every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
function getSafePublicRequestHost(req) {
|
|
2757
|
+
return normalizePublicRequestHost(getRequestHostHeader(req));
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2340
2760
|
function isLoopbackHost(hostValue) {
|
|
2341
2761
|
const rawHost = String(hostValue || '').split(',')[0].trim();
|
|
2342
2762
|
if (!rawHost) {
|
|
@@ -2382,7 +2802,7 @@ function stripTrailingSlashes(value) {
|
|
|
2382
2802
|
return input.slice(0, end);
|
|
2383
2803
|
}
|
|
2384
2804
|
|
|
2385
|
-
function normalizePublicMarketingHtml(html, runtimeConfig) {
|
|
2805
|
+
function normalizePublicMarketingHtml(html, runtimeConfig, requestHost) {
|
|
2386
2806
|
const appOrigin = runtimeConfig?.appOrigin
|
|
2387
2807
|
? stripTrailingSlashes(runtimeConfig.appOrigin)
|
|
2388
2808
|
: '';
|
|
@@ -2391,11 +2811,14 @@ function normalizePublicMarketingHtml(html, runtimeConfig) {
|
|
|
2391
2811
|
let output = String(html);
|
|
2392
2812
|
output = output.replaceAll(DEFAULT_PUBLIC_APP_ORIGIN, appOrigin);
|
|
2393
2813
|
try {
|
|
2394
|
-
const host = new URL(appOrigin).host;
|
|
2814
|
+
const host = normalizePublicRequestHost(requestHost) || new URL(appOrigin).host;
|
|
2395
2815
|
const plausibleDomain = resolvePlausibleDataDomain({ host });
|
|
2396
2816
|
output = output.replaceAll(
|
|
2397
2817
|
'data-domain="thumbgate-production.up.railway.app"',
|
|
2398
2818
|
`data-domain="${escapeHtmlAttribute(plausibleDomain)}"`
|
|
2819
|
+
).replaceAll(
|
|
2820
|
+
'data-domain="thumbgate.ai"',
|
|
2821
|
+
`data-domain="${escapeHtmlAttribute(plausibleDomain)}"`
|
|
2399
2822
|
);
|
|
2400
2823
|
} catch {
|
|
2401
2824
|
// appOrigin is normalized by hosted-config; leave static analytics domains
|
|
@@ -2444,7 +2867,7 @@ function loadPublicMarketingTemplateHtml(templatePath, runtimeConfig, pageContex
|
|
|
2444
2867
|
'__GTM_PLAN_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/GO_TO_MARKET_REVENUE_WEDGE_2026-03.md',
|
|
2445
2868
|
'__GITHUB_URL__': 'https://github.com/IgorGanapolsky/ThumbGate',
|
|
2446
2869
|
'__POSTHOG_API_KEY__': runtimeConfig.posthogApiKey || '',
|
|
2447
|
-
}), runtimeConfig);
|
|
2870
|
+
}), runtimeConfig, pageContext.requestHost);
|
|
2448
2871
|
}
|
|
2449
2872
|
|
|
2450
2873
|
function loadLandingPageHtml(runtimeConfig, pageContext = {}) {
|
|
@@ -2459,6 +2882,18 @@ function loadPricingPageHtml(runtimeConfig, pageContext = {}) {
|
|
|
2459
2882
|
return loadPublicMarketingTemplateHtml(PRICING_PAGE_PATH, runtimeConfig, pageContext);
|
|
2460
2883
|
}
|
|
2461
2884
|
|
|
2885
|
+
function loadAboutPageHtml(runtimeConfig, pageContext = {}) {
|
|
2886
|
+
return loadPublicMarketingTemplateHtml(ABOUT_PAGE_PATH, runtimeConfig, pageContext);
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
function loadGuidePageHtml(runtimeConfig, pageContext = {}) {
|
|
2890
|
+
return loadPublicMarketingTemplateHtml(GUIDE_PAGE_PATH, runtimeConfig, pageContext);
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
function loadLearnPageHtml(runtimeConfig, pageContext = {}) {
|
|
2894
|
+
return loadPublicMarketingTemplateHtml(LEARN_PAGE_PATH, runtimeConfig, pageContext);
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2462
2897
|
function readOptionalPublicTemplate(filePath) {
|
|
2463
2898
|
try {
|
|
2464
2899
|
return fs.readFileSync(filePath, 'utf-8');
|
|
@@ -3091,6 +3526,7 @@ function renderSitemapXml(runtimeConfig) {
|
|
|
3091
3526
|
{ path: '/learn/codex-role-plugins-need-governance', changefreq: 'weekly', priority: '0.85' },
|
|
3092
3527
|
{ path: '/learn/agentic-os-team-governance', changefreq: 'weekly', priority: '0.85' },
|
|
3093
3528
|
{ path: '/learn/cost-aware-agent-gate-routing', changefreq: 'weekly', priority: '0.85' },
|
|
3529
|
+
{ path: '/learn/pretix-stripe-connect-marketplaces', changefreq: 'weekly', priority: '0.9' },
|
|
3094
3530
|
{ path: '/compare/claude-code-hooks', changefreq: 'weekly', priority: '0.85' },
|
|
3095
3531
|
{ path: '/compare/bumblebee', changefreq: 'weekly', priority: '0.85' },
|
|
3096
3532
|
{ path: '/compare/anthropic-containment', changefreq: 'weekly', priority: '0.85' },
|
|
@@ -3099,6 +3535,30 @@ function renderSitemapXml(runtimeConfig) {
|
|
|
3099
3535
|
{ path: '/compare/anthropic-claude-for-legal', changefreq: 'weekly', priority: '0.9' },
|
|
3100
3536
|
...THUMBGATE_SEO_SITEMAP_ENTRIES,
|
|
3101
3537
|
];
|
|
3538
|
+
// Auto-include every hand-written SEO page so /sitemap.xml can never drift out
|
|
3539
|
+
// of sync with public/compare/*.html or public/guides/*.html. Crawlers and AI
|
|
3540
|
+
// answer engines only surface pages they can discover, so a buyer-intent page
|
|
3541
|
+
// missing from the sitemap is invisible on its query. De-duped against entries
|
|
3542
|
+
// already declared above (e.g. seo-gsd specs), which keep explicit priorities.
|
|
3543
|
+
const declaredPaths = new Set(entries.map((entry) => entry.path));
|
|
3544
|
+
try {
|
|
3545
|
+
const seoDirectories = [
|
|
3546
|
+
{ dir: 'compare', route: '/compare', priority: '0.85' },
|
|
3547
|
+
{ dir: 'guides', route: '/guides', priority: '0.85' },
|
|
3548
|
+
];
|
|
3549
|
+
for (const catalog of seoDirectories) {
|
|
3550
|
+
const files = fs.readdirSync(path.join(PUBLIC_DIR, catalog.dir)).sort((a, b) => a.localeCompare(b));
|
|
3551
|
+
for (const file of files) {
|
|
3552
|
+
if (!file.endsWith('.html')) continue;
|
|
3553
|
+
const publicPath = `${catalog.route}/${file.replace(/\.html$/, '')}`;
|
|
3554
|
+
if (declaredPaths.has(publicPath)) continue;
|
|
3555
|
+
declaredPaths.add(publicPath);
|
|
3556
|
+
entries.push({ path: publicPath, changefreq: 'weekly', priority: catalog.priority });
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
} catch {
|
|
3560
|
+
// SEO directories absent in a stripped bundle — fall back to static entries.
|
|
3561
|
+
}
|
|
3102
3562
|
return [
|
|
3103
3563
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
3104
3564
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
@@ -3224,11 +3684,13 @@ function servePublicMarketingPage({
|
|
|
3224
3684
|
}, req.headers, 'seo_landing_view');
|
|
3225
3685
|
}
|
|
3226
3686
|
|
|
3687
|
+
const requestHost = getSafePublicRequestHost(req);
|
|
3227
3688
|
const html = renderHtml(hostedConfig, {
|
|
3228
3689
|
serverVisitorId: journeyState.visitorId,
|
|
3229
3690
|
serverSessionId: journeyState.sessionId,
|
|
3230
3691
|
serverAcquisitionId: journeyState.acquisitionId,
|
|
3231
3692
|
serverTelemetryCaptured: landingTelemetryCaptured,
|
|
3693
|
+
requestHost,
|
|
3232
3694
|
});
|
|
3233
3695
|
|
|
3234
3696
|
sendHtml(
|
|
@@ -4307,6 +4769,42 @@ function normalizeJobIdFromPath(pathname, suffix = '') {
|
|
|
4307
4769
|
return match ? decodeURIComponent(match[1]) : null;
|
|
4308
4770
|
}
|
|
4309
4771
|
|
|
4772
|
+
function escapeHtml(value) {
|
|
4773
|
+
return String(value)
|
|
4774
|
+
.replaceAll('&', '&')
|
|
4775
|
+
.replaceAll('<', '<')
|
|
4776
|
+
.replaceAll('>', '>')
|
|
4777
|
+
.replaceAll('"', '"')
|
|
4778
|
+
.replaceAll("'", ''');
|
|
4779
|
+
}
|
|
4780
|
+
|
|
4781
|
+
function escapeHtmlUnsafeJsonString(value) {
|
|
4782
|
+
return String(value)
|
|
4783
|
+
.replaceAll('<', String.raw`\u003c`)
|
|
4784
|
+
.replaceAll('>', String.raw`\u003e`)
|
|
4785
|
+
.replaceAll('&', String.raw`\u0026`)
|
|
4786
|
+
.replaceAll('\u2028', String.raw`\u2028`)
|
|
4787
|
+
.replaceAll('\u2029', String.raw`\u2029`);
|
|
4788
|
+
}
|
|
4789
|
+
|
|
4790
|
+
function sanitizeHtmlUnsafeJsonValue(value) {
|
|
4791
|
+
if (typeof value === 'string') {
|
|
4792
|
+
return escapeHtmlUnsafeJsonString(value);
|
|
4793
|
+
}
|
|
4794
|
+
if (Array.isArray(value)) {
|
|
4795
|
+
return value.map((entry) => sanitizeHtmlUnsafeJsonValue(entry));
|
|
4796
|
+
}
|
|
4797
|
+
if (!value || typeof value !== 'object') {
|
|
4798
|
+
return value;
|
|
4799
|
+
}
|
|
4800
|
+
return Object.fromEntries(
|
|
4801
|
+
Object.entries(value).map(([key, entry]) => [
|
|
4802
|
+
escapeHtmlUnsafeJsonString(key),
|
|
4803
|
+
sanitizeHtmlUnsafeJsonValue(entry),
|
|
4804
|
+
])
|
|
4805
|
+
);
|
|
4806
|
+
}
|
|
4807
|
+
|
|
4310
4808
|
function normalizeDocumentIdFromPath(pathname) {
|
|
4311
4809
|
const match = pathname.match(/^\/v1\/documents\/([^/]+)$/);
|
|
4312
4810
|
return match ? decodeURIComponent(match[1]) : null;
|
|
@@ -5078,12 +5576,40 @@ async function addContext(){
|
|
|
5078
5576
|
return;
|
|
5079
5577
|
}
|
|
5080
5578
|
|
|
5579
|
+
if (isGetLikeRequest && (pathname === '/about' || pathname === '/about.html')) {
|
|
5580
|
+
try {
|
|
5581
|
+
servePublicMarketingPage({
|
|
5582
|
+
req,
|
|
5583
|
+
res,
|
|
5584
|
+
parsed,
|
|
5585
|
+
hostedConfig,
|
|
5586
|
+
isHeadRequest,
|
|
5587
|
+
renderHtml: loadAboutPageHtml,
|
|
5588
|
+
extraTelemetry: {
|
|
5589
|
+
pageType: 'about',
|
|
5590
|
+
},
|
|
5591
|
+
});
|
|
5592
|
+
} catch (err) {
|
|
5593
|
+
sendText(res, 500, err.message || 'About page unavailable');
|
|
5594
|
+
}
|
|
5595
|
+
return;
|
|
5596
|
+
}
|
|
5597
|
+
|
|
5081
5598
|
if (isGetLikeRequest && (pathname === '/guide' || pathname === '/guide.html')) {
|
|
5082
5599
|
try {
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5600
|
+
servePublicMarketingPage({
|
|
5601
|
+
req,
|
|
5602
|
+
res,
|
|
5603
|
+
parsed,
|
|
5604
|
+
hostedConfig,
|
|
5605
|
+
isHeadRequest,
|
|
5606
|
+
renderHtml: loadGuidePageHtml,
|
|
5607
|
+
extraTelemetry: {
|
|
5608
|
+
pageType: 'guide',
|
|
5609
|
+
},
|
|
5610
|
+
});
|
|
5611
|
+
} catch (err) {
|
|
5612
|
+
sendText(res, 500, err.message || 'Guide page unavailable');
|
|
5087
5613
|
}
|
|
5088
5614
|
return;
|
|
5089
5615
|
}
|
|
@@ -5143,10 +5669,19 @@ async function addContext(){
|
|
|
5143
5669
|
|
|
5144
5670
|
if (isGetLikeRequest && (pathname === '/learn' || pathname === '/learn.html')) {
|
|
5145
5671
|
try {
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5672
|
+
servePublicMarketingPage({
|
|
5673
|
+
req,
|
|
5674
|
+
res,
|
|
5675
|
+
parsed,
|
|
5676
|
+
hostedConfig,
|
|
5677
|
+
isHeadRequest,
|
|
5678
|
+
renderHtml: loadLearnPageHtml,
|
|
5679
|
+
extraTelemetry: {
|
|
5680
|
+
pageType: 'learn',
|
|
5681
|
+
},
|
|
5682
|
+
});
|
|
5683
|
+
} catch (err) {
|
|
5684
|
+
sendText(res, 500, err.message || 'Learn page unavailable');
|
|
5150
5685
|
}
|
|
5151
5686
|
return;
|
|
5152
5687
|
}
|
|
@@ -5337,13 +5872,13 @@ async function addContext(){
|
|
|
5337
5872
|
|
|
5338
5873
|
if (isGetLikeRequest && pathname.startsWith('/learn/')) {
|
|
5339
5874
|
try {
|
|
5340
|
-
const
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
sendJson(res, 403, { error: 'Forbidden' });
|
|
5875
|
+
const articlePath = resolvePublicHtmlFile(LEARN_PAGE_PATHS_BY_SLUG, pathname.replace('/learn/', ''));
|
|
5876
|
+
if (!articlePath) {
|
|
5877
|
+
sendJson(res, 404, { error: 'Article not found' });
|
|
5344
5878
|
return;
|
|
5345
5879
|
}
|
|
5346
|
-
const
|
|
5880
|
+
const requestHost = getSafePublicRequestHost(req);
|
|
5881
|
+
const html = normalizePublicMarketingHtml(fs.readFileSync(articlePath, 'utf-8'), hostedConfig, requestHost);
|
|
5347
5882
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
5348
5883
|
} catch {
|
|
5349
5884
|
sendJson(res, 404, { error: 'Article not found' });
|
|
@@ -5353,10 +5888,10 @@ async function addContext(){
|
|
|
5353
5888
|
|
|
5354
5889
|
if (isGetLikeRequest && pathname.startsWith('/guides/')) {
|
|
5355
5890
|
try {
|
|
5356
|
-
const
|
|
5357
|
-
|
|
5358
|
-
|
|
5359
|
-
const html = fs.readFileSync(guidePath, 'utf-8');
|
|
5891
|
+
const guidePath = resolvePublicHtmlFile(GUIDE_PAGE_PATHS_BY_SLUG, pathname.replace('/guides/', ''));
|
|
5892
|
+
if (!guidePath) { sendJson(res, 404, { error: 'Guide not found' }); return; }
|
|
5893
|
+
const requestHost = getSafePublicRequestHost(req);
|
|
5894
|
+
const html = normalizePublicMarketingHtml(fs.readFileSync(guidePath, 'utf-8'), hostedConfig, requestHost);
|
|
5360
5895
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
5361
5896
|
} catch { sendJson(res, 404, { error: 'Guide not found' }); }
|
|
5362
5897
|
return;
|
|
@@ -5364,10 +5899,10 @@ async function addContext(){
|
|
|
5364
5899
|
|
|
5365
5900
|
if (isGetLikeRequest && pathname.startsWith('/compare/') && pathname !== '/compare') {
|
|
5366
5901
|
try {
|
|
5367
|
-
const
|
|
5368
|
-
|
|
5369
|
-
|
|
5370
|
-
const html = fs.readFileSync(comparePath, 'utf-8');
|
|
5902
|
+
const comparePath = resolvePublicHtmlFile(COMPARE_PAGE_PATHS_BY_SLUG, pathname.replace('/compare/', ''));
|
|
5903
|
+
if (!comparePath) { sendJson(res, 404, { error: 'Comparison not found' }); return; }
|
|
5904
|
+
const requestHost = getSafePublicRequestHost(req);
|
|
5905
|
+
const html = normalizePublicMarketingHtml(fs.readFileSync(comparePath, 'utf-8'), hostedConfig, requestHost);
|
|
5371
5906
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
5372
5907
|
} catch { sendJson(res, 404, { error: 'Comparison not found' }); }
|
|
5373
5908
|
return;
|
|
@@ -5375,10 +5910,10 @@ async function addContext(){
|
|
|
5375
5910
|
|
|
5376
5911
|
if (isGetLikeRequest && pathname.startsWith('/use-cases/')) {
|
|
5377
5912
|
try {
|
|
5378
|
-
const
|
|
5379
|
-
|
|
5380
|
-
|
|
5381
|
-
const html = fs.readFileSync(useCasePath, 'utf-8');
|
|
5913
|
+
const useCasePath = resolvePublicHtmlFile(USE_CASE_PAGE_PATHS_BY_SLUG, pathname.replace('/use-cases/', ''));
|
|
5914
|
+
if (!useCasePath) { sendJson(res, 404, { error: 'Use case not found' }); return; }
|
|
5915
|
+
const requestHost = getSafePublicRequestHost(req);
|
|
5916
|
+
const html = normalizePublicMarketingHtml(fs.readFileSync(useCasePath, 'utf-8'), hostedConfig, requestHost);
|
|
5382
5917
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
5383
5918
|
} catch { sendJson(res, 404, { error: 'Use case not found' }); }
|
|
5384
5919
|
return;
|
|
@@ -5395,6 +5930,18 @@ async function addContext(){
|
|
|
5395
5930
|
return;
|
|
5396
5931
|
}
|
|
5397
5932
|
|
|
5933
|
+
if (isGetLikeRequest && pathname.startsWith('/media/')) {
|
|
5934
|
+
const rel = pathname.slice('/media/'.length);
|
|
5935
|
+
const mediaDir = path.join(PUBLIC_DIR, 'media');
|
|
5936
|
+
const resolved = path.resolve(mediaDir, rel);
|
|
5937
|
+
if (!resolved.startsWith(mediaDir + path.sep) && resolved !== mediaDir) {
|
|
5938
|
+
sendJson(res, 403, { error: 'Forbidden' });
|
|
5939
|
+
return;
|
|
5940
|
+
}
|
|
5941
|
+
serveStaticFile(res, resolved, { headOnly: isHeadRequest });
|
|
5942
|
+
return;
|
|
5943
|
+
}
|
|
5944
|
+
|
|
5398
5945
|
if (isGetLikeRequest && (
|
|
5399
5946
|
pathname === '/favicon.ico'
|
|
5400
5947
|
|| pathname === '/thumbgate-logo.png'
|
|
@@ -5476,8 +6023,70 @@ async function addContext(){
|
|
|
5476
6023
|
// because no real crawler appends customer_email to discovered URLs.
|
|
5477
6024
|
const hasCustomerEmailHint = !!parsed?.searchParams?.has('customer_email');
|
|
5478
6025
|
const botShouldBypass = !botClassification.isBot || hasCustomerEmailHint;
|
|
5479
|
-
|
|
6026
|
+
// 2026-06-04 audit: after the POST-401 fix, 3 of 4 fresh /checkout/pro
|
|
6027
|
+
// sessions had customer_email=null in Stripe (zombie sessions with no
|
|
6028
|
+
// recovery surface). Root cause: POSTs were auto-confirmed regardless of
|
|
6029
|
+
// whether the email query param was present. Require email on POSTs too
|
|
6030
|
+
// so emails-less POSTs fall through to the interstitial form instead of
|
|
6031
|
+
// creating an un-recoverable Stripe session.
|
|
6032
|
+
const isConfirmedCheckout = (req.method === 'POST' && hasCustomerEmailHint)
|
|
5480
6033
|
|| (hasConfirmFlag && botShouldBypass);
|
|
6034
|
+
// 2026-06-05 revenue bypass: env-gated direct-to-Stripe redirect.
|
|
6035
|
+
// Live 30d billing showed 254 interstitial views → 1 Stripe click-through
|
|
6036
|
+
// → 0 paid. When THUMBGATE_CHECKOUT_INTERSTITIAL_BYPASS=1 is set we
|
|
6037
|
+
// route raw /checkout/pro GETs (no confirm=1, no POST) straight to the
|
|
6038
|
+
// pro Stripe Payment Link, preserving UTM + attribution metadata via
|
|
6039
|
+
// buildCheckoutFallbackUrl. Default-off; bot-deflection still applies
|
|
6040
|
+
// (bot + no email hint still falls through to the existing interstitial).
|
|
6041
|
+
const interstitialBypassEnabled = process.env.THUMBGATE_CHECKOUT_INTERSTITIAL_BYPASS === '1';
|
|
6042
|
+
const interstitialSampleRate = normalizeCheckoutInterstitialSampleRate(
|
|
6043
|
+
process.env.THUMBGATE_CHECKOUT_INTERSTITIAL_SAMPLE_RATE
|
|
6044
|
+
);
|
|
6045
|
+
const interstitialSampled = shouldSampleCheckoutInterstitial({
|
|
6046
|
+
sampleRate: interstitialSampleRate,
|
|
6047
|
+
traceId,
|
|
6048
|
+
analyticsMetadata,
|
|
6049
|
+
});
|
|
6050
|
+
if (
|
|
6051
|
+
!isConfirmedCheckout
|
|
6052
|
+
&& interstitialBypassEnabled
|
|
6053
|
+
&& req.method !== 'POST'
|
|
6054
|
+
&& botShouldBypass
|
|
6055
|
+
&& !interstitialSampled
|
|
6056
|
+
) {
|
|
6057
|
+
// Always target the pro Stripe Payment Link directly. The
|
|
6058
|
+
// hostedConfig.checkoutFallbackUrl (e.g. https://thumbgate.ai/go/pro)
|
|
6059
|
+
// is a router that 302s back to /checkout/pro, which would create a
|
|
6060
|
+
// redirect loop when bypass is on. Env override via
|
|
6061
|
+
// THUMBGATE_CHECKOUT_PRO_STRIPE_URL is supported for future
|
|
6062
|
+
// price-link rotation without a redeploy.
|
|
6063
|
+
const bypassTarget = process.env.THUMBGATE_CHECKOUT_PRO_STRIPE_URL
|
|
6064
|
+
|| FIRST_FAILURE_RULE_CHECKOUT_URL;
|
|
6065
|
+
appendBestEffortTelemetry(FEEDBACK_DIR, {
|
|
6066
|
+
eventType: 'checkout_interstitial_bypass_redirect',
|
|
6067
|
+
clientType: 'web',
|
|
6068
|
+
traceId,
|
|
6069
|
+
acquisitionId: analyticsMetadata.acquisitionId,
|
|
6070
|
+
visitorId: analyticsMetadata.visitorId,
|
|
6071
|
+
sessionId: analyticsMetadata.sessionId,
|
|
6072
|
+
utmSource: analyticsMetadata.utmSource,
|
|
6073
|
+
utmMedium: analyticsMetadata.utmMedium,
|
|
6074
|
+
utmCampaign: analyticsMetadata.utmCampaign,
|
|
6075
|
+
utmContent: analyticsMetadata.utmContent,
|
|
6076
|
+
utmTerm: analyticsMetadata.utmTerm,
|
|
6077
|
+
referrer: analyticsMetadata.referrer,
|
|
6078
|
+
referrerHost: analyticsMetadata.referrerHost,
|
|
6079
|
+
page: '/checkout/pro',
|
|
6080
|
+
planId: analyticsMetadata.planId,
|
|
6081
|
+
interstitialSampleRate,
|
|
6082
|
+
}, req.headers, 'checkout_interstitial_bypass_redirect');
|
|
6083
|
+
res.writeHead(302, {
|
|
6084
|
+
...responseHeaders,
|
|
6085
|
+
Location: buildCheckoutFallbackUrl(bypassTarget, analyticsMetadata),
|
|
6086
|
+
});
|
|
6087
|
+
res.end();
|
|
6088
|
+
return;
|
|
6089
|
+
}
|
|
5481
6090
|
// Plausible funnel event #1 of 3: page view. Fired before interstitial
|
|
5482
6091
|
// deflection so we get the full top-of-funnel count, with isBot as a
|
|
5483
6092
|
// prop so the dashboard can filter human vs. crawler traffic. Fire-and-forget.
|
|
@@ -5536,10 +6145,14 @@ async function addContext(){
|
|
|
5536
6145
|
billingCycle: analyticsMetadata.billingCycle,
|
|
5537
6146
|
landingPath: analyticsMetadata.landingPath,
|
|
5538
6147
|
isBot: botClassification.isBot ? 'true' : 'false',
|
|
6148
|
+
interstitialSampled: interstitialSampled ? 'true' : 'false',
|
|
6149
|
+
interstitialSampleRate,
|
|
5539
6150
|
reason: botClassification.reason,
|
|
5540
6151
|
}, req.headers, eventType);
|
|
5541
6152
|
const prefilledEmail = parsed?.searchParams?.get('customer_email') || '';
|
|
5542
|
-
const html = renderCheckoutIntentPage(prefilledEmail
|
|
6153
|
+
const html = renderCheckoutIntentPage(prefilledEmail, parsed, {
|
|
6154
|
+
includeHiddenAttribution: !botClassification.isBot,
|
|
6155
|
+
});
|
|
5543
6156
|
sendHtml(res, 200, html, responseHeaders);
|
|
5544
6157
|
return;
|
|
5545
6158
|
}
|
|
@@ -5802,12 +6415,13 @@ async function addContext(){
|
|
|
5802
6415
|
// Public-facing broker lead-flow audit landing page. Wedge for the
|
|
5803
6416
|
// real-estate broker outreach. Static HTML served from src/api/static.
|
|
5804
6417
|
try {
|
|
6418
|
+
const host = getSafePublicRequestHost(req);
|
|
5805
6419
|
const html = normalizePublicMarketingHtml(fillTemplate(fs.readFileSync(
|
|
5806
6420
|
path.resolve(__dirname, '../../assets/static/broker-audit.html'),
|
|
5807
6421
|
'utf8'
|
|
5808
6422
|
), {
|
|
5809
6423
|
'__POSTHOG_API_KEY__': hostedConfig.posthogApiKey || '',
|
|
5810
|
-
}), hostedConfig);
|
|
6424
|
+
}), hostedConfig, host);
|
|
5811
6425
|
if (isHeadRequest) {
|
|
5812
6426
|
sendHtml(res, 200, html, {}, { headOnly: true });
|
|
5813
6427
|
return;
|
|
@@ -5859,9 +6473,9 @@ async function addContext(){
|
|
|
5859
6473
|
// Authorization endpoint: GET renders consent, POST issues the code.
|
|
5860
6474
|
if (pathname === '/oauth/authorize') {
|
|
5861
6475
|
if (isGetLikeRequest) {
|
|
5862
|
-
const
|
|
5863
|
-
|
|
5864
|
-
|
|
6476
|
+
const authRequestToken = createPendingOauthAuthorizeRequest(
|
|
6477
|
+
getOauthAuthorizeParamsFromQuery(parsed.searchParams)
|
|
6478
|
+
);
|
|
5865
6479
|
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Authorize ThumbGate</title>
|
|
5866
6480
|
<style>body{font:15px system-ui;margin:0;background:#0b0b0c;color:#eee;display:flex;min-height:100vh;align-items:center;justify-content:center}
|
|
5867
6481
|
.card{background:#161618;border:1px solid #2a2a2e;border-radius:12px;padding:28px;max-width:420px}
|
|
@@ -5870,7 +6484,7 @@ button{width:100%;padding:11px;border-radius:8px;border:0;background:#10b981;col
|
|
|
5870
6484
|
a{color:#8b9}</style></head><body><form class="card" method="post" action="/oauth/authorize">
|
|
5871
6485
|
<h2>Authorize Claude → ThumbGate</h2>
|
|
5872
6486
|
<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>
|
|
5873
|
-
${
|
|
6487
|
+
<input type="hidden" name="auth_request_token" value="${escapeHtmlAttribute(authRequestToken)}">
|
|
5874
6488
|
<input type="password" name="api_key" placeholder="ThumbGate API key" autocomplete="off" required>
|
|
5875
6489
|
<button type="submit" name="approve" value="yes">Approve</button>
|
|
5876
6490
|
</form></body></html>`;
|
|
@@ -5882,8 +6496,9 @@ ${hidden}
|
|
|
5882
6496
|
req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
|
|
5883
6497
|
req.on('end', () => {
|
|
5884
6498
|
const form = new URLSearchParams(body);
|
|
5885
|
-
const
|
|
5886
|
-
const
|
|
6499
|
+
const authorizationParams = getOauthAuthorizeParamsFromForm(form, hostedConfig);
|
|
6500
|
+
const redirectUri = authorizationParams.redirectUri;
|
|
6501
|
+
const state = authorizationParams.state;
|
|
5887
6502
|
// Validate the presented ThumbGate key before issuing a code. When keys
|
|
5888
6503
|
// are configured (production) the key MUST match a configured admin /
|
|
5889
6504
|
// operator / reviewer key — otherwise OAuth would authenticate nobody.
|
|
@@ -5893,12 +6508,12 @@ ${hidden}
|
|
|
5893
6508
|
return;
|
|
5894
6509
|
}
|
|
5895
6510
|
const issued = mcpOauth.createAuthorizationCode(oauthStore, {
|
|
5896
|
-
clientId:
|
|
6511
|
+
clientId: authorizationParams.clientId,
|
|
5897
6512
|
redirectUri,
|
|
5898
|
-
codeChallenge:
|
|
5899
|
-
codeChallengeMethod:
|
|
5900
|
-
scope:
|
|
5901
|
-
resource:
|
|
6513
|
+
codeChallenge: authorizationParams.codeChallenge,
|
|
6514
|
+
codeChallengeMethod: authorizationParams.codeChallengeMethod,
|
|
6515
|
+
scope: authorizationParams.scope,
|
|
6516
|
+
resource: authorizationParams.resource || buildPublicUrl(hostedConfig, '/mcp'),
|
|
5902
6517
|
boundKey: form.get('api_key') || '',
|
|
5903
6518
|
state,
|
|
5904
6519
|
});
|
|
@@ -7303,7 +7918,9 @@ ${hidden}
|
|
|
7303
7918
|
|
|
7304
7919
|
try {
|
|
7305
7920
|
if (req.method === 'GET' && pathname === '/v1/feedback/stats') {
|
|
7306
|
-
const stats =
|
|
7921
|
+
const stats = shouldAggregateFeedback()
|
|
7922
|
+
? computeAggregateFeedbackStats({ feedbackDir: requestFeedbackPaths.FEEDBACK_DIR })
|
|
7923
|
+
: analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH);
|
|
7307
7924
|
try {
|
|
7308
7925
|
const { getStatuslineMeta } = require('../../scripts/statusline-meta');
|
|
7309
7926
|
const meta = getStatuslineMeta({ env: process.env });
|
|
@@ -7335,6 +7952,9 @@ ${hidden}
|
|
|
7335
7952
|
stats.geminiKeyStatus = 'validated';
|
|
7336
7953
|
}
|
|
7337
7954
|
stats.hybridInferenceAvailable = !!(stats.geminiConfigured || stats.perplexityConfigured);
|
|
7955
|
+
stats.localLlmConfigured = Boolean(process.env.THUMBGATE_LOCAL_LLM_ENDPOINT);
|
|
7956
|
+
stats.localLlmEndpoint = process.env.THUMBGATE_LOCAL_LLM_ENDPOINT || null;
|
|
7957
|
+
stats.localLlmModel = process.env.THUMBGATE_LOCAL_LLM_MODEL || null;
|
|
7338
7958
|
sendJson(res, 200, stats);
|
|
7339
7959
|
return;
|
|
7340
7960
|
}
|
|
@@ -7930,11 +8550,13 @@ ${hidden}
|
|
|
7930
8550
|
const signal = parsed.searchParams.get('signal') || null;
|
|
7931
8551
|
let results;
|
|
7932
8552
|
try {
|
|
8553
|
+
const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
|
|
7933
8554
|
results = searchThumbgate({
|
|
7934
8555
|
query,
|
|
7935
8556
|
limit: Number.isFinite(limit) ? limit : 10,
|
|
7936
8557
|
source,
|
|
7937
8558
|
signal,
|
|
8559
|
+
feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
|
|
7938
8560
|
});
|
|
7939
8561
|
} catch (err) {
|
|
7940
8562
|
throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
|
|
@@ -7952,11 +8574,13 @@ ${hidden}
|
|
|
7952
8574
|
const body = await parseJsonBody(req);
|
|
7953
8575
|
let results;
|
|
7954
8576
|
try {
|
|
8577
|
+
const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
|
|
7955
8578
|
results = searchThumbgate({
|
|
7956
8579
|
query: body.query || body.q || '',
|
|
7957
8580
|
limit: body.limit,
|
|
7958
8581
|
source: body.source,
|
|
7959
8582
|
signal: body.signal,
|
|
8583
|
+
feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
|
|
7960
8584
|
});
|
|
7961
8585
|
} catch (err) {
|
|
7962
8586
|
throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
|
|
@@ -7982,11 +8606,14 @@ ${hidden}
|
|
|
7982
8606
|
{
|
|
7983
8607
|
const documentId = normalizeDocumentIdFromPath(pathname);
|
|
7984
8608
|
if (req.method === 'GET' && documentId) {
|
|
8609
|
+
if (!/^[a-zA-Z0-9-_]+$/.test(documentId)) {
|
|
8610
|
+
throw createHttpError(400, 'Invalid document ID format');
|
|
8611
|
+
}
|
|
7985
8612
|
const document = readImportedDocument(documentId, {
|
|
7986
8613
|
feedbackDir: requestFeedbackDir,
|
|
7987
8614
|
});
|
|
7988
8615
|
if (!document) {
|
|
7989
|
-
throw createHttpError(404, `Imported document not found: ${documentId}`);
|
|
8616
|
+
throw createHttpError(404, `Imported document not found: ${escapeHtml(documentId)}`);
|
|
7990
8617
|
}
|
|
7991
8618
|
sendJson(res, 200, { document });
|
|
7992
8619
|
return;
|
|
@@ -9082,6 +9709,8 @@ module.exports = {
|
|
|
9082
9709
|
buildEnterpriseChatAnswer,
|
|
9083
9710
|
answerEnterpriseDataChat,
|
|
9084
9711
|
answerEnterpriseDialogflowChat,
|
|
9712
|
+
buildLossAnalyticsResponse,
|
|
9713
|
+
sanitizeHtmlUnsafeJsonValue,
|
|
9085
9714
|
},
|
|
9086
9715
|
};
|
|
9087
9716
|
|