thumbgate 1.27.4 → 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/dashboard.md +15 -0
- package/.claude/commands/thumbgate-blocked.md +27 -0
- package/.claude/commands/thumbgate-dashboard.md +15 -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 +2 -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 +230 -6
- package/bin/postinstall.js +1 -1
- package/commands/dashboard.md +15 -0
- package/commands/thumbgate-dashboard.md +15 -0
- package/config/gate-templates.json +84 -0
- package/config/gates/claim-verification.json +12 -0
- package/config/gates/default.json +20 -0
- package/config/github-about.json +1 -1
- package/config/model-candidates.json +50 -0
- package/config/post-deploy-marketing-pages.json +5 -0
- package/package.json +67 -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 +316 -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 +88 -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/billing.js +12 -1
- package/scripts/build-metadata.js +24 -3
- package/scripts/cli-schema.js +42 -10
- package/scripts/dashboard-chat.js +53 -7
- package/scripts/dashboard.js +12 -17
- 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 +121 -0
- package/scripts/filesystem-search.js +35 -10
- package/scripts/gates-engine.js +234 -7
- 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/hybrid-feedback-context.js +1 -0
- 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 +15 -2
- package/scripts/plausible-server-events.js +4 -4
- 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 +862 -146
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',
|
|
@@ -1550,14 +1595,104 @@ function normalizeEnterpriseChatPrompt(value) {
|
|
|
1550
1595
|
|
|
1551
1596
|
function classifyEnterpriseChatTopic(prompt) {
|
|
1552
1597
|
const lower = String(prompt || '').toLowerCase();
|
|
1553
|
-
|
|
1554
|
-
|
|
1598
|
+
// Feedback-specific words run FIRST: "what mistakes were blocked today" is a
|
|
1599
|
+
// feedback question, not a gates question — must not be hijacked by /block/.
|
|
1600
|
+
if (/mistake|lesson|memory|feedback|thumb|negative|positive|what (?:went )?wrong|fail|win|success|worked|good/.test(lower)) return 'feedback';
|
|
1601
|
+
if (/gate|block|deny|prevent|guard|enforce/.test(lower)) return 'gates';
|
|
1555
1602
|
if (/team|agent|org|enterprise|rollout/.test(lower)) return 'team';
|
|
1556
1603
|
if (/token|cost|saving|budget|spend/.test(lower)) return 'cost';
|
|
1557
1604
|
if (/vertex|gcp|google|dialogflow|dfcx|cloud/.test(lower)) return 'cloud';
|
|
1558
1605
|
return 'overview';
|
|
1559
1606
|
}
|
|
1560
1607
|
|
|
1608
|
+
// Parse intent: LIST vs COUNT, and time window. Lets us answer "what mistakes
|
|
1609
|
+
// today?" with an actual filtered list instead of a canned total.
|
|
1610
|
+
function parseChatIntent(prompt) {
|
|
1611
|
+
const lower = String(prompt || '').toLowerCase();
|
|
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);
|
|
1616
|
+
let windowMs = null;
|
|
1617
|
+
let windowLabel = 'across all time';
|
|
1618
|
+
if (/\btoday\b/.test(lower)) { windowMs = 24 * 60 * 60 * 1000; windowLabel = 'today'; }
|
|
1619
|
+
else if (/\byesterday\b/.test(lower)) { windowMs = 48 * 60 * 60 * 1000; windowLabel = 'yesterday'; }
|
|
1620
|
+
else if (/\bthis week\b|\b7 ?d(ay)?s?\b|\blast week\b/.test(lower)) { windowMs = 7 * 24 * 60 * 60 * 1000; windowLabel = 'the last 7 days'; }
|
|
1621
|
+
else if (/\bthis month\b|\b30 ?d(ay)?s?\b|\blast month\b/.test(lower)) { windowMs = 30 * 24 * 60 * 60 * 1000; windowLabel = 'the last 30 days'; }
|
|
1622
|
+
return { wantsList, windowMs, windowLabel };
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// Read recent feedback entries (signal-filtered, time-filtered) directly from
|
|
1626
|
+
// the feedback log. Bounded + best-effort — never throws into the chat handler.
|
|
1627
|
+
// Treat short/placeholder context values as "no real description" so we don't
|
|
1628
|
+
// surface `"thumbs down"` × 3 as a useful list. Real feedback always has a
|
|
1629
|
+
// concrete sentence somewhere (whatWentWrong, distillation, whatToChange) —
|
|
1630
|
+
// pick the longest informative one.
|
|
1631
|
+
// Short tokens that mean "no real description" — kept as a plain Set, not a
|
|
1632
|
+
// big regex, to keep complexity low and easy to extend.
|
|
1633
|
+
const PLACEHOLDER_TOKENS = new Set([
|
|
1634
|
+
'thumbs down', 'thumbs up', 'thumb down', 'thumb up',
|
|
1635
|
+
'good', 'bad', 'ok', 'nice', 'verify', 'verifies', 'verification', 'test', 'testing',
|
|
1636
|
+
]);
|
|
1637
|
+
|
|
1638
|
+
function isPlaceholder(text) {
|
|
1639
|
+
const t = String(text || '').trim();
|
|
1640
|
+
if (!t || t.length < 20) return true;
|
|
1641
|
+
return PLACEHOLDER_TOKENS.has(t.toLowerCase().replace(/\.$/, ''));
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
function bestFeedbackDescription(row) {
|
|
1645
|
+
const candidates = [row.whatWentWrong, row.distillation, row.context, row.whatToChange, row.whatWorked, row.reasoning]
|
|
1646
|
+
.map((c) => String(c || '').trim());
|
|
1647
|
+
// Prefer the longest non-placeholder candidate; fall back to any non-empty.
|
|
1648
|
+
const informative = candidates.filter((c) => c && !isPlaceholder(c));
|
|
1649
|
+
const fallback = candidates.find(Boolean) || '';
|
|
1650
|
+
const best = informative.reduce((a, b) => (b.length > a.length ? b : a), '') || fallback;
|
|
1651
|
+
return best.slice(0, 220);
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
function readRecentFeedbackEntries(feedbackDir, signal, windowMs, limit = 5, opts = {}) {
|
|
1655
|
+
try {
|
|
1656
|
+
if (!feedbackDir) return [];
|
|
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
|
+
})();
|
|
1667
|
+
const cutoff = windowMs ? Date.now() - windowMs : 0;
|
|
1668
|
+
const filtered = rows
|
|
1669
|
+
.filter((r) => !signal || r.signal === signal)
|
|
1670
|
+
.filter((r) => {
|
|
1671
|
+
if (!cutoff) return true;
|
|
1672
|
+
const t = r.timestamp ? Date.parse(r.timestamp) : Number.NaN;
|
|
1673
|
+
return Number.isFinite(t) && t >= cutoff;
|
|
1674
|
+
})
|
|
1675
|
+
.reverse()
|
|
1676
|
+
.map((r) => {
|
|
1677
|
+
const out = {
|
|
1678
|
+
timestamp: r.timestamp,
|
|
1679
|
+
context: bestFeedbackDescription(r),
|
|
1680
|
+
tags: Array.isArray(r.tags) ? r.tags : []
|
|
1681
|
+
};
|
|
1682
|
+
if (opts.includeSignal) out.signal = r.signal;
|
|
1683
|
+
return out;
|
|
1684
|
+
});
|
|
1685
|
+
// For list display, drop entries with no real description so the list is useful,
|
|
1686
|
+
// not three "thumbs down" placeholders. For count-only (high limit), keep all.
|
|
1687
|
+
const useful = opts.skipPlaceholders === false || limit > 50
|
|
1688
|
+
? filtered
|
|
1689
|
+
: filtered.filter((e) => e.context && !isPlaceholder(e.context));
|
|
1690
|
+
return useful.slice(0, limit);
|
|
1691
|
+
} catch {
|
|
1692
|
+
return [];
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1561
1696
|
function containsUnsafeEnterpriseChatInput(prompt) {
|
|
1562
1697
|
return /[;&|`$<>\\]/.test(String(prompt || ''));
|
|
1563
1698
|
}
|
|
@@ -1567,101 +1702,135 @@ function compactNumber(value) {
|
|
|
1567
1702
|
return Number.isFinite(n) ? n : 0;
|
|
1568
1703
|
}
|
|
1569
1704
|
|
|
1570
|
-
|
|
1571
|
-
|
|
1705
|
+
// Pick a signal preference from the prompt. Returns 'negative' | 'positive' | null.
|
|
1706
|
+
function detectFeedbackSignalFromPrompt(prompt) {
|
|
1707
|
+
const lower = String(prompt || '').toLowerCase();
|
|
1708
|
+
const wantsNeg = /mistake|wrong|fail|negative|thumbs? *down|block/.test(lower);
|
|
1709
|
+
const wantsPos = /positive|thumbs? *up|worked|success|wins?\b|good/.test(lower);
|
|
1710
|
+
if (wantsNeg && !wantsPos) return 'negative';
|
|
1711
|
+
if (wantsPos && !wantsNeg) return 'positive';
|
|
1712
|
+
return null;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
const FEEDBACK_LIST_LABELS = Object.freeze({
|
|
1716
|
+
negative: 'Recent mistakes',
|
|
1717
|
+
positive: 'Recent wins',
|
|
1718
|
+
});
|
|
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
|
+
}
|
|
1572
1738
|
}
|
|
1573
1739
|
|
|
1574
|
-
function
|
|
1575
|
-
|
|
1576
|
-
|
|
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);
|
|
1577
1744
|
}
|
|
1578
1745
|
|
|
1579
|
-
function
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
.
|
|
1584
|
-
|
|
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}`;
|
|
1585
1754
|
}
|
|
1586
1755
|
|
|
1587
|
-
function
|
|
1588
|
-
if (!
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
const prevention = dashboardData.prevention || {};
|
|
1592
|
-
const today = getTodayGateAudit(gateAudit) || { deny: 0, warn: 0, intercepted: 0, byGate: {} };
|
|
1593
|
-
const activeGateCount = gates.length || compactNumber(gateStats.totalGates);
|
|
1594
|
-
|
|
1595
|
-
if (/\b(activated|promoted|created|enabled)\b/.test(lower)) {
|
|
1596
|
-
const promotionsToday = compactNumber(prevention.promotionsToday);
|
|
1597
|
-
const promotedIds = Array.isArray(prevention.promotionIdsToday) ? prevention.promotionIdsToday.filter(Boolean) : [];
|
|
1598
|
-
return [
|
|
1599
|
-
`Gates activated today: ${promotionsToday}.`,
|
|
1600
|
-
`Active gates now: ${activeGateCount}.`,
|
|
1601
|
-
promotedIds.length > 0 ? `Promoted today: ${promotedIds.slice(0, 3).join(', ')}.` : '',
|
|
1602
|
-
].filter(Boolean);
|
|
1756
|
+
function appendFeedbackListLines(lines, { entries, signal, intent }) {
|
|
1757
|
+
if (!entries.length) {
|
|
1758
|
+
lines.push(`No ${signal || 'feedback'} entries found ${intent.windowLabel}.`);
|
|
1759
|
+
return;
|
|
1603
1760
|
}
|
|
1761
|
+
lines.push(`${FEEDBACK_LIST_LABELS[signal] || 'Recent feedback'} (${intent.windowLabel}):`);
|
|
1762
|
+
lines.push(...entries.map(formatFeedbackEntry));
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
function buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeline }) {
|
|
1766
|
+
const signal = detectFeedbackSignalFromPrompt(ctx.prompt);
|
|
1767
|
+
// One read of the time-windowed log, then in-memory counts + (signal-filtered,
|
|
1768
|
+
// placeholder-stripped) list. Counts include ALL entries (so "Feedback today: 5"
|
|
1769
|
+
// matches the dashboard tile); the list drops vague entries like literal "thumbs
|
|
1770
|
+
// down" / "good" so it surfaces only entries with real, actionable description.
|
|
1771
|
+
const windowed = readRecentFeedbackEntries(feedbackDir, null, intent.windowMs, 10000, { includeSignal: true });
|
|
1772
|
+
const windowPos = windowed.filter((r) => r.signal === 'positive').length;
|
|
1773
|
+
const windowNeg = windowed.filter((r) => r.signal === 'negative').length;
|
|
1774
|
+
const entries = buildFeedbackEntries(windowed, signal);
|
|
1775
|
+
|
|
1776
|
+
const lines = [
|
|
1777
|
+
intent.windowMs
|
|
1778
|
+
? `Feedback ${intent.windowLabel}: ${windowed.length} (${windowPos} positive, ${windowNeg} negative).`
|
|
1779
|
+
: `Feedback total: ${compactNumber(approval.total)} (${compactNumber(approval.positive)} positive, ${compactNumber(approval.negative)} negative).`,
|
|
1780
|
+
];
|
|
1604
1781
|
|
|
1605
|
-
if (
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
gateBuckets.length > 0
|
|
1610
|
-
? `Top blocked/warned gates today: ${gateBuckets.join(', ')}.`
|
|
1611
|
-
: 'No per-gate blocked mistake names are present in today\'s local audit snapshot.',
|
|
1612
|
-
];
|
|
1782
|
+
if (intent.wantsList) {
|
|
1783
|
+
appendFeedbackListLines(lines, { entries, signal, intent });
|
|
1784
|
+
} else {
|
|
1785
|
+
lines.push(`Lesson pipeline: ${compactNumber(lessonPipeline.lessons || lessonPipeline.generated || 0)} lessons visible in the current dashboard snapshot.`);
|
|
1613
1786
|
}
|
|
1787
|
+
return { lines, sources: ['feedback log', intent.wantsList ? 'feedback contexts' : 'lesson pipeline'] };
|
|
1788
|
+
}
|
|
1614
1789
|
|
|
1615
|
-
|
|
1616
|
-
return [
|
|
1617
|
-
`Mistakes prevented today: ${compactNumber(today.intercepted)} interventions (${compactNumber(today.deny)} blocked, ${compactNumber(today.warn)} warned).`,
|
|
1618
|
-
`All-time blocked actions recorded: ${compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked)}.`,
|
|
1619
|
-
];
|
|
1620
|
-
}
|
|
1790
|
+
const GATE_EVENTS_REGEX = /activat|fired|trigger|block|denied|prevent|enforce|hit/;
|
|
1621
1791
|
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
`Warnings today: ${compactNumber(today.warn)}; all-time blocked actions recorded: ${compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked)}.`,
|
|
1626
|
-
];
|
|
1627
|
-
}
|
|
1792
|
+
function describeGate(g) {
|
|
1793
|
+
return `${g.name || g.id || 'unnamed'}${g.severity ? ' [' + g.severity + ']' : ''}`;
|
|
1794
|
+
}
|
|
1628
1795
|
|
|
1629
|
-
|
|
1796
|
+
function buildGatesSection({ ctx, intent, gates, gateStats }) {
|
|
1797
|
+
const asksEvents = GATE_EVENTS_REGEX.test(String(ctx.prompt || '').toLowerCase());
|
|
1798
|
+
const totalActive = gates.length || compactNumber(gateStats.totalGates);
|
|
1799
|
+
const totalBlocked = compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked);
|
|
1800
|
+
|
|
1801
|
+
const lines = [];
|
|
1802
|
+
if (asksEvents) {
|
|
1803
|
+
lines.push(`Blocked actions recorded (all time): ${totalBlocked}.`);
|
|
1804
|
+
if (intent.windowMs) {
|
|
1805
|
+
lines.push(`(Per-${intent.windowLabel} block-event breakdown isn't tracked in the local dashboard snapshot — only the running total. Filter the gate-events log directly for a precise window.)`);
|
|
1806
|
+
}
|
|
1807
|
+
} else {
|
|
1808
|
+
lines.push(`Active gates: ${totalActive}.`);
|
|
1809
|
+
}
|
|
1810
|
+
if (intent.wantsList && gates.length) {
|
|
1811
|
+
lines.push('Active gates:');
|
|
1812
|
+
for (const g of gates.slice(0, 8)) lines.push(` • ${describeGate(g)}`);
|
|
1813
|
+
} else if (gates[0] && !intent.wantsList) {
|
|
1814
|
+
lines.push(`Example gate: ${describeGate(gates[0])}.`);
|
|
1815
|
+
}
|
|
1816
|
+
return { lines, sources: ['gate stats'] };
|
|
1630
1817
|
}
|
|
1631
1818
|
|
|
1632
|
-
function buildEnterpriseChatSection(topic, dashboardData, status,
|
|
1819
|
+
function buildEnterpriseChatSection(topic, dashboardData, status, ctx = {}) {
|
|
1633
1820
|
const approval = dashboardData.approval || {};
|
|
1634
1821
|
const gates = Array.isArray(dashboardData.gates) ? dashboardData.gates : [];
|
|
1635
1822
|
const gateStats = dashboardData.gateStats || {};
|
|
1636
1823
|
const team = dashboardData.team || {};
|
|
1637
1824
|
const tokenSavings = dashboardData.tokenSavings || {};
|
|
1638
1825
|
const lessonPipeline = dashboardData.lessonPipeline || {};
|
|
1826
|
+
const intent = ctx.intent || { wantsList: false, windowMs: null, windowLabel: 'across all time' };
|
|
1827
|
+
const feedbackDir = ctx.feedbackDir || null;
|
|
1639
1828
|
|
|
1640
1829
|
if (topic === 'feedback') {
|
|
1641
|
-
return {
|
|
1642
|
-
lines: [
|
|
1643
|
-
`Feedback total: ${compactNumber(approval.total)} (${compactNumber(approval.positive)} positive, ${compactNumber(approval.negative)} negative).`,
|
|
1644
|
-
`Lesson pipeline: ${compactNumber(lessonPipeline.lessons || lessonPipeline.generated || 0)} lessons visible in the current dashboard snapshot.`,
|
|
1645
|
-
],
|
|
1646
|
-
sources: ['feedback log', 'lesson pipeline'],
|
|
1647
|
-
};
|
|
1830
|
+
return buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeline });
|
|
1648
1831
|
}
|
|
1649
1832
|
if (topic === 'gates') {
|
|
1650
|
-
|
|
1651
|
-
if (todayGateAnswer) {
|
|
1652
|
-
return {
|
|
1653
|
-
lines: todayGateAnswer,
|
|
1654
|
-
sources: ['gate audit', 'gate stats'],
|
|
1655
|
-
};
|
|
1656
|
-
}
|
|
1657
|
-
return {
|
|
1658
|
-
lines: [
|
|
1659
|
-
`Active gates: ${gates.length || compactNumber(gateStats.totalGates)}.`,
|
|
1660
|
-
`Blocked actions recorded: ${compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked)}.`,
|
|
1661
|
-
gates[0] ? `Example gate: ${gates[0].name || gates[0].id || 'unnamed gate'}.` : '',
|
|
1662
|
-
].filter(Boolean),
|
|
1663
|
-
sources: ['gate stats'],
|
|
1664
|
-
};
|
|
1833
|
+
return buildGatesSection({ ctx, intent, gates, gateStats });
|
|
1665
1834
|
}
|
|
1666
1835
|
if (topic === 'team') {
|
|
1667
1836
|
return {
|
|
@@ -1702,13 +1871,22 @@ function buildEnterpriseChatSection(topic, dashboardData, status, prompt) {
|
|
|
1702
1871
|
};
|
|
1703
1872
|
}
|
|
1704
1873
|
|
|
1705
|
-
function buildEnterpriseChatAnswer(prompt, dashboardData, status) {
|
|
1874
|
+
function buildEnterpriseChatAnswer(prompt, dashboardData, status, opts = {}) {
|
|
1706
1875
|
const topic = classifyEnterpriseChatTopic(prompt);
|
|
1707
|
-
const
|
|
1876
|
+
const intent = parseChatIntent(prompt);
|
|
1877
|
+
const section = buildEnterpriseChatSection(topic, dashboardData, status, {
|
|
1878
|
+
intent,
|
|
1879
|
+
feedbackDir: opts.feedbackDir,
|
|
1880
|
+
prompt,
|
|
1881
|
+
});
|
|
1882
|
+
|
|
1883
|
+
// List-style answers want newlines; single-line answers join with space.
|
|
1884
|
+
const hasList = section.lines.some((l) => /^\s*•/.test(l));
|
|
1885
|
+
const answer = hasList ? section.lines.join('\n') : section.lines.join(' ');
|
|
1708
1886
|
|
|
1709
1887
|
return {
|
|
1710
1888
|
topic,
|
|
1711
|
-
answer
|
|
1889
|
+
answer,
|
|
1712
1890
|
sources: ['local dashboard data', ...section.sources],
|
|
1713
1891
|
};
|
|
1714
1892
|
}
|
|
@@ -1724,6 +1902,7 @@ async function trySendLocalDashboardChat(res, parsed, feedbackDir, prompt, suffi
|
|
|
1724
1902
|
prompt,
|
|
1725
1903
|
dashboardResult.data,
|
|
1726
1904
|
buildEnterpriseDataChatStatus(),
|
|
1905
|
+
{ feedbackDir },
|
|
1727
1906
|
);
|
|
1728
1907
|
sendJson(res, 200, {
|
|
1729
1908
|
ok: true,
|
|
@@ -1735,7 +1914,7 @@ async function trySendLocalDashboardChat(res, parsed, feedbackDir, prompt, suffi
|
|
|
1735
1914
|
grounded: true,
|
|
1736
1915
|
});
|
|
1737
1916
|
return true;
|
|
1738
|
-
} catch
|
|
1917
|
+
} catch {
|
|
1739
1918
|
return false;
|
|
1740
1919
|
}
|
|
1741
1920
|
}
|
|
@@ -1771,7 +1950,7 @@ async function answerEnterpriseDataChat({ prompt, feedbackDir, parsed }) {
|
|
|
1771
1950
|
// The dashboard's real local/open-source chatbot turn goes through /v1/chat,
|
|
1772
1951
|
// which uses lesson retrieval + optional LanceDB vector search + the user's
|
|
1773
1952
|
// configured local or BYO model.
|
|
1774
|
-
const chat = buildEnterpriseChatAnswer(normalizedPrompt, dashboardData, status);
|
|
1953
|
+
const chat = buildEnterpriseChatAnswer(normalizedPrompt, dashboardData, status, { feedbackDir });
|
|
1775
1954
|
const dfcxRequest = {
|
|
1776
1955
|
fulfillmentInfo: { tag: 'chat-with-data' },
|
|
1777
1956
|
sessionInfo: {
|
|
@@ -1809,7 +1988,7 @@ async function answerEnterpriseDataChat({ prompt, feedbackDir, parsed }) {
|
|
|
1809
1988
|
const answerEnterpriseDialogflowChat = answerEnterpriseDataChat;
|
|
1810
1989
|
|
|
1811
1990
|
function buildLossAnalyticsResponse(data, summaryOptions) {
|
|
1812
|
-
return {
|
|
1991
|
+
return sanitizeHtmlUnsafeJsonValue({
|
|
1813
1992
|
window: data.analytics.window || summaryOptions,
|
|
1814
1993
|
lossAnalysis: data.analytics.lossAnalysis || null,
|
|
1815
1994
|
buyerLoss: data.analytics.buyerLoss || null,
|
|
@@ -1821,13 +2000,50 @@ function buildLossAnalyticsResponse(data, summaryOptions) {
|
|
|
1821
2000
|
ctas: data.analytics.telemetry && data.analytics.telemetry.ctas,
|
|
1822
2001
|
visitors: data.analytics.telemetry && data.analytics.telemetry.visitors,
|
|
1823
2002
|
},
|
|
1824
|
-
};
|
|
2003
|
+
});
|
|
1825
2004
|
}
|
|
1826
2005
|
|
|
1827
2006
|
function createJourneyId(prefix) {
|
|
1828
2007
|
return createTraceId(prefix).replace(/^trace_/, `${prefix}_`);
|
|
1829
2008
|
}
|
|
1830
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
|
+
|
|
1831
2047
|
function appendQueryParam(url, key, value) {
|
|
1832
2048
|
const normalized = normalizeNullableText(value);
|
|
1833
2049
|
if (normalized) {
|
|
@@ -1887,9 +2103,245 @@ function buildCheckoutIntentHref(baseUrl, metadata = {}, overrides = {}) {
|
|
|
1887
2103
|
});
|
|
1888
2104
|
}
|
|
1889
2105
|
|
|
1890
|
-
|
|
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 = {}) {
|
|
1891
2206
|
const plausibleDomain = escapeHtmlAttribute(resolvePlausibleDataDomain({ host: 'thumbgate.ai' }));
|
|
1892
|
-
|
|
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>`;
|
|
1893
2345
|
}
|
|
1894
2346
|
|
|
1895
2347
|
function buildCheckoutBootstrapBody(parsed, req, journeyState = resolveJourneyState(req, parsed)) {
|
|
@@ -1953,17 +2405,6 @@ function normalizeCheckoutCustomerEmail(value) {
|
|
|
1953
2405
|
return email;
|
|
1954
2406
|
}
|
|
1955
2407
|
|
|
1956
|
-
function renderCheckoutIntentGate(parsed, responseHeaders = {}) {
|
|
1957
|
-
let hiddenInputs = '';
|
|
1958
|
-
for (const [key, value] of parsed.searchParams.entries()) {
|
|
1959
|
-
if (key !== 'confirm' && key !== 'customer_email') hiddenInputs += `<input type=hidden name=${escapeHtmlAttribute(key)} value=${escapeHtmlAttribute(value)}>`;
|
|
1960
|
-
}
|
|
1961
|
-
return {
|
|
1962
|
-
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>`,
|
|
1963
|
-
headers: responseHeaders,
|
|
1964
|
-
};
|
|
1965
|
-
}
|
|
1966
|
-
|
|
1967
2408
|
function normalizeTrackedLinkSlug(value) {
|
|
1968
2409
|
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
1969
2410
|
}
|
|
@@ -1974,6 +2415,27 @@ function normalizePublicPageSlug(value) {
|
|
|
1974
2415
|
.replace(/[^a-z0-9-]/g, '');
|
|
1975
2416
|
}
|
|
1976
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
|
+
|
|
1977
2439
|
function getTrackedLinkTarget(slug) {
|
|
1978
2440
|
const normalizedSlug = normalizeTrackedLinkSlug(slug);
|
|
1979
2441
|
return TRACKED_LINK_TARGETS[normalizedSlug]
|
|
@@ -2009,8 +2471,10 @@ function appendTrackedLinkQueryParams(destinationUrl, parsed, target) {
|
|
|
2009
2471
|
}
|
|
2010
2472
|
|
|
2011
2473
|
function buildTrackedLinkDestination(target, hostedConfig, parsed) {
|
|
2012
|
-
const
|
|
2013
|
-
|
|
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)
|
|
2014
2478
|
: new URL(target.path || '/', hostedConfig.appOrigin);
|
|
2015
2479
|
appendTrackedLinkQueryParams(destinationUrl, parsed, target);
|
|
2016
2480
|
return destinationUrl;
|
|
@@ -2137,6 +2601,7 @@ function sendJson(res, statusCode, payload, extraHeaders = {}, options = {}) {
|
|
|
2137
2601
|
const body = JSON.stringify(payload);
|
|
2138
2602
|
res.writeHead(statusCode, {
|
|
2139
2603
|
'Content-Type': 'application/json; charset=utf-8',
|
|
2604
|
+
'X-Content-Type-Options': 'nosniff',
|
|
2140
2605
|
'Content-Length': Buffer.byteLength(body),
|
|
2141
2606
|
...extraHeaders,
|
|
2142
2607
|
});
|
|
@@ -2211,7 +2676,7 @@ function appendBestEffortTelemetry(feedbackDir, payload, headers, context) {
|
|
|
2211
2676
|
evidence: [err?.message ? err.message : 'unknown_error'],
|
|
2212
2677
|
},
|
|
2213
2678
|
});
|
|
2214
|
-
} catch
|
|
2679
|
+
} catch {}
|
|
2215
2680
|
return false;
|
|
2216
2681
|
}
|
|
2217
2682
|
}
|
|
@@ -2230,8 +2695,9 @@ function chatgptActionEventType(integration, suffix) {
|
|
|
2230
2695
|
}
|
|
2231
2696
|
|
|
2232
2697
|
function getPublicOrigin(req) {
|
|
2233
|
-
const
|
|
2234
|
-
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';
|
|
2235
2701
|
return `${proto}://${host}`;
|
|
2236
2702
|
}
|
|
2237
2703
|
|
|
@@ -2250,6 +2716,47 @@ function getRequestHostHeader(req) {
|
|
|
2250
2716
|
return forwardedHost || req.headers.host || '';
|
|
2251
2717
|
}
|
|
2252
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
|
+
|
|
2253
2760
|
function isLoopbackHost(hostValue) {
|
|
2254
2761
|
const rawHost = String(hostValue || '').split(',')[0].trim();
|
|
2255
2762
|
if (!rawHost) {
|
|
@@ -2295,7 +2802,7 @@ function stripTrailingSlashes(value) {
|
|
|
2295
2802
|
return input.slice(0, end);
|
|
2296
2803
|
}
|
|
2297
2804
|
|
|
2298
|
-
function normalizePublicMarketingHtml(html, runtimeConfig) {
|
|
2805
|
+
function normalizePublicMarketingHtml(html, runtimeConfig, requestHost) {
|
|
2299
2806
|
const appOrigin = runtimeConfig?.appOrigin
|
|
2300
2807
|
? stripTrailingSlashes(runtimeConfig.appOrigin)
|
|
2301
2808
|
: '';
|
|
@@ -2304,11 +2811,14 @@ function normalizePublicMarketingHtml(html, runtimeConfig) {
|
|
|
2304
2811
|
let output = String(html);
|
|
2305
2812
|
output = output.replaceAll(DEFAULT_PUBLIC_APP_ORIGIN, appOrigin);
|
|
2306
2813
|
try {
|
|
2307
|
-
const host = new URL(appOrigin).host;
|
|
2814
|
+
const host = normalizePublicRequestHost(requestHost) || new URL(appOrigin).host;
|
|
2308
2815
|
const plausibleDomain = resolvePlausibleDataDomain({ host });
|
|
2309
2816
|
output = output.replaceAll(
|
|
2310
2817
|
'data-domain="thumbgate-production.up.railway.app"',
|
|
2311
2818
|
`data-domain="${escapeHtmlAttribute(plausibleDomain)}"`
|
|
2819
|
+
).replaceAll(
|
|
2820
|
+
'data-domain="thumbgate.ai"',
|
|
2821
|
+
`data-domain="${escapeHtmlAttribute(plausibleDomain)}"`
|
|
2312
2822
|
);
|
|
2313
2823
|
} catch {
|
|
2314
2824
|
// appOrigin is normalized by hosted-config; leave static analytics domains
|
|
@@ -2357,7 +2867,7 @@ function loadPublicMarketingTemplateHtml(templatePath, runtimeConfig, pageContex
|
|
|
2357
2867
|
'__GTM_PLAN_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/GO_TO_MARKET_REVENUE_WEDGE_2026-03.md',
|
|
2358
2868
|
'__GITHUB_URL__': 'https://github.com/IgorGanapolsky/ThumbGate',
|
|
2359
2869
|
'__POSTHOG_API_KEY__': runtimeConfig.posthogApiKey || '',
|
|
2360
|
-
}), runtimeConfig);
|
|
2870
|
+
}), runtimeConfig, pageContext.requestHost);
|
|
2361
2871
|
}
|
|
2362
2872
|
|
|
2363
2873
|
function loadLandingPageHtml(runtimeConfig, pageContext = {}) {
|
|
@@ -2372,6 +2882,18 @@ function loadPricingPageHtml(runtimeConfig, pageContext = {}) {
|
|
|
2372
2882
|
return loadPublicMarketingTemplateHtml(PRICING_PAGE_PATH, runtimeConfig, pageContext);
|
|
2373
2883
|
}
|
|
2374
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
|
+
|
|
2375
2897
|
function readOptionalPublicTemplate(filePath) {
|
|
2376
2898
|
try {
|
|
2377
2899
|
return fs.readFileSync(filePath, 'utf-8');
|
|
@@ -3004,6 +3526,7 @@ function renderSitemapXml(runtimeConfig) {
|
|
|
3004
3526
|
{ path: '/learn/codex-role-plugins-need-governance', changefreq: 'weekly', priority: '0.85' },
|
|
3005
3527
|
{ path: '/learn/agentic-os-team-governance', changefreq: 'weekly', priority: '0.85' },
|
|
3006
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' },
|
|
3007
3530
|
{ path: '/compare/claude-code-hooks', changefreq: 'weekly', priority: '0.85' },
|
|
3008
3531
|
{ path: '/compare/bumblebee', changefreq: 'weekly', priority: '0.85' },
|
|
3009
3532
|
{ path: '/compare/anthropic-containment', changefreq: 'weekly', priority: '0.85' },
|
|
@@ -3012,6 +3535,30 @@ function renderSitemapXml(runtimeConfig) {
|
|
|
3012
3535
|
{ path: '/compare/anthropic-claude-for-legal', changefreq: 'weekly', priority: '0.9' },
|
|
3013
3536
|
...THUMBGATE_SEO_SITEMAP_ENTRIES,
|
|
3014
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
|
+
}
|
|
3015
3562
|
return [
|
|
3016
3563
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
3017
3564
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
@@ -3137,11 +3684,13 @@ function servePublicMarketingPage({
|
|
|
3137
3684
|
}, req.headers, 'seo_landing_view');
|
|
3138
3685
|
}
|
|
3139
3686
|
|
|
3687
|
+
const requestHost = getSafePublicRequestHost(req);
|
|
3140
3688
|
const html = renderHtml(hostedConfig, {
|
|
3141
3689
|
serverVisitorId: journeyState.visitorId,
|
|
3142
3690
|
serverSessionId: journeyState.sessionId,
|
|
3143
3691
|
serverAcquisitionId: journeyState.acquisitionId,
|
|
3144
3692
|
serverTelemetryCaptured: landingTelemetryCaptured,
|
|
3693
|
+
requestHost,
|
|
3145
3694
|
});
|
|
3146
3695
|
|
|
3147
3696
|
sendHtml(
|
|
@@ -3423,7 +3972,7 @@ function renderCheckoutSuccessPage(runtimeConfig) {
|
|
|
3423
3972
|
if (window.sessionStorage) {
|
|
3424
3973
|
window.sessionStorage.setItem(marker, '1');
|
|
3425
3974
|
}
|
|
3426
|
-
} catch
|
|
3975
|
+
} catch {
|
|
3427
3976
|
sendTelemetry(eventType, extra);
|
|
3428
3977
|
}
|
|
3429
3978
|
}
|
|
@@ -4220,6 +4769,42 @@ function normalizeJobIdFromPath(pathname, suffix = '') {
|
|
|
4220
4769
|
return match ? decodeURIComponent(match[1]) : null;
|
|
4221
4770
|
}
|
|
4222
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
|
+
|
|
4223
4808
|
function normalizeDocumentIdFromPath(pathname) {
|
|
4224
4809
|
const match = pathname.match(/^\/v1\/documents\/([^/]+)$/);
|
|
4225
4810
|
return match ? decodeURIComponent(match[1]) : null;
|
|
@@ -4991,12 +5576,40 @@ async function addContext(){
|
|
|
4991
5576
|
return;
|
|
4992
5577
|
}
|
|
4993
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
|
+
|
|
4994
5598
|
if (isGetLikeRequest && (pathname === '/guide' || pathname === '/guide.html')) {
|
|
4995
5599
|
try {
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
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');
|
|
5000
5613
|
}
|
|
5001
5614
|
return;
|
|
5002
5615
|
}
|
|
@@ -5056,10 +5669,19 @@ async function addContext(){
|
|
|
5056
5669
|
|
|
5057
5670
|
if (isGetLikeRequest && (pathname === '/learn' || pathname === '/learn.html')) {
|
|
5058
5671
|
try {
|
|
5059
|
-
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
|
|
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');
|
|
5063
5685
|
}
|
|
5064
5686
|
return;
|
|
5065
5687
|
}
|
|
@@ -5250,13 +5872,13 @@ async function addContext(){
|
|
|
5250
5872
|
|
|
5251
5873
|
if (isGetLikeRequest && pathname.startsWith('/learn/')) {
|
|
5252
5874
|
try {
|
|
5253
|
-
const
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
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' });
|
|
5257
5878
|
return;
|
|
5258
5879
|
}
|
|
5259
|
-
const
|
|
5880
|
+
const requestHost = getSafePublicRequestHost(req);
|
|
5881
|
+
const html = normalizePublicMarketingHtml(fs.readFileSync(articlePath, 'utf-8'), hostedConfig, requestHost);
|
|
5260
5882
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
5261
5883
|
} catch {
|
|
5262
5884
|
sendJson(res, 404, { error: 'Article not found' });
|
|
@@ -5266,10 +5888,10 @@ async function addContext(){
|
|
|
5266
5888
|
|
|
5267
5889
|
if (isGetLikeRequest && pathname.startsWith('/guides/')) {
|
|
5268
5890
|
try {
|
|
5269
|
-
const
|
|
5270
|
-
|
|
5271
|
-
|
|
5272
|
-
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);
|
|
5273
5895
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
5274
5896
|
} catch { sendJson(res, 404, { error: 'Guide not found' }); }
|
|
5275
5897
|
return;
|
|
@@ -5277,10 +5899,10 @@ async function addContext(){
|
|
|
5277
5899
|
|
|
5278
5900
|
if (isGetLikeRequest && pathname.startsWith('/compare/') && pathname !== '/compare') {
|
|
5279
5901
|
try {
|
|
5280
|
-
const
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
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);
|
|
5284
5906
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
5285
5907
|
} catch { sendJson(res, 404, { error: 'Comparison not found' }); }
|
|
5286
5908
|
return;
|
|
@@ -5288,10 +5910,10 @@ async function addContext(){
|
|
|
5288
5910
|
|
|
5289
5911
|
if (isGetLikeRequest && pathname.startsWith('/use-cases/')) {
|
|
5290
5912
|
try {
|
|
5291
|
-
const
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
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);
|
|
5295
5917
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
5296
5918
|
} catch { sendJson(res, 404, { error: 'Use case not found' }); }
|
|
5297
5919
|
return;
|
|
@@ -5308,6 +5930,18 @@ async function addContext(){
|
|
|
5308
5930
|
return;
|
|
5309
5931
|
}
|
|
5310
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
|
+
|
|
5311
5945
|
if (isGetLikeRequest && (
|
|
5312
5946
|
pathname === '/favicon.ico'
|
|
5313
5947
|
|| pathname === '/thumbgate-logo.png'
|
|
@@ -5389,8 +6023,70 @@ async function addContext(){
|
|
|
5389
6023
|
// because no real crawler appends customer_email to discovered URLs.
|
|
5390
6024
|
const hasCustomerEmailHint = !!parsed?.searchParams?.has('customer_email');
|
|
5391
6025
|
const botShouldBypass = !botClassification.isBot || hasCustomerEmailHint;
|
|
5392
|
-
|
|
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)
|
|
5393
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
|
+
}
|
|
5394
6090
|
// Plausible funnel event #1 of 3: page view. Fired before interstitial
|
|
5395
6091
|
// deflection so we get the full top-of-funnel count, with isBot as a
|
|
5396
6092
|
// prop so the dashboard can filter human vs. crawler traffic. Fire-and-forget.
|
|
@@ -5449,10 +6145,14 @@ async function addContext(){
|
|
|
5449
6145
|
billingCycle: analyticsMetadata.billingCycle,
|
|
5450
6146
|
landingPath: analyticsMetadata.landingPath,
|
|
5451
6147
|
isBot: botClassification.isBot ? 'true' : 'false',
|
|
6148
|
+
interstitialSampled: interstitialSampled ? 'true' : 'false',
|
|
6149
|
+
interstitialSampleRate,
|
|
5452
6150
|
reason: botClassification.reason,
|
|
5453
6151
|
}, req.headers, eventType);
|
|
5454
6152
|
const prefilledEmail = parsed?.searchParams?.get('customer_email') || '';
|
|
5455
|
-
const html = renderCheckoutIntentPage(prefilledEmail
|
|
6153
|
+
const html = renderCheckoutIntentPage(prefilledEmail, parsed, {
|
|
6154
|
+
includeHiddenAttribution: !botClassification.isBot,
|
|
6155
|
+
});
|
|
5456
6156
|
sendHtml(res, 200, html, responseHeaders);
|
|
5457
6157
|
return;
|
|
5458
6158
|
}
|
|
@@ -5715,12 +6415,13 @@ async function addContext(){
|
|
|
5715
6415
|
// Public-facing broker lead-flow audit landing page. Wedge for the
|
|
5716
6416
|
// real-estate broker outreach. Static HTML served from src/api/static.
|
|
5717
6417
|
try {
|
|
6418
|
+
const host = getSafePublicRequestHost(req);
|
|
5718
6419
|
const html = normalizePublicMarketingHtml(fillTemplate(fs.readFileSync(
|
|
5719
6420
|
path.resolve(__dirname, '../../assets/static/broker-audit.html'),
|
|
5720
6421
|
'utf8'
|
|
5721
6422
|
), {
|
|
5722
6423
|
'__POSTHOG_API_KEY__': hostedConfig.posthogApiKey || '',
|
|
5723
|
-
}), hostedConfig);
|
|
6424
|
+
}), hostedConfig, host);
|
|
5724
6425
|
if (isHeadRequest) {
|
|
5725
6426
|
sendHtml(res, 200, html, {}, { headOnly: true });
|
|
5726
6427
|
return;
|
|
@@ -5772,9 +6473,9 @@ async function addContext(){
|
|
|
5772
6473
|
// Authorization endpoint: GET renders consent, POST issues the code.
|
|
5773
6474
|
if (pathname === '/oauth/authorize') {
|
|
5774
6475
|
if (isGetLikeRequest) {
|
|
5775
|
-
const
|
|
5776
|
-
|
|
5777
|
-
|
|
6476
|
+
const authRequestToken = createPendingOauthAuthorizeRequest(
|
|
6477
|
+
getOauthAuthorizeParamsFromQuery(parsed.searchParams)
|
|
6478
|
+
);
|
|
5778
6479
|
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Authorize ThumbGate</title>
|
|
5779
6480
|
<style>body{font:15px system-ui;margin:0;background:#0b0b0c;color:#eee;display:flex;min-height:100vh;align-items:center;justify-content:center}
|
|
5780
6481
|
.card{background:#161618;border:1px solid #2a2a2e;border-radius:12px;padding:28px;max-width:420px}
|
|
@@ -5783,7 +6484,7 @@ button{width:100%;padding:11px;border-radius:8px;border:0;background:#10b981;col
|
|
|
5783
6484
|
a{color:#8b9}</style></head><body><form class="card" method="post" action="/oauth/authorize">
|
|
5784
6485
|
<h2>Authorize Claude → ThumbGate</h2>
|
|
5785
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>
|
|
5786
|
-
${
|
|
6487
|
+
<input type="hidden" name="auth_request_token" value="${escapeHtmlAttribute(authRequestToken)}">
|
|
5787
6488
|
<input type="password" name="api_key" placeholder="ThumbGate API key" autocomplete="off" required>
|
|
5788
6489
|
<button type="submit" name="approve" value="yes">Approve</button>
|
|
5789
6490
|
</form></body></html>`;
|
|
@@ -5795,8 +6496,9 @@ ${hidden}
|
|
|
5795
6496
|
req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
|
|
5796
6497
|
req.on('end', () => {
|
|
5797
6498
|
const form = new URLSearchParams(body);
|
|
5798
|
-
const
|
|
5799
|
-
const
|
|
6499
|
+
const authorizationParams = getOauthAuthorizeParamsFromForm(form, hostedConfig);
|
|
6500
|
+
const redirectUri = authorizationParams.redirectUri;
|
|
6501
|
+
const state = authorizationParams.state;
|
|
5800
6502
|
// Validate the presented ThumbGate key before issuing a code. When keys
|
|
5801
6503
|
// are configured (production) the key MUST match a configured admin /
|
|
5802
6504
|
// operator / reviewer key — otherwise OAuth would authenticate nobody.
|
|
@@ -5806,12 +6508,12 @@ ${hidden}
|
|
|
5806
6508
|
return;
|
|
5807
6509
|
}
|
|
5808
6510
|
const issued = mcpOauth.createAuthorizationCode(oauthStore, {
|
|
5809
|
-
clientId:
|
|
6511
|
+
clientId: authorizationParams.clientId,
|
|
5810
6512
|
redirectUri,
|
|
5811
|
-
codeChallenge:
|
|
5812
|
-
codeChallengeMethod:
|
|
5813
|
-
scope:
|
|
5814
|
-
resource:
|
|
6513
|
+
codeChallenge: authorizationParams.codeChallenge,
|
|
6514
|
+
codeChallengeMethod: authorizationParams.codeChallengeMethod,
|
|
6515
|
+
scope: authorizationParams.scope,
|
|
6516
|
+
resource: authorizationParams.resource || buildPublicUrl(hostedConfig, '/mcp'),
|
|
5815
6517
|
boundKey: form.get('api_key') || '',
|
|
5816
6518
|
state,
|
|
5817
6519
|
});
|
|
@@ -6082,7 +6784,7 @@ ${hidden}
|
|
|
6082
6784
|
evidence: [err?.message ? err.message : 'unknown_error'],
|
|
6083
6785
|
},
|
|
6084
6786
|
});
|
|
6085
|
-
} catch
|
|
6787
|
+
} catch {
|
|
6086
6788
|
// Telemetry is best-effort and must never fail the caller.
|
|
6087
6789
|
}
|
|
6088
6790
|
}
|
|
@@ -6449,7 +7151,7 @@ ${hidden}
|
|
|
6449
7151
|
context: 'failed to persist install-email capture to ledger',
|
|
6450
7152
|
metadata: { error: err?.message || 'unknown' },
|
|
6451
7153
|
});
|
|
6452
|
-
} catch
|
|
7154
|
+
} catch {}
|
|
6453
7155
|
}
|
|
6454
7156
|
|
|
6455
7157
|
// Privacy-clean telemetry ping for funnel attribution (no email).
|
|
@@ -7216,7 +7918,9 @@ ${hidden}
|
|
|
7216
7918
|
|
|
7217
7919
|
try {
|
|
7218
7920
|
if (req.method === 'GET' && pathname === '/v1/feedback/stats') {
|
|
7219
|
-
const stats =
|
|
7921
|
+
const stats = shouldAggregateFeedback()
|
|
7922
|
+
? computeAggregateFeedbackStats({ feedbackDir: requestFeedbackPaths.FEEDBACK_DIR })
|
|
7923
|
+
: analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH);
|
|
7220
7924
|
try {
|
|
7221
7925
|
const { getStatuslineMeta } = require('../../scripts/statusline-meta');
|
|
7222
7926
|
const meta = getStatuslineMeta({ env: process.env });
|
|
@@ -7248,6 +7952,9 @@ ${hidden}
|
|
|
7248
7952
|
stats.geminiKeyStatus = 'validated';
|
|
7249
7953
|
}
|
|
7250
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;
|
|
7251
7958
|
sendJson(res, 200, stats);
|
|
7252
7959
|
return;
|
|
7253
7960
|
}
|
|
@@ -7843,11 +8550,13 @@ ${hidden}
|
|
|
7843
8550
|
const signal = parsed.searchParams.get('signal') || null;
|
|
7844
8551
|
let results;
|
|
7845
8552
|
try {
|
|
8553
|
+
const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
|
|
7846
8554
|
results = searchThumbgate({
|
|
7847
8555
|
query,
|
|
7848
8556
|
limit: Number.isFinite(limit) ? limit : 10,
|
|
7849
8557
|
source,
|
|
7850
8558
|
signal,
|
|
8559
|
+
feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
|
|
7851
8560
|
});
|
|
7852
8561
|
} catch (err) {
|
|
7853
8562
|
throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
|
|
@@ -7865,11 +8574,13 @@ ${hidden}
|
|
|
7865
8574
|
const body = await parseJsonBody(req);
|
|
7866
8575
|
let results;
|
|
7867
8576
|
try {
|
|
8577
|
+
const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
|
|
7868
8578
|
results = searchThumbgate({
|
|
7869
8579
|
query: body.query || body.q || '',
|
|
7870
8580
|
limit: body.limit,
|
|
7871
8581
|
source: body.source,
|
|
7872
8582
|
signal: body.signal,
|
|
8583
|
+
feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
|
|
7873
8584
|
});
|
|
7874
8585
|
} catch (err) {
|
|
7875
8586
|
throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
|
|
@@ -7895,11 +8606,14 @@ ${hidden}
|
|
|
7895
8606
|
{
|
|
7896
8607
|
const documentId = normalizeDocumentIdFromPath(pathname);
|
|
7897
8608
|
if (req.method === 'GET' && documentId) {
|
|
8609
|
+
if (!/^[a-zA-Z0-9-_]+$/.test(documentId)) {
|
|
8610
|
+
throw createHttpError(400, 'Invalid document ID format');
|
|
8611
|
+
}
|
|
7898
8612
|
const document = readImportedDocument(documentId, {
|
|
7899
8613
|
feedbackDir: requestFeedbackDir,
|
|
7900
8614
|
});
|
|
7901
8615
|
if (!document) {
|
|
7902
|
-
throw createHttpError(404, `Imported document not found: ${documentId}`);
|
|
8616
|
+
throw createHttpError(404, `Imported document not found: ${escapeHtml(documentId)}`);
|
|
7903
8617
|
}
|
|
7904
8618
|
sendJson(res, 200, { document });
|
|
7905
8619
|
return;
|
|
@@ -7926,7 +8640,7 @@ ${hidden}
|
|
|
7926
8640
|
feedbackDir: getSafeDataDir(),
|
|
7927
8641
|
limit: 10,
|
|
7928
8642
|
});
|
|
7929
|
-
} catch
|
|
8643
|
+
} catch { /* best-effort — conversation window is optional */ }
|
|
7930
8644
|
}
|
|
7931
8645
|
const result = captureFeedback({
|
|
7932
8646
|
signal: body.signal,
|
|
@@ -8995,6 +9709,8 @@ module.exports = {
|
|
|
8995
9709
|
buildEnterpriseChatAnswer,
|
|
8996
9710
|
answerEnterpriseDataChat,
|
|
8997
9711
|
answerEnterpriseDialogflowChat,
|
|
9712
|
+
buildLossAnalyticsResponse,
|
|
9713
|
+
sanitizeHtmlUnsafeJsonValue,
|
|
8998
9714
|
},
|
|
8999
9715
|
};
|
|
9000
9716
|
|