thumbgate 1.27.16 → 1.27.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +85 -0
- package/.claude-plugin/plugin.json +3 -4
- package/.well-known/llms.txt +12 -33
- package/.well-known/mcp/server-card.json +8 -8
- package/README.md +30 -215
- package/adapters/chatgpt/openapi.yaml +1695 -0
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/gemini/function-declarations.json +0 -1
- package/adapters/mcp/server-stdio.js +11 -263
- package/adapters/opencode/opencode.json +1 -1
- package/bench/thumbgate-bench.json +2 -2
- package/bin/cli.js +122 -1421
- package/bin/postinstall.js +27 -11
- package/config/gate-templates.json +0 -216
- package/config/gates/claim-verification.json +0 -12
- package/config/gates/default.json +2 -31
- package/config/github-about.json +2 -2
- package/config/mcp-allowlists.json +13 -23
- package/config/merge-quality-checks.json +1 -0
- package/config/model-candidates.json +6 -121
- package/openapi/openapi.yaml +0 -12
- package/package.json +98 -228
- package/public/blog.html +4 -4
- package/public/codex-plugin.html +20 -72
- package/public/compare.html +8 -31
- package/public/dashboard.html +166 -930
- package/public/federal.html +2 -2
- package/public/guide.html +9 -33
- package/public/index.html +116 -495
- package/public/learn.html +18 -191
- package/public/lessons.html +10 -168
- package/public/numbers.html +7 -7
- package/public/pro.html +11 -34
- package/scripts/agent-memory-lifecycle.js +0 -211
- package/scripts/agent-readiness.js +3 -167
- package/scripts/agent-reward-model.js +1 -53
- package/scripts/auto-promote-gates.js +10 -82
- package/scripts/auto-wire-hooks.js +0 -14
- package/scripts/billing.js +1 -93
- package/scripts/build-metadata.js +10 -50
- package/scripts/cli-feedback.js +6 -18
- package/scripts/cli-schema.js +0 -90
- package/scripts/cli-telemetry.js +1 -6
- package/scripts/commercial-offer.js +2 -82
- package/scripts/context-manager.js +6 -74
- package/scripts/dashboard.js +2 -68
- package/scripts/durability/step.js +3 -3
- package/scripts/export-databricks-bundle.js +1 -5
- package/scripts/export-dpo-pairs.js +2 -7
- package/scripts/feedback-loop.js +1 -123
- package/scripts/feedback-quality.js +0 -87
- package/scripts/filesystem-search.js +10 -35
- package/scripts/gate-stats.js +0 -89
- package/scripts/gates-engine.js +85 -1105
- package/scripts/gemini-embedding-policy.js +1 -2
- package/scripts/hook-runtime.js +14 -20
- package/scripts/hook-thumbgate-cache-updater.js +2 -18
- package/scripts/hybrid-feedback-context.js +7 -142
- package/scripts/lesson-inference.js +3 -8
- package/scripts/lesson-search.js +1 -17
- package/scripts/license.js +1 -0
- package/scripts/llm-client.js +9 -390
- package/scripts/local-model-profile.js +8 -15
- package/scripts/memory-scope-readiness.js +0 -159
- package/scripts/meta-agent-loop.js +0 -36
- package/scripts/operational-integrity.js +5 -39
- package/scripts/oss-pr-opportunity-scout.js +5 -35
- package/scripts/plausible-server-events.js +6 -9
- package/scripts/pro-local-dashboard.js +4 -4
- package/scripts/proxy-pointer-rag-guardrails.js +1 -42
- package/scripts/rate-limiter.js +13 -64
- package/scripts/secret-scanner.js +5 -44
- package/scripts/security-scanner.js +10 -260
- package/scripts/self-distill-agent.js +1 -3
- package/scripts/seo-gsd.js +2 -916
- package/scripts/statusline-cache-path.js +2 -17
- package/scripts/statusline-local-stats.js +1 -9
- package/scripts/statusline-meta.js +2 -28
- package/scripts/statusline.sh +4 -20
- package/scripts/telemetry-analytics.js +0 -357
- package/scripts/thompson-sampling.js +10 -31
- package/scripts/thumbgate-bench.js +1 -16
- package/scripts/thumbgate-search.js +19 -85
- package/scripts/tool-registry.js +1 -169
- package/scripts/vector-store.js +0 -45
- package/scripts/workflow-sentinel.js +53 -286
- package/scripts/workspace-evolver.js +2 -62
- package/src/api/server.js +290 -2578
- package/.claude/commands/dashboard.md +0 -15
- package/.claude/commands/thumbgate-blocked.md +0 -27
- package/.claude/commands/thumbgate-dashboard.md +0 -15
- package/.claude/commands/thumbgate-doctor.md +0 -30
- package/.claude/commands/thumbgate-guard.md +0 -36
- package/.claude/commands/thumbgate-protect.md +0 -30
- package/.claude/commands/thumbgate-rules.md +0 -30
- package/.well-known/agentic-verify.txt +0 -1
- package/adapters/gcp/dfcx-webhook-gate.js +0 -295
- package/adapters/letta/README.md +0 -41
- package/adapters/letta/thumbgate-letta-adapter.js +0 -133
- package/adapters/policy-engine/ethicore-guardian-client.js +0 -68
- package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +0 -260
- package/bench/observability-eval-suite.json +0 -26
- package/bin/dashboard-cli.js +0 -7
- package/commands/dashboard.md +0 -15
- package/commands/thumbgate-dashboard.md +0 -15
- package/config/builtin-lessons.json +0 -23
- package/config/gate-classifier-routing.json +0 -98
- package/config/post-deploy-marketing-pages.json +0 -61
- package/public/about.html +0 -162
- package/public/agent-manager.html +0 -179
- package/public/agents-cost-savings.html +0 -153
- package/public/ai-malpractice-prevention.html +0 -818
- 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-logo-transparent.svg +0 -22
- package/public/assets/brand/thumbgate-mark-inline-v3.svg +0 -19
- package/public/assets/brand/thumbgate-mark-pro.svg +0 -23
- package/public/assets/brand/thumbgate-mark-team.svg +0 -26
- package/public/assets/brand/thumbgate-mark.svg +0 -21
- package/public/assets/brand/thumbgate-wordmark.svg +0 -20
- package/public/assets/claude-thumbgate-statusbar.svg +0 -8
- package/public/assets/codex-thumbgate-statusbar-test.svg +0 -9
- package/public/assets/legal-intake-control-flow.svg +0 -66
- package/public/brand/thumbgate-mark.svg +0 -19
- package/public/brand/thumbgate-og.svg +0 -16
- package/public/chatgpt-app.html +0 -330
- package/public/codex-enterprise.html +0 -123
- package/public/js/buyer-intent.js +0 -672
- package/public/pricing.html +0 -399
- package/scripts/action-receipts.js +0 -324
- package/scripts/activation-quickstart.js +0 -187
- package/scripts/agent-operations-planner.js +0 -621
- package/scripts/ai-component-inventory.js +0 -367
- package/scripts/async-eval-observability.js +0 -236
- package/scripts/audit.js +0 -65
- package/scripts/aws-blocks-guardrails.js +0 -228
- package/scripts/classifier-routing.js +0 -130
- package/scripts/dashboard-chat.js +0 -332
- package/scripts/feedback-aggregate.js +0 -281
- package/scripts/feedback-sanitizer.js +0 -105
- package/scripts/hob-pack.js +0 -591
- package/scripts/hook-stop-anti-claim.js +0 -301
- package/scripts/install-shim.js +0 -87
- package/scripts/mcp-oauth.js +0 -293
- package/scripts/noop-detect.js +0 -285
- package/scripts/omlx-smoke.js +0 -192
- package/scripts/parallel-workflow-orchestrator.js +0 -293
- package/scripts/plan-gate.js +0 -243
- package/scripts/plausible-domain-config.js +0 -99
- package/scripts/qa-scenario-planner.js +0 -136
- package/scripts/repeat-metric.js +0 -137
- package/scripts/secret-fixture-tokens.js +0 -61
- package/scripts/secret-redaction.js +0 -166
- package/scripts/self-harness-optimizer.js +0 -141
- package/scripts/self-healing-check.js +0 -193
- package/scripts/silent-failure-cluster.js +0 -531
- package/scripts/statusline-cache-read.js +0 -57
- package/scripts/sync-telemetry-from-prod.js +0 -374
- package/scripts/tool-contract-validator.js +0 -76
- package/scripts/trajectory-scorer.js +0 -63
- package/scripts/verify-marketing-pages-deployed.js +0 -195
- package/scripts/visitor-journey.js +0 -172
package/src/api/server.js
CHANGED
|
@@ -3,7 +3,6 @@ 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');
|
|
7
6
|
const { EventEmitter } = require('node:events');
|
|
8
7
|
const pkg = require('../../package.json');
|
|
9
8
|
const {
|
|
@@ -97,9 +96,6 @@ const {
|
|
|
97
96
|
const {
|
|
98
97
|
recordCheckoutFunnelEvent,
|
|
99
98
|
} = require('../../scripts/plausible-server-events');
|
|
100
|
-
const {
|
|
101
|
-
resolvePlausibleDataDomain,
|
|
102
|
-
} = require('../../scripts/plausible-domain-config');
|
|
103
99
|
const {
|
|
104
100
|
buildCloudflareSandboxPlan,
|
|
105
101
|
} = require('../../scripts/cloudflare-dynamic-sandbox');
|
|
@@ -169,16 +165,7 @@ const {
|
|
|
169
165
|
buildReviewSnapshot,
|
|
170
166
|
readDashboardReviewState,
|
|
171
167
|
writeDashboardReviewState,
|
|
172
|
-
collectAllFeedbackEntries,
|
|
173
168
|
} = require('../../scripts/dashboard');
|
|
174
|
-
const {
|
|
175
|
-
collectAggregateLogEntries,
|
|
176
|
-
computeAggregateFeedbackStats,
|
|
177
|
-
shouldAggregateFeedback,
|
|
178
|
-
} = require('../../scripts/feedback-aggregate');
|
|
179
|
-
const {
|
|
180
|
-
guardDfcxWebhook,
|
|
181
|
-
} = require('../../adapters/gcp/dfcx-webhook-gate');
|
|
182
169
|
const {
|
|
183
170
|
buildDashboardRenderSpec,
|
|
184
171
|
} = require('../../scripts/dashboard-render-spec');
|
|
@@ -212,12 +199,6 @@ const {
|
|
|
212
199
|
} = require('../../scripts/rate-limiter');
|
|
213
200
|
const { sendProblem, PROBLEM_TYPES } = require('../../scripts/problem-detail');
|
|
214
201
|
const { TOOLS: MCP_TOOLS } = require('../../scripts/tool-registry');
|
|
215
|
-
const mcpOauth = require('../../scripts/mcp-oauth');
|
|
216
|
-
// OAuth 2.1 (PKCE) authorization-server state for the remote MCP connector
|
|
217
|
-
// (Claude Connectors Directory requires OAuth for authenticated services).
|
|
218
|
-
const oauthStore = mcpOauth.createStore();
|
|
219
|
-
const pendingOauthAuthorizeRequests = new Map();
|
|
220
|
-
const OAUTH_AUTHORIZE_REQUEST_TTL_MS = 10 * 60 * 1000;
|
|
221
202
|
const resendMailer = require('../../scripts/mailer/resend-mailer');
|
|
222
203
|
const {
|
|
223
204
|
buildContextFootprintReport,
|
|
@@ -233,24 +214,17 @@ const PRO_PAGE_PATH = path.resolve(__dirname, '../../public/pro.html');
|
|
|
233
214
|
const DASHBOARD_PAGE_PATH = path.resolve(__dirname, '../../public/dashboard.html');
|
|
234
215
|
const LESSONS_PAGE_PATH = path.resolve(__dirname, '../../public/lessons.html');
|
|
235
216
|
const GUIDE_PAGE_PATH = path.resolve(__dirname, '../../public/guide.html');
|
|
236
|
-
const CHATGPT_APP_PAGE_PATH = path.resolve(__dirname, '../../public/chatgpt-app.html');
|
|
237
217
|
const CODEX_PLUGIN_PAGE_PATH = path.resolve(__dirname, '../../public/codex-plugin.html');
|
|
238
218
|
const COMPARE_PAGE_PATH = path.resolve(__dirname, '../../public/compare.html');
|
|
239
219
|
const LEARN_PAGE_PATH = path.resolve(__dirname, '../../public/learn.html');
|
|
240
220
|
const NUMBERS_PAGE_PATH = path.resolve(__dirname, '../../public/numbers.html');
|
|
241
221
|
const FEDERAL_PAGE_PATH = path.resolve(__dirname, '../../public/federal.html');
|
|
242
|
-
const PRICING_PAGE_PATH = path.resolve(__dirname, '../../public/pricing.html');
|
|
243
|
-
const ABOUT_PAGE_PATH = path.resolve(__dirname, '../../public/about.html');
|
|
244
222
|
const LEARN_DIR = path.resolve(__dirname, '../../public/learn');
|
|
245
223
|
const GUIDES_DIR = path.resolve(__dirname, '../../public/guides');
|
|
246
224
|
const COMPARE_DIR = path.resolve(__dirname, '../../public/compare');
|
|
247
225
|
const USE_CASES_DIR = path.resolve(__dirname, '../../public/use-cases');
|
|
248
226
|
const PUBLIC_DIR = path.resolve(__dirname, '../../public');
|
|
249
227
|
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);
|
|
254
228
|
const BUYER_INTENT_SCRIPT_PATH = path.resolve(__dirname, '../../public/js/buyer-intent.js');
|
|
255
229
|
const STATIC_MIME_BY_EXT = Object.freeze({
|
|
256
230
|
'.png': 'image/png',
|
|
@@ -369,7 +343,6 @@ function serveStaticFile(res, filePath, { headOnly = false, cacheSeconds = 86400
|
|
|
369
343
|
res.setHeader('Content-Type', contentType);
|
|
370
344
|
res.setHeader('Content-Length', stat.size);
|
|
371
345
|
res.setHeader('Cache-Control', `public, max-age=${cacheSeconds}, immutable`);
|
|
372
|
-
res.setHeader('Referrer-Policy', 'same-origin');
|
|
373
346
|
if (headOnly) {
|
|
374
347
|
res.end();
|
|
375
348
|
return;
|
|
@@ -440,75 +413,27 @@ const TRACKED_LINK_TARGETS = Object.freeze({
|
|
|
440
413
|
},
|
|
441
414
|
allowCustomerEmail: true,
|
|
442
415
|
},
|
|
443
|
-
// 2026-
|
|
416
|
+
// 2026-05-12: Aiventyx marketplace listing routes its Teams clicks through
|
|
417
|
+
// /go/teams (best-performing listing at ~62% CTR). Without this slug the
|
|
418
|
+
// server returned 404 + "Tracked link not found". Every Aiventyx Teams
|
|
419
|
+
// click between the URL swap and this deploy landed on that error page.
|
|
420
|
+
// Destination: 3-seat Team self-serve Stripe checkout (the path I shipped
|
|
421
|
+
// in PR #1877 — plan_id=team + seat_count=3 = $147/mo entry).
|
|
444
422
|
teams: {
|
|
445
|
-
path: '/go/pro?utm_source=legacy_teams&utm_medium=redirect',
|
|
446
|
-
ctaId: 'go_pro',
|
|
447
|
-
ctaPlacement: 'link_router',
|
|
448
|
-
eventType: 'cta_click',
|
|
449
|
-
},
|
|
450
|
-
team: {
|
|
451
|
-
path: '/go/pro?utm_source=legacy_teams&utm_medium=redirect',
|
|
452
|
-
ctaId: 'go_pro',
|
|
453
|
-
ctaPlacement: 'link_router',
|
|
454
|
-
eventType: 'cta_click',
|
|
455
|
-
},
|
|
456
|
-
checkout: {
|
|
457
423
|
path: '/checkout/pro',
|
|
458
|
-
ctaId: '
|
|
424
|
+
ctaId: 'go_teams',
|
|
459
425
|
ctaPlacement: 'link_router',
|
|
460
426
|
eventType: 'cta_click',
|
|
461
427
|
defaults: {
|
|
462
428
|
utm_source: 'website',
|
|
463
429
|
utm_medium: 'link_router',
|
|
464
|
-
utm_campaign: '
|
|
465
|
-
plan_id: '
|
|
430
|
+
utm_campaign: 'team_self_serve',
|
|
431
|
+
plan_id: 'team',
|
|
432
|
+
seat_count: '3',
|
|
466
433
|
billing_cycle: 'monthly',
|
|
467
434
|
},
|
|
468
435
|
allowCustomerEmail: true,
|
|
469
436
|
},
|
|
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
|
-
},
|
|
500
|
-
trial: {
|
|
501
|
-
path: '/guide',
|
|
502
|
-
ctaId: 'go_trial',
|
|
503
|
-
ctaPlacement: 'link_router',
|
|
504
|
-
eventType: 'trial_start_click',
|
|
505
|
-
defaults: {
|
|
506
|
-
utm_source: 'website',
|
|
507
|
-
utm_medium: 'link_router',
|
|
508
|
-
utm_campaign: 'trial_start',
|
|
509
|
-
plan_id: 'free_trial',
|
|
510
|
-
},
|
|
511
|
-
},
|
|
512
437
|
install: {
|
|
513
438
|
path: '/guide',
|
|
514
439
|
ctaId: 'go_install',
|
|
@@ -723,51 +648,6 @@ function resolveRequestProjectDir(req, parsed) {
|
|
|
723
648
|
});
|
|
724
649
|
}
|
|
725
650
|
|
|
726
|
-
function debugApiFallback(label, error) {
|
|
727
|
-
if (process.env.THUMBGATE_DEBUG_API !== '1') return;
|
|
728
|
-
console.warn(`[api] ${label}: ${error?.message || String(error)}`);
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
function stripEnvQuotes(value) {
|
|
732
|
-
return String(value || '').trim().replace(/^["']|["']$/g, '');
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
function extractEnvValue(content, names) {
|
|
736
|
-
const pattern = new RegExp(`^(?:${names.join('|')})=(.*)$`, 'm');
|
|
737
|
-
const match = pattern.exec(content);
|
|
738
|
-
return match ? stripEnvQuotes(match[1]) : '';
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
function readProjectChatSettings(req, parsed) {
|
|
742
|
-
const settings = {
|
|
743
|
-
geminiKey: '',
|
|
744
|
-
perplexityKey: '',
|
|
745
|
-
localEndpoint: '',
|
|
746
|
-
localModel: '',
|
|
747
|
-
geminiValidatedAt: null,
|
|
748
|
-
};
|
|
749
|
-
try {
|
|
750
|
-
const projectDir = resolveRequestProjectDir(req, parsed);
|
|
751
|
-
const envPath = path.join(projectDir, '.env');
|
|
752
|
-
if (fs.existsSync(envPath)) {
|
|
753
|
-
const content = fs.readFileSync(envPath, 'utf8');
|
|
754
|
-
settings.geminiKey = extractEnvValue(content, ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'THUMBGATE_GEMINI_API_KEY']);
|
|
755
|
-
settings.perplexityKey = extractEnvValue(content, ['PERPLEXITY_API_KEY', 'THUMBGATE_PERPLEXITY_API_KEY']);
|
|
756
|
-
settings.localEndpoint = extractEnvValue(content, ['THUMBGATE_LOCAL_LLM_ENDPOINT']);
|
|
757
|
-
settings.localModel = extractEnvValue(content, ['THUMBGATE_LOCAL_LLM_MODEL']);
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
const statusPath = path.join(projectDir, '.gemini-validated.json');
|
|
761
|
-
if (fs.existsSync(statusPath)) {
|
|
762
|
-
const status = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
|
|
763
|
-
settings.geminiValidatedAt = status.validatedAt || null;
|
|
764
|
-
}
|
|
765
|
-
} catch (error) {
|
|
766
|
-
debugApiFallback('project chat settings unavailable', error);
|
|
767
|
-
}
|
|
768
|
-
return settings;
|
|
769
|
-
}
|
|
770
|
-
|
|
771
651
|
function shouldPreferProjectScopedFeedback(req, parsed) {
|
|
772
652
|
const explicitProject = getEffectiveRequestedProjectSelection(req, parsed);
|
|
773
653
|
if (explicitProject) return true;
|
|
@@ -833,23 +713,16 @@ function updateLessonRecord(feedbackDir, lessonId, updater) {
|
|
|
833
713
|
function getPublicMcpTools() {
|
|
834
714
|
return MCP_TOOLS.map((tool) => ({
|
|
835
715
|
name: tool.name,
|
|
836
|
-
...(tool.title ? { title: tool.title } : {}),
|
|
837
716
|
description: tool.description,
|
|
838
717
|
inputSchema: tool.inputSchema,
|
|
839
|
-
// Serve the tool-registry annotations (readOnlyHint/destructiveHint). Required
|
|
840
|
-
// by the Claude Connectors Directory (missing annotations = the #1 rejection
|
|
841
|
-
// cause) and used by MCP clients for permission prompts. Was being dropped here.
|
|
842
|
-
...(tool.annotations ? { annotations: tool.annotations } : {}),
|
|
843
718
|
}));
|
|
844
719
|
}
|
|
845
720
|
|
|
846
721
|
function getServerCardTools() {
|
|
847
722
|
return MCP_TOOLS.map((tool) => ({
|
|
848
723
|
name: tool.name,
|
|
849
|
-
...(tool.title ? { title: tool.title } : {}),
|
|
850
724
|
description: tool.description,
|
|
851
725
|
inputSchema: tool.inputSchema,
|
|
852
|
-
...(tool.annotations ? { annotations: tool.annotations } : {}),
|
|
853
726
|
}));
|
|
854
727
|
}
|
|
855
728
|
|
|
@@ -889,7 +762,7 @@ function getMcpSkillManifests(hostedConfig) {
|
|
|
889
762
|
'Inspect prevention_rules after repeats.',
|
|
890
763
|
],
|
|
891
764
|
installCommand: 'npx thumbgate init',
|
|
892
|
-
contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
|
|
765
|
+
contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
|
|
893
766
|
proofUrl: VERIFICATION_EVIDENCE_URL,
|
|
894
767
|
},
|
|
895
768
|
{
|
|
@@ -916,7 +789,7 @@ function getMcpSkillManifests(hostedConfig) {
|
|
|
916
789
|
'Evaluate NDCG@10 on visual hard negatives.',
|
|
917
790
|
'Require artifact links before using retrieved evidence in claims.',
|
|
918
791
|
],
|
|
919
|
-
contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
|
|
792
|
+
contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
|
|
920
793
|
proofUrl: VERIFICATION_EVIDENCE_URL,
|
|
921
794
|
},
|
|
922
795
|
{
|
|
@@ -930,7 +803,7 @@ function getMcpSkillManifests(hostedConfig) {
|
|
|
930
803
|
'Compact feedback context with anchors for proof-critical lessons.',
|
|
931
804
|
'Record estimated token savings next to the workflow evidence.',
|
|
932
805
|
],
|
|
933
|
-
contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
|
|
806
|
+
contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
|
|
934
807
|
footprintUrl: buildPublicUrl(hostedConfig, '/.well-known/mcp/footprint.json'),
|
|
935
808
|
proofUrl: VERIFICATION_EVIDENCE_URL,
|
|
936
809
|
},
|
|
@@ -945,7 +818,7 @@ function getMcpSkillManifests(hostedConfig) {
|
|
|
945
818
|
'Require baseline evals before adding autonomy or subagents.',
|
|
946
819
|
'Classify tool risk before allowing writes, money movement, production changes, or outbound actions.',
|
|
947
820
|
],
|
|
948
|
-
contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
|
|
821
|
+
contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
|
|
949
822
|
proofUrl: VERIFICATION_EVIDENCE_URL,
|
|
950
823
|
},
|
|
951
824
|
{
|
|
@@ -959,7 +832,7 @@ function getMcpSkillManifests(hostedConfig) {
|
|
|
959
832
|
'Grade goal inference separately from intervention timing.',
|
|
960
833
|
'Block multi-app proactive writes until rollback and orchestration evidence exists.',
|
|
961
834
|
],
|
|
962
|
-
contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
|
|
835
|
+
contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
|
|
963
836
|
proofUrl: VERIFICATION_EVIDENCE_URL,
|
|
964
837
|
},
|
|
965
838
|
{
|
|
@@ -973,7 +846,7 @@ function getMcpSkillManifests(hostedConfig) {
|
|
|
973
846
|
'Map every proxy metric to the real user objective.',
|
|
974
847
|
'Require holdout or regression proof before treating benchmark gains as product gains.',
|
|
975
848
|
],
|
|
976
|
-
contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
|
|
849
|
+
contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
|
|
977
850
|
proofUrl: VERIFICATION_EVIDENCE_URL,
|
|
978
851
|
},
|
|
979
852
|
{
|
|
@@ -987,7 +860,7 @@ function getMcpSkillManifests(hostedConfig) {
|
|
|
987
860
|
'Reproduce locally before claiming a fix.',
|
|
988
861
|
'Open one focused PR with tests, proof, and transparent ThumbGate context only when relevant.',
|
|
989
862
|
],
|
|
990
|
-
contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
|
|
863
|
+
contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
|
|
991
864
|
proofUrl: VERIFICATION_EVIDENCE_URL,
|
|
992
865
|
},
|
|
993
866
|
{
|
|
@@ -1001,7 +874,7 @@ function getMcpSkillManifests(hostedConfig) {
|
|
|
1001
874
|
'Route self-serve intent to the guide and team pain to Workflow Hardening Sprint intake.',
|
|
1002
875
|
'Block unsupported ad and landing-page claims before spend scales.',
|
|
1003
876
|
],
|
|
1004
|
-
contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
|
|
877
|
+
contextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
|
|
1005
878
|
proofUrl: VERIFICATION_EVIDENCE_URL,
|
|
1006
879
|
},
|
|
1007
880
|
];
|
|
@@ -1135,7 +1008,7 @@ function getMcpDiscoveryManifest(hostedConfig) {
|
|
|
1135
1008
|
footprint: getContextFootprintReport(hostedConfig),
|
|
1136
1009
|
proof: {
|
|
1137
1010
|
verificationEvidenceUrl: VERIFICATION_EVIDENCE_URL,
|
|
1138
|
-
llmContextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
|
|
1011
|
+
llmContextUrl: buildPublicUrl(hostedConfig, '/public/llm-context.md'),
|
|
1139
1012
|
},
|
|
1140
1013
|
};
|
|
1141
1014
|
}
|
|
@@ -1545,450 +1418,8 @@ async function loadLiveDashboardDataOrRespondProblem(res, parsed, feedbackDir, i
|
|
|
1545
1418
|
}
|
|
1546
1419
|
}
|
|
1547
1420
|
|
|
1548
|
-
function buildEnterpriseDataChatStatus(env = process.env) {
|
|
1549
|
-
const vertexProject = normalizeNullableText(env.VERTEX_PROJECT_ID)
|
|
1550
|
-
|| normalizeNullableText(env.GOOGLE_VERTEX_PROJECT);
|
|
1551
|
-
const vertexLocation = normalizeNullableText(env.GOOGLE_VERTEX_LOCATION)
|
|
1552
|
-
|| normalizeNullableText(env.VERTEX_LOCATION)
|
|
1553
|
-
|| 'us-central1';
|
|
1554
|
-
const dfcxFulfillmentUrl = normalizeNullableText(env.THUMBGATE_DFCX_FULFILLMENT_URL);
|
|
1555
|
-
const dfcxAgentId = normalizeNullableText(env.THUMBGATE_DFCX_AGENT_ID);
|
|
1556
|
-
const dfcxLocation = normalizeNullableText(env.THUMBGATE_DFCX_LOCATION);
|
|
1557
|
-
|
|
1558
|
-
return {
|
|
1559
|
-
mode: 'local-dashboard',
|
|
1560
|
-
vertex: {
|
|
1561
|
-
configured: Boolean(vertexProject),
|
|
1562
|
-
projectId: vertexProject,
|
|
1563
|
-
location: vertexLocation,
|
|
1564
|
-
providerMode: normalizeNullableText(env.THUMBGATE_PROVIDER_MODE) || null,
|
|
1565
|
-
},
|
|
1566
|
-
dfcx: {
|
|
1567
|
-
apiSurface: 'Dialogflow CX REST API: projects.locations.agents',
|
|
1568
|
-
liveAgentConfigured: Boolean(dfcxAgentId && dfcxLocation),
|
|
1569
|
-
agentId: dfcxAgentId,
|
|
1570
|
-
location: dfcxLocation,
|
|
1571
|
-
fulfillmentProxyConfigured: Boolean(dfcxFulfillmentUrl),
|
|
1572
|
-
fulfillmentUrlConfigured: Boolean(dfcxFulfillmentUrl),
|
|
1573
|
-
gcloudCxCommandSupported: false,
|
|
1574
|
-
verification: dfcxAgentId && dfcxLocation
|
|
1575
|
-
? 'Agent metadata is present in env; verify via REST/console before production claims.'
|
|
1576
|
-
: 'No live DFCX agent env configured. Use REST/console/deployed webhook evidence before claiming a live agent.',
|
|
1577
|
-
},
|
|
1578
|
-
chat: {
|
|
1579
|
-
available: true,
|
|
1580
|
-
source: 'local ThumbGate dashboard data with LanceDB-backed retrieval',
|
|
1581
|
-
guard: 'local data-access guard; DFCX adapter optional for customer Dialogflow deployments',
|
|
1582
|
-
providerRequired: false,
|
|
1583
|
-
localLlmEndpointConfigured: Boolean(normalizeNullableText(env.THUMBGATE_LOCAL_LLM_ENDPOINT)),
|
|
1584
|
-
},
|
|
1585
|
-
};
|
|
1586
|
-
}
|
|
1587
|
-
|
|
1588
|
-
const buildEnterpriseDialogflowStatus = buildEnterpriseDataChatStatus;
|
|
1589
|
-
|
|
1590
|
-
function normalizeEnterpriseChatPrompt(value) {
|
|
1591
|
-
const text = normalizeNullableText(value);
|
|
1592
|
-
if (!text) return null;
|
|
1593
|
-
return text.slice(0, 800);
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
function classifyEnterpriseChatTopic(prompt) {
|
|
1597
|
-
const lower = String(prompt || '').toLowerCase();
|
|
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';
|
|
1602
|
-
if (/team|agent|org|enterprise|rollout/.test(lower)) return 'team';
|
|
1603
|
-
if (/token|cost|saving|budget|spend/.test(lower)) return 'cost';
|
|
1604
|
-
if (/vertex|gcp|google|dialogflow|dfcx|cloud/.test(lower)) return 'cloud';
|
|
1605
|
-
return 'overview';
|
|
1606
|
-
}
|
|
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
|
-
|
|
1696
|
-
function containsUnsafeEnterpriseChatInput(prompt) {
|
|
1697
|
-
return /[;&|`$<>\\]/.test(String(prompt || ''));
|
|
1698
|
-
}
|
|
1699
|
-
|
|
1700
|
-
function compactNumber(value) {
|
|
1701
|
-
const n = Number(value || 0);
|
|
1702
|
-
return Number.isFinite(n) ? n : 0;
|
|
1703
|
-
}
|
|
1704
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
];
|
|
1781
|
-
|
|
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.`);
|
|
1786
|
-
}
|
|
1787
|
-
return { lines, sources: ['feedback log', intent.wantsList ? 'feedback contexts' : 'lesson pipeline'] };
|
|
1788
|
-
}
|
|
1789
|
-
|
|
1790
|
-
const GATE_EVENTS_REGEX = /activat|fired|trigger|block|denied|prevent|enforce|hit/;
|
|
1791
|
-
|
|
1792
|
-
function describeGate(g) {
|
|
1793
|
-
return `${g.name || g.id || 'unnamed'}${g.severity ? ' [' + g.severity + ']' : ''}`;
|
|
1794
|
-
}
|
|
1795
|
-
|
|
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'] };
|
|
1817
|
-
}
|
|
1818
|
-
|
|
1819
|
-
function buildEnterpriseChatSection(topic, dashboardData, status, ctx = {}) {
|
|
1820
|
-
const approval = dashboardData.approval || {};
|
|
1821
|
-
const gates = Array.isArray(dashboardData.gates) ? dashboardData.gates : [];
|
|
1822
|
-
const gateStats = dashboardData.gateStats || {};
|
|
1823
|
-
const team = dashboardData.team || {};
|
|
1824
|
-
const tokenSavings = dashboardData.tokenSavings || {};
|
|
1825
|
-
const lessonPipeline = dashboardData.lessonPipeline || {};
|
|
1826
|
-
const intent = ctx.intent || { wantsList: false, windowMs: null, windowLabel: 'across all time' };
|
|
1827
|
-
const feedbackDir = ctx.feedbackDir || null;
|
|
1828
|
-
|
|
1829
|
-
if (topic === 'feedback') {
|
|
1830
|
-
return buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeline });
|
|
1831
|
-
}
|
|
1832
|
-
if (topic === 'gates') {
|
|
1833
|
-
return buildGatesSection({ ctx, intent, gates, gateStats });
|
|
1834
|
-
}
|
|
1835
|
-
if (topic === 'team') {
|
|
1836
|
-
return {
|
|
1837
|
-
lines: [
|
|
1838
|
-
'Team dashboard is available in this local Enterprise view.',
|
|
1839
|
-
`Tracked agents: ${compactNumber(team.totalAgents || team.agentCount || 0)}; risky agents: ${compactNumber(team.riskyAgents || team.highRiskAgents || 0)}.`,
|
|
1840
|
-
],
|
|
1841
|
-
sources: ['team dashboard'],
|
|
1842
|
-
};
|
|
1843
|
-
}
|
|
1844
|
-
if (topic === 'cost') {
|
|
1845
|
-
return {
|
|
1846
|
-
lines: [
|
|
1847
|
-
`Estimated token savings: ${tokenSavings.dollarsSavedDisplay || '$0.00'} from ${compactNumber(tokenSavings.blockedCalls)} blocked calls.`,
|
|
1848
|
-
'Google Cloud budget alerts are evidence for spend visibility; ThumbGate-side stop conditions must be verified separately before calling them a hard cap.',
|
|
1849
|
-
],
|
|
1850
|
-
sources: ['token savings', 'budget posture'],
|
|
1851
|
-
};
|
|
1852
|
-
}
|
|
1853
|
-
if (topic === 'cloud') {
|
|
1854
|
-
const vertexLine = status.vertex.configured
|
|
1855
|
-
? `Vertex routing config is present for project ${status.vertex.projectId} (${status.vertex.location}).`
|
|
1856
|
-
: 'Vertex routing config is not present in this server environment.';
|
|
1857
|
-
const dfcxLine = status.dfcx.liveAgentConfigured
|
|
1858
|
-
? `DFCX env has agent ${status.dfcx.agentId} in ${status.dfcx.location}; verify it with REST/console before production claims.`
|
|
1859
|
-
: 'No live DFCX agent is configured in env. Do not use the old alpha gcloud CX command group; verify agents with the Dialogflow CX REST API or console.';
|
|
1860
|
-
return {
|
|
1861
|
-
lines: [vertexLine, dfcxLine],
|
|
1862
|
-
sources: ['enterprise cloud status'],
|
|
1863
|
-
};
|
|
1864
|
-
}
|
|
1865
|
-
return {
|
|
1866
|
-
lines: [
|
|
1867
|
-
'Ask about feedback, lessons, active gates, team rollout, token savings, or Vertex/DFCX readiness.',
|
|
1868
|
-
`Current local snapshot: ${compactNumber(approval.total)} feedback events and ${gates.length || compactNumber(gateStats.totalGates)} active gates.`,
|
|
1869
|
-
],
|
|
1870
|
-
sources: [],
|
|
1871
|
-
};
|
|
1872
|
-
}
|
|
1873
|
-
|
|
1874
|
-
function buildEnterpriseChatAnswer(prompt, dashboardData, status, opts = {}) {
|
|
1875
|
-
const topic = classifyEnterpriseChatTopic(prompt);
|
|
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(' ');
|
|
1886
|
-
|
|
1887
|
-
return {
|
|
1888
|
-
topic,
|
|
1889
|
-
answer,
|
|
1890
|
-
sources: ['local dashboard data', ...section.sources],
|
|
1891
|
-
};
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1894
|
-
// Answer the dashboard "Chat with your data" panel LOCALLY (deterministic, no
|
|
1895
|
-
// cloud/LLM) from this install's own per-project dashboard data. Returns true if
|
|
1896
|
-
// it sent a response; false if the local snapshot was unavailable (caller then
|
|
1897
|
-
// falls through). Shared by the local-first path and the no-model fallback.
|
|
1898
|
-
async function trySendLocalDashboardChat(res, parsed, feedbackDir, prompt, suffix) {
|
|
1899
|
-
try {
|
|
1900
|
-
const dashboardResult = await buildLiveDashboardData(parsed, feedbackDir);
|
|
1901
|
-
const localChat = buildEnterpriseChatAnswer(
|
|
1902
|
-
prompt,
|
|
1903
|
-
dashboardResult.data,
|
|
1904
|
-
buildEnterpriseDataChatStatus(),
|
|
1905
|
-
{ feedbackDir },
|
|
1906
|
-
);
|
|
1907
|
-
sendJson(res, 200, {
|
|
1908
|
-
ok: true,
|
|
1909
|
-
answer: suffix ? `${localChat.answer} ${suffix}` : localChat.answer,
|
|
1910
|
-
sources: (localChat.sources || []).map((title) => ({ title })),
|
|
1911
|
-
topic: localChat.topic,
|
|
1912
|
-
provider: 'local-data',
|
|
1913
|
-
llm: 'none',
|
|
1914
|
-
grounded: true,
|
|
1915
|
-
});
|
|
1916
|
-
return true;
|
|
1917
|
-
} catch {
|
|
1918
|
-
return false;
|
|
1919
|
-
}
|
|
1920
|
-
}
|
|
1921
|
-
|
|
1922
|
-
async function answerEnterpriseDataChat({ prompt, feedbackDir, parsed }) {
|
|
1923
|
-
const normalizedPrompt = normalizeEnterpriseChatPrompt(prompt);
|
|
1924
|
-
if (!normalizedPrompt) {
|
|
1925
|
-
throw createHttpError(400, 'prompt is required');
|
|
1926
|
-
}
|
|
1927
|
-
const status = buildEnterpriseDataChatStatus();
|
|
1928
|
-
if (containsUnsafeEnterpriseChatInput(normalizedPrompt)) {
|
|
1929
|
-
return {
|
|
1930
|
-
ok: false,
|
|
1931
|
-
blocked: true,
|
|
1932
|
-
answer: 'This prompt contains unsafe control characters and was blocked before data access.',
|
|
1933
|
-
status,
|
|
1934
|
-
dfcx: {
|
|
1935
|
-
blocked: true,
|
|
1936
|
-
evaluation: {
|
|
1937
|
-
allowed: false,
|
|
1938
|
-
gate: 'enterprise-chat-unsafe-input',
|
|
1939
|
-
severity: 'critical',
|
|
1940
|
-
},
|
|
1941
|
-
},
|
|
1942
|
-
sources: ['enterprise input guard'],
|
|
1943
|
-
};
|
|
1944
|
-
}
|
|
1945
|
-
|
|
1946
|
-
const dashboardResult = await buildLiveDashboardData(parsed, feedbackDir);
|
|
1947
|
-
const dashboardData = dashboardResult.data;
|
|
1948
|
-
|
|
1949
|
-
// This guarded stats endpoint stays deterministic for API compatibility.
|
|
1950
|
-
// The dashboard's real local/open-source chatbot turn goes through /v1/chat,
|
|
1951
|
-
// which uses lesson retrieval + optional LanceDB vector search + the user's
|
|
1952
|
-
// configured local or BYO model.
|
|
1953
|
-
const chat = buildEnterpriseChatAnswer(normalizedPrompt, dashboardData, status, { feedbackDir });
|
|
1954
|
-
const dfcxRequest = {
|
|
1955
|
-
fulfillmentInfo: { tag: 'chat-with-data' },
|
|
1956
|
-
sessionInfo: {
|
|
1957
|
-
session: 'local-dashboard/enterprise-chat',
|
|
1958
|
-
parameters: {
|
|
1959
|
-
topic: chat.topic,
|
|
1960
|
-
prompt_key: normalizedPrompt.toLowerCase().replace(/[^a-z0-9._ -]/g, '').slice(0, 64),
|
|
1961
|
-
},
|
|
1962
|
-
},
|
|
1963
|
-
languageCode: 'en',
|
|
1964
|
-
};
|
|
1965
|
-
const guarded = await guardDfcxWebhook(
|
|
1966
|
-
dfcxRequest,
|
|
1967
|
-
async () => ({
|
|
1968
|
-
fulfillment_response: { messages: [{ text: { text: [chat.answer] } }] },
|
|
1969
|
-
session_info: { parameters: { thumbgate_topic: chat.topic } },
|
|
1970
|
-
}),
|
|
1971
|
-
{ blockOnRepeat: false },
|
|
1972
|
-
);
|
|
1973
|
-
|
|
1974
|
-
return {
|
|
1975
|
-
ok: !guarded.blocked,
|
|
1976
|
-
blocked: Boolean(guarded.blocked),
|
|
1977
|
-
answer: guarded.blocked ? 'ThumbGate blocked this enterprise chat turn before data access.' : chat.answer,
|
|
1978
|
-
status,
|
|
1979
|
-
dfcx: {
|
|
1980
|
-
blocked: Boolean(guarded.blocked),
|
|
1981
|
-
evaluation: guarded.evaluation,
|
|
1982
|
-
response: guarded.response,
|
|
1983
|
-
},
|
|
1984
|
-
sources: chat.sources,
|
|
1985
|
-
};
|
|
1986
|
-
}
|
|
1987
|
-
|
|
1988
|
-
const answerEnterpriseDialogflowChat = answerEnterpriseDataChat;
|
|
1989
|
-
|
|
1990
1421
|
function buildLossAnalyticsResponse(data, summaryOptions) {
|
|
1991
|
-
return
|
|
1422
|
+
return {
|
|
1992
1423
|
window: data.analytics.window || summaryOptions,
|
|
1993
1424
|
lossAnalysis: data.analytics.lossAnalysis || null,
|
|
1994
1425
|
buyerLoss: data.analytics.buyerLoss || null,
|
|
@@ -2000,50 +1431,13 @@ function buildLossAnalyticsResponse(data, summaryOptions) {
|
|
|
2000
1431
|
ctas: data.analytics.telemetry && data.analytics.telemetry.ctas,
|
|
2001
1432
|
visitors: data.analytics.telemetry && data.analytics.telemetry.visitors,
|
|
2002
1433
|
},
|
|
2003
|
-
}
|
|
1434
|
+
};
|
|
2004
1435
|
}
|
|
2005
1436
|
|
|
2006
1437
|
function createJourneyId(prefix) {
|
|
2007
1438
|
return createTraceId(prefix).replace(/^trace_/, `${prefix}_`);
|
|
2008
1439
|
}
|
|
2009
1440
|
|
|
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
|
-
|
|
2047
1441
|
function appendQueryParam(url, key, value) {
|
|
2048
1442
|
const normalized = normalizeNullableText(value);
|
|
2049
1443
|
if (normalized) {
|
|
@@ -2103,287 +1497,55 @@ function buildCheckoutIntentHref(baseUrl, metadata = {}, overrides = {}) {
|
|
|
2103
1497
|
});
|
|
2104
1498
|
}
|
|
2105
1499
|
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
'
|
|
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('');
|
|
1500
|
+
function renderCheckoutIntentPage({
|
|
1501
|
+
confirmHref,
|
|
1502
|
+
workflowIntakeHref,
|
|
1503
|
+
}) {
|
|
1504
|
+
const safeConfirmHref = escapeHtmlAttribute(confirmHref);
|
|
1505
|
+
const safeWorkflowIntakeHref = escapeHtmlAttribute(workflowIntakeHref);
|
|
1506
|
+
return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Confirm — ThumbGate Pro</title><style>body{background:#0a0a0a;color:#eee;font-family:system-ui,-apple-system,sans-serif;line-height:1.5}main{max-width:520px;margin:8vh auto;padding:0 20px}.brand{display:flex;align-items:center;gap:10px;margin-bottom:24px;font-size:14px;color:#94a3b8}.brand-mark{width:24px;height:24px;background:#22d3ee;border-radius:6px;display:inline-block}h1{font-size:24px;margin:0 0 8px;color:#fff}.price{font-size:32px;font-weight:700;color:#22d3ee;margin:8px 0 4px}.price small{font-size:14px;color:#94a3b8;font-weight:400}p{color:#cbd5e1;margin:8px 0}a{display:block;text-decoration:none}a.primary{background:#22d3ee;color:#000;padding:16px;text-align:center;border-radius:8px;font-weight:700;font-size:16px;margin:20px 0 10px}a.secondary{border:1px solid #374151;color:#cbd5e1;padding:12px;text-align:center;border-radius:8px;margin:8px 0 0;font-size:14px}.trust{margin:24px 0;padding:16px;border:1px solid #1f2937;border-radius:8px;background:#0f172a}.trust-item{font-size:13px;color:#cbd5e1;padding:4px 0;display:flex;gap:8px}.trust-item::before{content:"✓";color:#22d3ee;font-weight:700}.choice-note{font-size:13px;color:#94a3b8;margin-top:14px}.back{text-align:center;color:#64748b;font-size:12px;margin-top:24px}.back a{color:#64748b;display:inline}</style><main><div class="brand"><span class="brand-mark"></span><span>ThumbGate</span></div><h1>Start ThumbGate Pro</h1><div class="price">$19<small>/mo</small></div><p>The npm package runs your gates locally. <strong>Pro</strong> is what keeps them working across every machine, every agent runtime, and every breaking-change week.</p><a class="primary" data-i="pro_checkout_confirmed" rel="nofollow noindex" href="${safeConfirmHref}">Pay $19/mo with Stripe →</a><a class="secondary" data-i="workflow_sprint_intake" href="${safeWorkflowIntakeHref}">Not sure yet? Send the workflow first</a><p class="choice-note">Cancel anytime. 7-day refund, no questions. Diagnostics and sprints have their own pages.</p><div class="trust"><div class="trust-item">Lessons synced across all your machines — no local SQLite to babysit</div><div class="trust-item">Adapter matrix kept current for Claude Code, Cursor, Codex, Gemini, Amp, Cline, OpenCode — version drift is our problem, not yours</div><div class="trust-item">Hosted dashboard: gate stats, DPO export, org-wide rule library</div><div class="trust-item">24×7 ops on the rule engine — SonarCloud regressions fixed in <24h</div></div><p class="back"><a href="/">← Back to thumbgate.ai</a></p></main><script>addEventListener('click',e=>{let a=e.target.closest('[data-i]');if(a&&navigator.sendBeacon)navigator.sendBeacon('/v1/telemetry/ping',new Blob([JSON.stringify({eventType:'checkout_interstitial_cta_clicked',clientType:'web',page:'/checkout/pro',ctaId:a.dataset.i,ctaPlacement:'checkout_interstitial'})],{type:'application/json'}))})</script></html>`;
|
|
2150
1507
|
}
|
|
2151
1508
|
|
|
2152
|
-
function
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
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 = {}) {
|
|
2206
|
-
const plausibleDomain = escapeHtmlAttribute(resolvePlausibleDataDomain({ host: 'thumbgate.ai' }));
|
|
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:36px;height:36px;display:block;flex:0 0 auto;border-radius:9px}
|
|
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"><img src="/thumbgate-icon.png?v=20260623-stripe-match" alt="" class="brand-mark" width="36" height="36"><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>`;
|
|
2345
|
-
}
|
|
2346
|
-
|
|
2347
|
-
function buildCheckoutBootstrapBody(parsed, req, journeyState = resolveJourneyState(req, parsed)) {
|
|
2348
|
-
const params = parsed.searchParams;
|
|
2349
|
-
const traceId = pickFirstText(params.get('trace_id')) || createJourneyId('checkout');
|
|
2350
|
-
const planId = normalizePlanId(pickFirstText(params.get('plan_id'), 'pro'));
|
|
2351
|
-
const billingCycle = normalizeBillingCycle(pickFirstText(params.get('billing_cycle'), 'monthly'));
|
|
2352
|
-
const seatCount = planId === 'team'
|
|
2353
|
-
? normalizeSeatCount(pickFirstText(params.get('seat_count')))
|
|
2354
|
-
: 1;
|
|
2355
|
-
return {
|
|
2356
|
-
traceId,
|
|
2357
|
-
installId: pickFirstText(params.get('install_id')),
|
|
2358
|
-
acquisitionId: journeyState.acquisitionId,
|
|
2359
|
-
visitorId: journeyState.visitorId,
|
|
2360
|
-
sessionId: journeyState.sessionId,
|
|
2361
|
-
customerEmail: pickFirstText(params.get('customer_email')),
|
|
2362
|
-
source: pickFirstText(params.get('source'), params.get('utm_source'), 'website'),
|
|
2363
|
-
utmSource: pickFirstText(params.get('utm_source'), params.get('source'), 'website'),
|
|
2364
|
-
utmMedium: pickFirstText(params.get('utm_medium'), 'cta_button'),
|
|
2365
|
-
utmCampaign: pickFirstText(params.get('utm_campaign'), 'pro_pack'),
|
|
2366
|
-
utmContent: pickFirstText(params.get('utm_content')),
|
|
2367
|
-
utmTerm: pickFirstText(params.get('utm_term')),
|
|
2368
|
-
creator: pickFirstText(params.get('creator'), params.get('creator_handle')),
|
|
2369
|
-
community: pickFirstText(params.get('community'), params.get('subreddit')),
|
|
2370
|
-
postId: pickFirstText(params.get('post_id')),
|
|
2371
|
-
commentId: pickFirstText(params.get('comment_id')),
|
|
2372
|
-
campaignVariant: pickFirstText(params.get('campaign_variant')),
|
|
2373
|
-
offerCode: pickFirstText(params.get('offer_code')),
|
|
2374
|
-
landingPath: pickFirstText(params.get('landing_path'), req.headers.referer ? '/' : '/'),
|
|
2375
|
-
referrerHost: pickFirstText(params.get('referrer_host')),
|
|
2376
|
-
ctaId: pickFirstText(params.get('cta_id'), 'pricing_pro'),
|
|
2377
|
-
ctaPlacement: pickFirstText(params.get('cta_placement'), 'pricing'),
|
|
2378
|
-
planId,
|
|
2379
|
-
billingCycle,
|
|
2380
|
-
seatCount,
|
|
2381
|
-
metadata: {
|
|
2382
|
-
referrer: pickFirstText(params.get('referrer'), req.headers.referer, req.headers.referrer),
|
|
2383
|
-
landingPath: pickFirstText(params.get('landing_path'), '/'),
|
|
2384
|
-
referrerHost: pickFirstText(params.get('referrer_host')),
|
|
2385
|
-
},
|
|
2386
|
-
};
|
|
1509
|
+
function buildCheckoutBootstrapBody(parsed, req, journeyState = resolveJourneyState(req, parsed)) {
|
|
1510
|
+
const params = parsed.searchParams;
|
|
1511
|
+
const traceId = pickFirstText(params.get('trace_id')) || createJourneyId('checkout');
|
|
1512
|
+
const planId = normalizePlanId(pickFirstText(params.get('plan_id'), 'pro'));
|
|
1513
|
+
const billingCycle = normalizeBillingCycle(pickFirstText(params.get('billing_cycle'), 'monthly'));
|
|
1514
|
+
const seatCount = planId === 'team'
|
|
1515
|
+
? normalizeSeatCount(pickFirstText(params.get('seat_count')))
|
|
1516
|
+
: 1;
|
|
1517
|
+
return {
|
|
1518
|
+
traceId,
|
|
1519
|
+
installId: pickFirstText(params.get('install_id')),
|
|
1520
|
+
acquisitionId: journeyState.acquisitionId,
|
|
1521
|
+
visitorId: journeyState.visitorId,
|
|
1522
|
+
sessionId: journeyState.sessionId,
|
|
1523
|
+
customerEmail: pickFirstText(params.get('customer_email')),
|
|
1524
|
+
source: pickFirstText(params.get('source'), params.get('utm_source'), 'website'),
|
|
1525
|
+
utmSource: pickFirstText(params.get('utm_source'), params.get('source'), 'website'),
|
|
1526
|
+
utmMedium: pickFirstText(params.get('utm_medium'), 'cta_button'),
|
|
1527
|
+
utmCampaign: pickFirstText(params.get('utm_campaign'), 'pro_pack'),
|
|
1528
|
+
utmContent: pickFirstText(params.get('utm_content')),
|
|
1529
|
+
utmTerm: pickFirstText(params.get('utm_term')),
|
|
1530
|
+
creator: pickFirstText(params.get('creator'), params.get('creator_handle')),
|
|
1531
|
+
community: pickFirstText(params.get('community'), params.get('subreddit')),
|
|
1532
|
+
postId: pickFirstText(params.get('post_id')),
|
|
1533
|
+
commentId: pickFirstText(params.get('comment_id')),
|
|
1534
|
+
campaignVariant: pickFirstText(params.get('campaign_variant')),
|
|
1535
|
+
offerCode: pickFirstText(params.get('offer_code')),
|
|
1536
|
+
landingPath: pickFirstText(params.get('landing_path'), req.headers.referer ? '/' : '/'),
|
|
1537
|
+
referrerHost: pickFirstText(params.get('referrer_host')),
|
|
1538
|
+
ctaId: pickFirstText(params.get('cta_id'), 'pricing_pro'),
|
|
1539
|
+
ctaPlacement: pickFirstText(params.get('cta_placement'), 'pricing'),
|
|
1540
|
+
planId,
|
|
1541
|
+
billingCycle,
|
|
1542
|
+
seatCount,
|
|
1543
|
+
metadata: {
|
|
1544
|
+
referrer: pickFirstText(params.get('referrer'), req.headers.referer, req.headers.referrer),
|
|
1545
|
+
landingPath: pickFirstText(params.get('landing_path'), '/'),
|
|
1546
|
+
referrerHost: pickFirstText(params.get('referrer_host')),
|
|
1547
|
+
},
|
|
1548
|
+
};
|
|
2387
1549
|
}
|
|
2388
1550
|
|
|
2389
1551
|
function buildCheckoutConfirmHref(parsed) {
|
|
@@ -2405,6 +1567,17 @@ function normalizeCheckoutCustomerEmail(value) {
|
|
|
2405
1567
|
return email;
|
|
2406
1568
|
}
|
|
2407
1569
|
|
|
1570
|
+
function renderCheckoutIntentGate(parsed, responseHeaders = {}) {
|
|
1571
|
+
let hiddenInputs = '';
|
|
1572
|
+
for (const [key, value] of parsed.searchParams.entries()) {
|
|
1573
|
+
if (key !== 'confirm' && key !== 'customer_email') hiddenInputs += `<input type=hidden name=${escapeHtmlAttribute(key)} value=${escapeHtmlAttribute(value)}>`;
|
|
1574
|
+
}
|
|
1575
|
+
return {
|
|
1576
|
+
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>`,
|
|
1577
|
+
headers: responseHeaders,
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
|
|
2408
1581
|
function normalizeTrackedLinkSlug(value) {
|
|
2409
1582
|
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
2410
1583
|
}
|
|
@@ -2415,27 +1588,6 @@ function normalizePublicPageSlug(value) {
|
|
|
2415
1588
|
.replace(/[^a-z0-9-]/g, '');
|
|
2416
1589
|
}
|
|
2417
1590
|
|
|
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
|
-
|
|
2439
1591
|
function getTrackedLinkTarget(slug) {
|
|
2440
1592
|
const normalizedSlug = normalizeTrackedLinkSlug(slug);
|
|
2441
1593
|
return TRACKED_LINK_TARGETS[normalizedSlug]
|
|
@@ -2471,10 +1623,8 @@ function appendTrackedLinkQueryParams(destinationUrl, parsed, target) {
|
|
|
2471
1623
|
}
|
|
2472
1624
|
|
|
2473
1625
|
function buildTrackedLinkDestination(target, hostedConfig, parsed) {
|
|
2474
|
-
const
|
|
2475
|
-
|
|
2476
|
-
const destinationUrl = href
|
|
2477
|
-
? new URL(href)
|
|
1626
|
+
const destinationUrl = target.href
|
|
1627
|
+
? new URL(target.href)
|
|
2478
1628
|
: new URL(target.path || '/', hostedConfig.appOrigin);
|
|
2479
1629
|
appendTrackedLinkQueryParams(destinationUrl, parsed, target);
|
|
2480
1630
|
return destinationUrl;
|
|
@@ -2601,7 +1751,6 @@ function sendJson(res, statusCode, payload, extraHeaders = {}, options = {}) {
|
|
|
2601
1751
|
const body = JSON.stringify(payload);
|
|
2602
1752
|
res.writeHead(statusCode, {
|
|
2603
1753
|
'Content-Type': 'application/json; charset=utf-8',
|
|
2604
|
-
'X-Content-Type-Options': 'nosniff',
|
|
2605
1754
|
'Content-Length': Buffer.byteLength(body),
|
|
2606
1755
|
...extraHeaders,
|
|
2607
1756
|
});
|
|
@@ -2676,28 +1825,14 @@ function appendBestEffortTelemetry(feedbackDir, payload, headers, context) {
|
|
|
2676
1825
|
evidence: [err?.message ? err.message : 'unknown_error'],
|
|
2677
1826
|
},
|
|
2678
1827
|
});
|
|
2679
|
-
} catch {}
|
|
1828
|
+
} catch (_) {}
|
|
2680
1829
|
return false;
|
|
2681
1830
|
}
|
|
2682
1831
|
}
|
|
2683
1832
|
|
|
2684
|
-
function inferActionIntegration(body = {}, headers = {}) {
|
|
2685
|
-
const explicit = pickFirstText(body.integration, body.source, body.actionIntegration, body.provider);
|
|
2686
|
-
const userAgent = String(headers['user-agent'] || '').toLowerCase();
|
|
2687
|
-
if (/chatgpt|gpt[_-]?actions?|openai/.test(String(explicit || '').toLowerCase()) || /chatgpt|openai/.test(userAgent)) {
|
|
2688
|
-
return 'chatgpt_gpt';
|
|
2689
|
-
}
|
|
2690
|
-
return explicit || 'api';
|
|
2691
|
-
}
|
|
2692
|
-
|
|
2693
|
-
function chatgptActionEventType(integration, suffix) {
|
|
2694
|
-
return integration === 'chatgpt_gpt' ? `chatgpt_action_${suffix}` : `api_action_${suffix}`;
|
|
2695
|
-
}
|
|
2696
|
-
|
|
2697
1833
|
function getPublicOrigin(req) {
|
|
2698
|
-
const
|
|
2699
|
-
const
|
|
2700
|
-
const host = getSafePublicRequestHost(req) || 'localhost';
|
|
1834
|
+
const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() || 'http';
|
|
1835
|
+
const host = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim() || 'localhost';
|
|
2701
1836
|
return `${proto}://${host}`;
|
|
2702
1837
|
}
|
|
2703
1838
|
|
|
@@ -2716,47 +1851,6 @@ function getRequestHostHeader(req) {
|
|
|
2716
1851
|
return forwardedHost || req.headers.host || '';
|
|
2717
1852
|
}
|
|
2718
1853
|
|
|
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
|
-
|
|
2760
1854
|
function isLoopbackHost(hostValue) {
|
|
2761
1855
|
const rawHost = String(hostValue || '').split(',')[0].trim();
|
|
2762
1856
|
if (!rawHost) {
|
|
@@ -2802,7 +1896,7 @@ function stripTrailingSlashes(value) {
|
|
|
2802
1896
|
return input.slice(0, end);
|
|
2803
1897
|
}
|
|
2804
1898
|
|
|
2805
|
-
function normalizePublicMarketingHtml(html, runtimeConfig
|
|
1899
|
+
function normalizePublicMarketingHtml(html, runtimeConfig) {
|
|
2806
1900
|
const appOrigin = runtimeConfig?.appOrigin
|
|
2807
1901
|
? stripTrailingSlashes(runtimeConfig.appOrigin)
|
|
2808
1902
|
: '';
|
|
@@ -2811,14 +1905,10 @@ function normalizePublicMarketingHtml(html, runtimeConfig, requestHost) {
|
|
|
2811
1905
|
let output = String(html);
|
|
2812
1906
|
output = output.replaceAll(DEFAULT_PUBLIC_APP_ORIGIN, appOrigin);
|
|
2813
1907
|
try {
|
|
2814
|
-
const host =
|
|
2815
|
-
const plausibleDomain = resolvePlausibleDataDomain({ host });
|
|
1908
|
+
const host = new URL(appOrigin).host;
|
|
2816
1909
|
output = output.replaceAll(
|
|
2817
1910
|
'data-domain="thumbgate-production.up.railway.app"',
|
|
2818
|
-
`data-domain="${escapeHtmlAttribute(
|
|
2819
|
-
).replaceAll(
|
|
2820
|
-
'data-domain="thumbgate.ai"',
|
|
2821
|
-
`data-domain="${escapeHtmlAttribute(plausibleDomain)}"`
|
|
1911
|
+
`data-domain="${escapeHtmlAttribute(host)}"`
|
|
2822
1912
|
);
|
|
2823
1913
|
} catch {
|
|
2824
1914
|
// appOrigin is normalized by hosted-config; leave static analytics domains
|
|
@@ -2867,7 +1957,7 @@ function loadPublicMarketingTemplateHtml(templatePath, runtimeConfig, pageContex
|
|
|
2867
1957
|
'__GTM_PLAN_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/GO_TO_MARKET_REVENUE_WEDGE_2026-03.md',
|
|
2868
1958
|
'__GITHUB_URL__': 'https://github.com/IgorGanapolsky/ThumbGate',
|
|
2869
1959
|
'__POSTHOG_API_KEY__': runtimeConfig.posthogApiKey || '',
|
|
2870
|
-
}), runtimeConfig
|
|
1960
|
+
}), runtimeConfig);
|
|
2871
1961
|
}
|
|
2872
1962
|
|
|
2873
1963
|
function loadLandingPageHtml(runtimeConfig, pageContext = {}) {
|
|
@@ -2878,22 +1968,6 @@ function loadProPageHtml(runtimeConfig, pageContext = {}) {
|
|
|
2878
1968
|
return loadPublicMarketingTemplateHtml(PRO_PAGE_PATH, runtimeConfig, pageContext);
|
|
2879
1969
|
}
|
|
2880
1970
|
|
|
2881
|
-
function loadPricingPageHtml(runtimeConfig, pageContext = {}) {
|
|
2882
|
-
return loadPublicMarketingTemplateHtml(PRICING_PAGE_PATH, runtimeConfig, pageContext);
|
|
2883
|
-
}
|
|
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
|
-
|
|
2897
1971
|
function readOptionalPublicTemplate(filePath) {
|
|
2898
1972
|
try {
|
|
2899
1973
|
return fs.readFileSync(filePath, 'utf-8');
|
|
@@ -2903,10 +1977,6 @@ function readOptionalPublicTemplate(filePath) {
|
|
|
2903
1977
|
}
|
|
2904
1978
|
}
|
|
2905
1979
|
|
|
2906
|
-
function escapeJsonForInlineScript(value) {
|
|
2907
|
-
return JSON.stringify(value).replaceAll('<', String.raw`\u003c`);
|
|
2908
|
-
}
|
|
2909
|
-
|
|
2910
1980
|
function resolveLocalPageBootstrap(req, expectedApiKey) {
|
|
2911
1981
|
const forwardedHost = req.headers['x-forwarded-host'];
|
|
2912
1982
|
const hostHeader = Array.isArray(forwardedHost)
|
|
@@ -2915,13 +1985,7 @@ function resolveLocalPageBootstrap(req, expectedApiKey) {
|
|
|
2915
1985
|
const localProBootstrap = process.env.THUMBGATE_PRO_MODE === '1' && Boolean(expectedApiKey) && isLoopbackHost(hostHeader);
|
|
2916
1986
|
const devOverride = expectedApiKey === null && isLoopbackHost(hostHeader);
|
|
2917
1987
|
const bootstrapActive = localProBootstrap || devOverride;
|
|
2918
|
-
|
|
2919
|
-
if (localProBootstrap) {
|
|
2920
|
-
bootstrapKey = expectedApiKey;
|
|
2921
|
-
} else if (devOverride) {
|
|
2922
|
-
bootstrapKey = process.env.THUMBGATE_API_KEY || 'dev-override';
|
|
2923
|
-
}
|
|
2924
|
-
const serializedBootstrapKey = escapeJsonForInlineScript(bootstrapKey);
|
|
1988
|
+
const serializedBootstrapKey = JSON.stringify(localProBootstrap ? expectedApiKey : devOverride ? 'dev-override' : '').replace(/</g, '\\u003c');
|
|
2925
1989
|
|
|
2926
1990
|
return {
|
|
2927
1991
|
bootstrapActive,
|
|
@@ -2962,7 +2026,6 @@ window.THUMBGATE_DASHBOARD_BOOTSTRAP = { enabled: ${bootstrapActive ? 'true' : '
|
|
|
2962
2026
|
<p>This lightweight npm dashboard is bundled without marketing assets, so installs stay small while core feedback, lessons, and API routes remain available.</p>
|
|
2963
2027
|
<div class="grid">
|
|
2964
2028
|
<a class="card" href="/v1/dashboard"><strong>Dashboard JSON</strong><span>Inspect feedback totals, lesson counts, and Reliability Gateway health.</span></a>
|
|
2965
|
-
<a class="card" href="/v1/enterprise/data-chat/status"><strong>Governed Data Chat</strong><span>Local RAG (LanceDB vectors + lessons) over your data plus your LLM. Guard simulation is available; Dialogflow/Vertex are optional adapters for customer-owned agent deployments.</span></a>
|
|
2966
2029
|
<a class="card" href="/lessons"><strong>Lessons</strong><span>Review remembered thumbs-up/down lessons and enforcement context.</span></a>
|
|
2967
2030
|
<a class="card" href="/health"><strong>Health</strong><span>Verify the installed package version and runtime status.</span></a>
|
|
2968
2031
|
</div>
|
|
@@ -3042,53 +2105,6 @@ function normalizeLessonSignal(signal) {
|
|
|
3042
2105
|
return 'down';
|
|
3043
2106
|
}
|
|
3044
2107
|
|
|
3045
|
-
function splitLessonTitlePrefix(titleText) {
|
|
3046
|
-
const prefixMatch = /^(MISTAKE|SUCCESS|LEARNING|PREFERENCE):\s*(.*)/i.exec(titleText);
|
|
3047
|
-
if (!prefixMatch) return { prefix: '', rest: titleText };
|
|
3048
|
-
return {
|
|
3049
|
-
prefix: `${prefixMatch[1].toUpperCase()}: `,
|
|
3050
|
-
rest: prefixMatch[2],
|
|
3051
|
-
};
|
|
3052
|
-
}
|
|
3053
|
-
|
|
3054
|
-
function maybeParseJsonObject(value) {
|
|
3055
|
-
const trimmed = String(value || '').trim();
|
|
3056
|
-
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null;
|
|
3057
|
-
try {
|
|
3058
|
-
return JSON.parse(trimmed);
|
|
3059
|
-
} catch (error) {
|
|
3060
|
-
debugApiFallback('lesson JSON parse skipped', error);
|
|
3061
|
-
return null;
|
|
3062
|
-
}
|
|
3063
|
-
}
|
|
3064
|
-
|
|
3065
|
-
function formatLessonJsonTitle(prefix, parsed) {
|
|
3066
|
-
const dirName = parsed.cwd ? parsed.cwd.split('/').pop() : '';
|
|
3067
|
-
const suffix = dirName ? ` inside ${dirName}` : '';
|
|
3068
|
-
if (parsed.prompt) return `${prefix}Prompt "${parsed.prompt}"${suffix}`;
|
|
3069
|
-
const hookVal = parsed.hook_event_name || parsed.hookEventName;
|
|
3070
|
-
if (hookVal) return `${prefix}Hook event ${hookVal}${suffix}`;
|
|
3071
|
-
if (parsed.signal) return `${prefix}${parsed.signal === 'up' ? 'Thumbs Up' : 'Thumbs Down'}${suffix}`;
|
|
3072
|
-
return '';
|
|
3073
|
-
}
|
|
3074
|
-
|
|
3075
|
-
function cleanLessonTitle(titleText) {
|
|
3076
|
-
if (!titleText) return 'Untitled Lesson';
|
|
3077
|
-
const { prefix, rest } = splitLessonTitlePrefix(titleText);
|
|
3078
|
-
const parsed = maybeParseJsonObject(rest);
|
|
3079
|
-
if (parsed) {
|
|
3080
|
-
const jsonTitle = formatLessonJsonTitle(prefix, parsed);
|
|
3081
|
-
if (jsonTitle) return jsonTitle;
|
|
3082
|
-
}
|
|
3083
|
-
return titleText;
|
|
3084
|
-
}
|
|
3085
|
-
|
|
3086
|
-
function formatLessonTextValue(value) {
|
|
3087
|
-
if (!value) return '';
|
|
3088
|
-
const parsed = maybeParseJsonObject(value);
|
|
3089
|
-
return parsed ? JSON.stringify(parsed, null, 2) : value;
|
|
3090
|
-
}
|
|
3091
|
-
|
|
3092
2108
|
function renderLessonDetailHtml(record, lessonId) {
|
|
3093
2109
|
if (!record) {
|
|
3094
2110
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Lesson Not Found</title>
|
|
@@ -3107,15 +2123,10 @@ a{color:#22d3ee;text-decoration:none}</style></head><body>
|
|
|
3107
2123
|
const signal = normalizeLessonSignal(merged.signal);
|
|
3108
2124
|
const emoji = signal === 'up' ? '👍' : '👎';
|
|
3109
2125
|
const signalColor = signal === 'up' ? '#4ade80' : '#f87171';
|
|
3110
|
-
const
|
|
3111
|
-
const
|
|
3112
|
-
const
|
|
3113
|
-
const
|
|
3114
|
-
|
|
3115
|
-
const title = cleanLessonTitle(rawTitle);
|
|
3116
|
-
const context = formatLessonTextValue(rawContext);
|
|
3117
|
-
const whatWentWrong = formatLessonTextValue(rawWhatWentWrong);
|
|
3118
|
-
const whatWorked = formatLessonTextValue(rawWhatWorked);
|
|
2126
|
+
const title = merged.title || merged.context || 'Untitled Lesson';
|
|
2127
|
+
const context = merged.context || '';
|
|
2128
|
+
const whatWentWrong = merged.whatWentWrong || '';
|
|
2129
|
+
const whatWorked = merged.whatWorked || '';
|
|
3119
2130
|
const whatToChange = merged.whatToChange || '';
|
|
3120
2131
|
const tags = Array.isArray(merged.tags) ? merged.tags.join(', ') : (merged.tags || '');
|
|
3121
2132
|
const timestamp = merged.timestamp ? new Date(merged.timestamp).toLocaleString() : '';
|
|
@@ -3333,7 +2344,7 @@ nav .container { display: flex; justify-content: space-between; align-items: cen
|
|
|
3333
2344
|
</head>
|
|
3334
2345
|
<body>
|
|
3335
2346
|
<nav><div class="container">
|
|
3336
|
-
<a href="/dashboard" class="nav-logo"><img src="/assets/brand/thumbgate-mark-inline
|
|
2347
|
+
<a href="/dashboard" class="nav-logo"><img src="/assets/brand/thumbgate-mark-inline.svg" alt="ThumbGate" class="logo-mark" width="28" height="28"><span class="logo-text">ThumbGate</span></a>
|
|
3337
2348
|
<div class="nav-links">
|
|
3338
2349
|
<a href="/dashboard">Dashboard</a>
|
|
3339
2350
|
<a href="/lessons">Lessons</a>
|
|
@@ -3457,16 +2468,6 @@ function renderRobotsTxt(runtimeConfig) {
|
|
|
3457
2468
|
'Disallow: /v1/billing/',
|
|
3458
2469
|
'',
|
|
3459
2470
|
'# AI crawler access — allow all major LLM crawlers',
|
|
3460
|
-
'User-agent: OAI-SearchBot',
|
|
3461
|
-
'Allow: /',
|
|
3462
|
-
'Disallow: /checkout/',
|
|
3463
|
-
'Disallow: /v1/billing/',
|
|
3464
|
-
'',
|
|
3465
|
-
'User-agent: ChatGPT-User',
|
|
3466
|
-
'Allow: /',
|
|
3467
|
-
'Disallow: /checkout/',
|
|
3468
|
-
'Disallow: /v1/billing/',
|
|
3469
|
-
'',
|
|
3470
2471
|
'User-agent: GPTBot',
|
|
3471
2472
|
'Allow: /',
|
|
3472
2473
|
'Disallow: /checkout/',
|
|
@@ -3475,18 +2476,9 @@ function renderRobotsTxt(runtimeConfig) {
|
|
|
3475
2476
|
'User-agent: ClaudeBot',
|
|
3476
2477
|
'Allow: /',
|
|
3477
2478
|
'',
|
|
3478
|
-
'User-agent: Claude-SearchBot',
|
|
3479
|
-
'Allow: /',
|
|
3480
|
-
'',
|
|
3481
|
-
'User-agent: Claude-User',
|
|
3482
|
-
'Allow: /',
|
|
3483
|
-
'',
|
|
3484
2479
|
'User-agent: PerplexityBot',
|
|
3485
2480
|
'Allow: /',
|
|
3486
2481
|
'',
|
|
3487
|
-
'User-agent: Perplexity-User',
|
|
3488
|
-
'Allow: /',
|
|
3489
|
-
'',
|
|
3490
2482
|
'User-agent: Googlebot',
|
|
3491
2483
|
'Allow: /',
|
|
3492
2484
|
'',
|
|
@@ -3501,7 +2493,6 @@ function renderRobotsTxt(runtimeConfig) {
|
|
|
3501
2493
|
'',
|
|
3502
2494
|
'# LLM context document — clean declarative content for AI retrieval',
|
|
3503
2495
|
`# ${runtimeConfig.appOrigin}/llm-context.md`,
|
|
3504
|
-
`# ${runtimeConfig.appOrigin}/llms.txt`,
|
|
3505
2496
|
'',
|
|
3506
2497
|
`Sitemap: ${runtimeConfig.appOrigin}/sitemap.xml`,
|
|
3507
2498
|
].join('\n');
|
|
@@ -3511,54 +2502,10 @@ function renderSitemapXml(runtimeConfig) {
|
|
|
3511
2502
|
const entries = [
|
|
3512
2503
|
{ path: '/', changefreq: 'weekly', priority: '1.0' },
|
|
3513
2504
|
{ path: '/pro', changefreq: 'weekly', priority: '0.9' },
|
|
3514
|
-
{ path: '/agent-manager', changefreq: 'weekly', priority: '0.9' },
|
|
3515
2505
|
{ path: '/llm-context.md', changefreq: 'weekly', priority: '0.8' },
|
|
3516
|
-
{ path: '/chatgpt-app', changefreq: 'weekly', priority: '0.85' },
|
|
3517
2506
|
{ path: '/codex-plugin', changefreq: 'weekly', priority: '0.75' },
|
|
3518
|
-
{ path: '/codex-enterprise', changefreq: 'weekly', priority: '0.85' },
|
|
3519
|
-
{ path: '/agents-cost-savings', changefreq: 'weekly', priority: '0.85' },
|
|
3520
|
-
{ path: '/ai-malpractice-prevention', changefreq: 'weekly', priority: '0.9' },
|
|
3521
|
-
{ path: '/learn/background-agent-control-layer', changefreq: 'weekly', priority: '0.85' },
|
|
3522
|
-
{ path: '/learn/ac-dc-runtime-enforcement', changefreq: 'weekly', priority: '0.85' },
|
|
3523
|
-
{ path: '/learn/feedback-loop-vs-decision-layer', changefreq: 'weekly', priority: '0.9' },
|
|
3524
|
-
{ path: '/learn/agentic-enterprise-context-brain', changefreq: 'weekly', priority: '0.85' },
|
|
3525
|
-
{ path: '/learn/deterministic-agent-workflows', changefreq: 'weekly', priority: '0.85' },
|
|
3526
|
-
{ path: '/learn/codex-role-plugins-need-governance', changefreq: 'weekly', priority: '0.85' },
|
|
3527
|
-
{ path: '/learn/agentic-os-team-governance', changefreq: 'weekly', priority: '0.85' },
|
|
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' },
|
|
3530
|
-
{ path: '/compare/claude-code-hooks', changefreq: 'weekly', priority: '0.85' },
|
|
3531
|
-
{ path: '/compare/bumblebee', changefreq: 'weekly', priority: '0.85' },
|
|
3532
|
-
{ path: '/compare/anthropic-containment', changefreq: 'weekly', priority: '0.85' },
|
|
3533
|
-
{ path: '/compare/oak-and-sparrow-gatekeeper', changefreq: 'weekly', priority: '0.85' },
|
|
3534
|
-
{ path: '/compare/arcjet', changefreq: 'weekly', priority: '0.85' },
|
|
3535
|
-
{ path: '/compare/anthropic-claude-for-legal', changefreq: 'weekly', priority: '0.9' },
|
|
3536
2507
|
...THUMBGATE_SEO_SITEMAP_ENTRIES,
|
|
3537
2508
|
];
|
|
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
|
-
}
|
|
3562
2509
|
return [
|
|
3563
2510
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
3564
2511
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
@@ -3684,13 +2631,11 @@ function servePublicMarketingPage({
|
|
|
3684
2631
|
}, req.headers, 'seo_landing_view');
|
|
3685
2632
|
}
|
|
3686
2633
|
|
|
3687
|
-
const requestHost = getSafePublicRequestHost(req);
|
|
3688
2634
|
const html = renderHtml(hostedConfig, {
|
|
3689
2635
|
serverVisitorId: journeyState.visitorId,
|
|
3690
2636
|
serverSessionId: journeyState.sessionId,
|
|
3691
2637
|
serverAcquisitionId: journeyState.acquisitionId,
|
|
3692
2638
|
serverTelemetryCaptured: landingTelemetryCaptured,
|
|
3693
|
-
requestHost,
|
|
3694
2639
|
});
|
|
3695
2640
|
|
|
3696
2641
|
sendHtml(
|
|
@@ -3830,12 +2775,11 @@ function renderCheckoutSuccessPage(runtimeConfig) {
|
|
|
3830
2775
|
</style>
|
|
3831
2776
|
<link rel="icon" type="image/png" href="/thumbgate-icon.png">
|
|
3832
2777
|
<link rel="apple-touch-icon" href="/assets/brand/thumbgate-mark.svg">
|
|
3833
|
-
<script defer data-domain="thumbgate-production.up.railway.app" src="https://plausible.io/js/script.
|
|
3834
|
-
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments); };</script>
|
|
2778
|
+
<script defer data-domain="thumbgate-production.up.railway.app" src="https://plausible.io/js/script.js"></script>
|
|
3835
2779
|
</head>
|
|
3836
2780
|
<body>
|
|
3837
2781
|
<main>
|
|
3838
|
-
<a href="/" class="brand-header"><img src="/assets/brand/thumbgate-mark-inline
|
|
2782
|
+
<a href="/" class="brand-header"><img src="/assets/brand/thumbgate-mark-inline.svg" alt="ThumbGate" class="logo-mark" width="32" height="32"><span class="logo-text">ThumbGate</span></a>
|
|
3839
2783
|
<span class="eyebrow">ThumbGate Pro</span>
|
|
3840
2784
|
<h1>Your local Pro dashboard is ready.</h1>
|
|
3841
2785
|
<p class="lead">This page verifies your Stripe session, provisions the key if needed, and gives you the exact command to save your license and launch your personal local dashboard.</p>
|
|
@@ -3972,7 +2916,7 @@ function renderCheckoutSuccessPage(runtimeConfig) {
|
|
|
3972
2916
|
if (window.sessionStorage) {
|
|
3973
2917
|
window.sessionStorage.setItem(marker, '1');
|
|
3974
2918
|
}
|
|
3975
|
-
} catch {
|
|
2919
|
+
} catch (_) {
|
|
3976
2920
|
sendTelemetry(eventType, extra);
|
|
3977
2921
|
}
|
|
3978
2922
|
}
|
|
@@ -4009,7 +2953,6 @@ function renderCheckoutSuccessPage(runtimeConfig) {
|
|
|
4009
2953
|
}
|
|
4010
2954
|
|
|
4011
2955
|
sendTelemetryOnce('checkout_paid_confirmed');
|
|
4012
|
-
try { window.plausible && window.plausible('Checkout Pro Success Page Confirmed', { props: { sessionId: sessionId || '', traceId: traceId || '', source: 'success_page' } }); } catch (_) {}
|
|
4013
2956
|
statusEl.textContent = 'ThumbGate Pro activated.';
|
|
4014
2957
|
const resolvedTraceId = body.traceId || traceId || '';
|
|
4015
2958
|
const emailStatus = body.trialEmail || {};
|
|
@@ -4409,39 +3352,6 @@ function renderWorkflowSprintIntakeResultPage(runtimeConfig, { title, detail, le
|
|
|
4409
3352
|
</html>`;
|
|
4410
3353
|
}
|
|
4411
3354
|
|
|
4412
|
-
function renderBrokerAuditIntakeResultPage(runtimeConfig, { title, detail, leadId = null }) {
|
|
4413
|
-
const safeTitle = escapeHtmlAttribute(title || 'Broker audit request received');
|
|
4414
|
-
const safeDetail = escapeHtmlAttribute(detail || 'Your broker lead-flow audit request is in the queue.');
|
|
4415
|
-
const safeLeadId = leadId ? `<p><strong>Lead ID:</strong> ${escapeHtmlAttribute(leadId)}</p>` : '';
|
|
4416
|
-
return `<!DOCTYPE html>
|
|
4417
|
-
<html lang="en">
|
|
4418
|
-
<head>
|
|
4419
|
-
<meta charset="UTF-8" />
|
|
4420
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
4421
|
-
<title>${safeTitle}</title>
|
|
4422
|
-
<meta name="robots" content="noindex,nofollow" />
|
|
4423
|
-
<style>
|
|
4424
|
-
body { margin: 0; font-family: system-ui, -apple-system, sans-serif; background: #0b1220; color: #e5edf8; line-height: 1.6; }
|
|
4425
|
-
main { max-width: 680px; margin: 0 auto; padding: 72px 20px; }
|
|
4426
|
-
.card { border: 1px solid #26354f; border-radius: 14px; padding: 24px; background: #111a2b; }
|
|
4427
|
-
h1 { margin: 0 0 10px; font-size: 30px; }
|
|
4428
|
-
p { color: #b8c5d8; }
|
|
4429
|
-
a { color: #7dd3fc; font-weight: 700; text-decoration: none; }
|
|
4430
|
-
</style>
|
|
4431
|
-
</head>
|
|
4432
|
-
<body>
|
|
4433
|
-
<main>
|
|
4434
|
-
<div class="card">
|
|
4435
|
-
<h1>${safeTitle}</h1>
|
|
4436
|
-
<p>${safeDetail}</p>
|
|
4437
|
-
${safeLeadId}
|
|
4438
|
-
<p><a href="${escapeHtmlAttribute(runtimeConfig.appOrigin)}/broker-audit">Back to broker audit page</a></p>
|
|
4439
|
-
</div>
|
|
4440
|
-
</main>
|
|
4441
|
-
</body>
|
|
4442
|
-
</html>`;
|
|
4443
|
-
}
|
|
4444
|
-
|
|
4445
3355
|
function readBodyBuffer(req, maxBytes = 1024 * 1024) {
|
|
4446
3356
|
return new Promise((resolve, reject) => {
|
|
4447
3357
|
let total = 0;
|
|
@@ -4486,72 +3396,6 @@ async function parseFormBody(req, maxBytes = 1024 * 1024) {
|
|
|
4486
3396
|
return Object.fromEntries(params.entries());
|
|
4487
3397
|
}
|
|
4488
3398
|
|
|
4489
|
-
function normalizeLeadEmail(value) {
|
|
4490
|
-
const email = normalizeNullableText(value);
|
|
4491
|
-
if (!email || !/^[^\s@]{1,64}@[^\s@]{1,255}\.[^\s@]{1,63}$/.test(email)) {
|
|
4492
|
-
throw createHttpError(400, 'A valid email is required');
|
|
4493
|
-
}
|
|
4494
|
-
return email.toLowerCase();
|
|
4495
|
-
}
|
|
4496
|
-
|
|
4497
|
-
function normalizeHttpUrl(value, fieldName) {
|
|
4498
|
-
const raw = normalizeNullableText(value);
|
|
4499
|
-
if (!raw) throw createHttpError(400, `${fieldName} is required`);
|
|
4500
|
-
try {
|
|
4501
|
-
const parsedUrl = new URL(raw);
|
|
4502
|
-
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
4503
|
-
throw new Error('unsupported protocol');
|
|
4504
|
-
}
|
|
4505
|
-
return parsedUrl.toString();
|
|
4506
|
-
} catch {
|
|
4507
|
-
throw createHttpError(400, `${fieldName} must be an http(s) URL`);
|
|
4508
|
-
}
|
|
4509
|
-
}
|
|
4510
|
-
|
|
4511
|
-
function appendBrokerAuditLead(feedbackDir, payload) {
|
|
4512
|
-
const lead = {
|
|
4513
|
-
leadId: createTraceId('broker_audit_lead'),
|
|
4514
|
-
status: 'new',
|
|
4515
|
-
offer: 'broker_lead_flow_audit_free',
|
|
4516
|
-
requestedAt: new Date().toISOString(),
|
|
4517
|
-
contact: {
|
|
4518
|
-
name: normalizeNullableText(payload.name),
|
|
4519
|
-
email: normalizeLeadEmail(payload.email),
|
|
4520
|
-
brokerage: normalizeNullableText(payload.brokerage),
|
|
4521
|
-
website: normalizeHttpUrl(payload.website, 'website'),
|
|
4522
|
-
},
|
|
4523
|
-
qualification: {
|
|
4524
|
-
suspectedLeak: normalizeNullableText(payload.suspected_leak || payload.suspectedLeak),
|
|
4525
|
-
},
|
|
4526
|
-
attribution: {
|
|
4527
|
-
traceId: payload.traceId,
|
|
4528
|
-
acquisitionId: payload.acquisitionId,
|
|
4529
|
-
visitorId: payload.visitorId,
|
|
4530
|
-
sessionId: payload.sessionId,
|
|
4531
|
-
source: payload.source,
|
|
4532
|
-
utmSource: payload.utmSource,
|
|
4533
|
-
utmMedium: payload.utmMedium,
|
|
4534
|
-
utmCampaign: payload.utmCampaign,
|
|
4535
|
-
utmContent: payload.utmContent,
|
|
4536
|
-
utmTerm: payload.utmTerm,
|
|
4537
|
-
ctaId: payload.ctaId,
|
|
4538
|
-
ctaPlacement: payload.ctaPlacement,
|
|
4539
|
-
page: payload.page,
|
|
4540
|
-
landingPath: payload.landingPath,
|
|
4541
|
-
referrer: payload.referrer,
|
|
4542
|
-
referrerHost: payload.referrerHost,
|
|
4543
|
-
},
|
|
4544
|
-
};
|
|
4545
|
-
|
|
4546
|
-
if (!lead.contact.name) throw createHttpError(400, 'name is required');
|
|
4547
|
-
if (!lead.contact.brokerage) throw createHttpError(400, 'brokerage is required');
|
|
4548
|
-
|
|
4549
|
-
const leadsPath = path.join(feedbackDir, 'broker-audit-leads.jsonl');
|
|
4550
|
-
fs.mkdirSync(path.dirname(leadsPath), { recursive: true });
|
|
4551
|
-
fs.appendFileSync(leadsPath, `${JSON.stringify(lead)}\n`, 'utf8');
|
|
4552
|
-
return lead;
|
|
4553
|
-
}
|
|
4554
|
-
|
|
4555
3399
|
function parseOptionalObject(input, name) {
|
|
4556
3400
|
if (input == null) return {};
|
|
4557
3401
|
if (typeof input === 'object' && !Array.isArray(input)) return input;
|
|
@@ -4626,49 +3470,6 @@ function extractApiKey(req) {
|
|
|
4626
3470
|
return '';
|
|
4627
3471
|
}
|
|
4628
3472
|
|
|
4629
|
-
/**
|
|
4630
|
-
* Map a ThumbGate key presented at the OAuth consent screen to a role.
|
|
4631
|
-
*
|
|
4632
|
-
* - When any key is configured (production), the presented key MUST match a
|
|
4633
|
-
* configured admin/operator/reviewer key; otherwise returns null (reject) so
|
|
4634
|
-
* the OAuth flow actually authenticates the holder.
|
|
4635
|
-
* - When NO keys are configured (insecure/dev mode, e.g. local tests), any
|
|
4636
|
-
* non-empty key is accepted as role 'dev' to preserve local development.
|
|
4637
|
-
*
|
|
4638
|
-
* 'reviewer' (THUMBGATE_REVIEWER_KEY) is a read-only, independently-revocable
|
|
4639
|
-
* credential safe to share with a directory reviewer — tool execution enforces
|
|
4640
|
-
* read-only for it (see the tools/call handler).
|
|
4641
|
-
*/
|
|
4642
|
-
// Constant-time comparison of two API/OAuth bearer keys. We compare the raw
|
|
4643
|
-
// bytes with crypto.timingSafeEqual after an explicit equal-length guard
|
|
4644
|
-
// (timingSafeEqual throws on a length mismatch). No digest is taken: these are
|
|
4645
|
-
// high-entropy random credentials compared transiently at request time, not
|
|
4646
|
-
// user passwords stored at rest, so neither a KDF (bcrypt/scrypt/argon2) nor a
|
|
4647
|
-
// length-normalizing hash is appropriate — and hashing the credential here is
|
|
4648
|
-
// exactly what static analysis (correctly, for *stored* passwords) flags. The
|
|
4649
|
-
// only side-channel is whether the two keys share a length, which reveals
|
|
4650
|
-
// nothing useful about a random secret. Once lengths match, the byte compare
|
|
4651
|
-
// is constant-time and leaks no information about where they differ.
|
|
4652
|
-
function safeKeyEqual(a, b) {
|
|
4653
|
-
const x = Buffer.from(String(a || ''), 'utf8');
|
|
4654
|
-
const y = Buffer.from(String(b || ''), 'utf8');
|
|
4655
|
-
if (x.length === 0 || y.length === 0 || x.length !== y.length) return false;
|
|
4656
|
-
return require('crypto').timingSafeEqual(x, y);
|
|
4657
|
-
}
|
|
4658
|
-
|
|
4659
|
-
function resolveKeyRole(key) {
|
|
4660
|
-
const k = String(key || '').trim();
|
|
4661
|
-
const adminKey = String(process.env.THUMBGATE_API_KEY || '').trim();
|
|
4662
|
-
const operatorKey = String(process.env.THUMBGATE_OPERATOR_KEY || '').trim();
|
|
4663
|
-
const reviewerKey = String(process.env.THUMBGATE_REVIEWER_KEY || '').trim();
|
|
4664
|
-
const configured = [adminKey, operatorKey, reviewerKey].filter(Boolean);
|
|
4665
|
-
if (configured.length === 0) return k ? 'dev' : null;
|
|
4666
|
-
if (safeKeyEqual(k, adminKey)) return 'admin';
|
|
4667
|
-
if (safeKeyEqual(k, operatorKey)) return 'operator';
|
|
4668
|
-
if (safeKeyEqual(k, reviewerKey)) return 'reviewer';
|
|
4669
|
-
return null;
|
|
4670
|
-
}
|
|
4671
|
-
|
|
4672
3473
|
/**
|
|
4673
3474
|
* Admin-only guard for static THUMBGATE_API_KEY.
|
|
4674
3475
|
* Billing keys are intentionally excluded from admin actions.
|
|
@@ -4769,42 +3570,6 @@ function normalizeJobIdFromPath(pathname, suffix = '') {
|
|
|
4769
3570
|
return match ? decodeURIComponent(match[1]) : null;
|
|
4770
3571
|
}
|
|
4771
3572
|
|
|
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
|
-
|
|
4808
3573
|
function normalizeDocumentIdFromPath(pathname) {
|
|
4809
3574
|
const match = pathname.match(/^\/v1\/documents\/([^/]+)$/);
|
|
4810
3575
|
return match ? decodeURIComponent(match[1]) : null;
|
|
@@ -4914,75 +3679,12 @@ function createApiServer() {
|
|
|
4914
3679
|
tools: getPublicMcpTools(),
|
|
4915
3680
|
},
|
|
4916
3681
|
});
|
|
4917
|
-
} else if (msg.method === 'tools/call') {
|
|
4918
|
-
// Authenticated tool execution. Accept either an OAuth 2.1 access
|
|
4919
|
-
// token (audience-bound to this MCP server, RFC 8707) or a raw
|
|
4920
|
-
// ThumbGate API key, both via the Bearer header.
|
|
4921
|
-
const bearer = extractBearerToken(req);
|
|
4922
|
-
const resourceUrl = buildPublicUrl(hostedConfig, '/mcp');
|
|
4923
|
-
const oauthSession = mcpOauth.resolveAccessToken(oauthStore, bearer);
|
|
4924
|
-
// OAuth path: token must resolve AND be audience-bound to this server
|
|
4925
|
-
// (RFC 8707). Raw-key path: only an exact match to a configured
|
|
4926
|
-
// operator/admin key — never "any non-empty bearer".
|
|
4927
|
-
const adminKey = String(process.env.THUMBGATE_API_KEY || '').trim();
|
|
4928
|
-
const operatorKey = String(process.env.THUMBGATE_OPERATOR_KEY || '').trim();
|
|
4929
|
-
const reviewerKey = String(process.env.THUMBGATE_REVIEWER_KEY || '').trim();
|
|
4930
|
-
const rawKeyValid = Boolean(bearer) && (safeKeyEqual(bearer, adminKey) || safeKeyEqual(bearer, operatorKey) || safeKeyEqual(bearer, reviewerKey));
|
|
4931
|
-
const authed = oauthSession
|
|
4932
|
-
? mcpOauth.tokenAudienceValid(oauthSession, resourceUrl)
|
|
4933
|
-
: rawKeyValid;
|
|
4934
|
-
if (!authed) {
|
|
4935
|
-
res.writeHead(401, {
|
|
4936
|
-
'Content-Type': 'application/json',
|
|
4937
|
-
// RFC 9728: point unauthenticated clients at the resource metadata.
|
|
4938
|
-
'WWW-Authenticate': `Bearer resource_metadata="${buildPublicUrl(hostedConfig, '/.well-known/oauth-protected-resource')}"`,
|
|
4939
|
-
});
|
|
4940
|
-
res.end(JSON.stringify({
|
|
4941
|
-
jsonrpc: '2.0', id: msg.id,
|
|
4942
|
-
error: { code: -32001, message: 'Authentication required. Use OAuth 2.1 (see /.well-known/oauth-protected-resource) or a ThumbGate API key.' },
|
|
4943
|
-
}));
|
|
4944
|
-
return;
|
|
4945
|
-
}
|
|
4946
|
-
// The reviewer credential (THUMBGATE_REVIEWER_KEY) is read-only: it may
|
|
4947
|
-
// only invoke tools annotated readOnlyHint:true. This makes a credential
|
|
4948
|
-
// safe to share (e.g. with a directory reviewer) without granting the
|
|
4949
|
-
// ability to mutate shared server state.
|
|
4950
|
-
const effectiveKey = oauthSession ? String(oauthSession.boundKey || '') : bearer;
|
|
4951
|
-
const isReviewer = Boolean(reviewerKey) && safeKeyEqual(effectiveKey, reviewerKey);
|
|
4952
|
-
if (isReviewer) {
|
|
4953
|
-
const name = msg.params && msg.params.name;
|
|
4954
|
-
const tool = MCP_TOOLS.find((t) => t.name === name);
|
|
4955
|
-
const readOnly = Boolean(tool && tool.annotations && tool.annotations.readOnlyHint === true);
|
|
4956
|
-
if (!readOnly) {
|
|
4957
|
-
sendJson(res, 200, {
|
|
4958
|
-
jsonrpc: '2.0', id: msg.id,
|
|
4959
|
-
error: { code: -32002, message: `Tool "${name}" requires write access; the reviewer credential is read-only.` },
|
|
4960
|
-
});
|
|
4961
|
-
return;
|
|
4962
|
-
}
|
|
4963
|
-
}
|
|
4964
|
-
(async () => {
|
|
4965
|
-
try {
|
|
4966
|
-
const { callTool } = require('../../adapters/mcp/server-stdio');
|
|
4967
|
-
const name = msg.params && msg.params.name;
|
|
4968
|
-
const args = (msg.params && msg.params.arguments) || {};
|
|
4969
|
-
const result = await callTool(name, args);
|
|
4970
|
-
sendJson(res, 200, {
|
|
4971
|
-
jsonrpc: '2.0', id: msg.id,
|
|
4972
|
-
result: { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }] },
|
|
4973
|
-
});
|
|
4974
|
-
} catch (err) {
|
|
4975
|
-
sendJson(res, 200, {
|
|
4976
|
-
jsonrpc: '2.0', id: msg.id,
|
|
4977
|
-
result: { isError: true, content: [{ type: 'text', text: String(err && err.message || err) }] },
|
|
4978
|
-
});
|
|
4979
|
-
}
|
|
4980
|
-
})();
|
|
4981
3682
|
} else {
|
|
3683
|
+
// All other tool calls require auth — return method not found for unauthenticated
|
|
4982
3684
|
sendJson(res, 200, {
|
|
4983
3685
|
jsonrpc: '2.0',
|
|
4984
3686
|
id: msg.id,
|
|
4985
|
-
error: { code: -32601, message:
|
|
3687
|
+
error: { code: -32601, message: 'Method requires authentication. Provide Bearer token.' },
|
|
4986
3688
|
});
|
|
4987
3689
|
}
|
|
4988
3690
|
} catch (_e) {
|
|
@@ -5070,10 +3772,7 @@ function createApiServer() {
|
|
|
5070
3772
|
});
|
|
5071
3773
|
sendJson(res, 200, result);
|
|
5072
3774
|
} catch (e) {
|
|
5073
|
-
sendJson(res, 500, {
|
|
5074
|
-
error: 'feedback submission failed',
|
|
5075
|
-
message: e?.message || 'Unable to submit dashboard feedback.',
|
|
5076
|
-
});
|
|
3775
|
+
sendJson(res, 500, { error: 'feedback submission failed' });
|
|
5077
3776
|
}
|
|
5078
3777
|
});
|
|
5079
3778
|
return;
|
|
@@ -5262,7 +3961,7 @@ function createApiServer() {
|
|
|
5262
3961
|
return;
|
|
5263
3962
|
}
|
|
5264
3963
|
|
|
5265
|
-
if (isGetLikeRequest &&
|
|
3964
|
+
if (isGetLikeRequest && pathname === '/.well-known/llms.txt') {
|
|
5266
3965
|
const llmsTxtPath = path.join(__dirname, '..', '..', '.well-known', 'llms.txt');
|
|
5267
3966
|
try {
|
|
5268
3967
|
const content = fs.readFileSync(llmsTxtPath, 'utf8');
|
|
@@ -5273,17 +3972,6 @@ function createApiServer() {
|
|
|
5273
3972
|
return;
|
|
5274
3973
|
}
|
|
5275
3974
|
|
|
5276
|
-
if (isGetLikeRequest && pathname === '/.well-known/agentic-verify.txt') {
|
|
5277
|
-
const agenticVerifyPath = path.join(__dirname, '..', '..', '.well-known', 'agentic-verify.txt');
|
|
5278
|
-
try {
|
|
5279
|
-
const content = fs.readFileSync(agenticVerifyPath, 'utf8');
|
|
5280
|
-
sendText(res, 200, content, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'public, max-age=3600' }, { headOnly: isHeadRequest });
|
|
5281
|
-
} catch {
|
|
5282
|
-
sendJson(res, 404, { error: 'agentic verification file not found' });
|
|
5283
|
-
}
|
|
5284
|
-
return;
|
|
5285
|
-
}
|
|
5286
|
-
|
|
5287
3975
|
if (isGetLikeRequest && pathname === '/sitemap.xml') {
|
|
5288
3976
|
sendText(res, 200, renderSitemapXml(hostedConfig), {
|
|
5289
3977
|
'Content-Type': 'application/xml; charset=utf-8',
|
|
@@ -5576,40 +4264,12 @@ async function addContext(){
|
|
|
5576
4264
|
return;
|
|
5577
4265
|
}
|
|
5578
4266
|
|
|
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
|
-
|
|
5598
4267
|
if (isGetLikeRequest && (pathname === '/guide' || pathname === '/guide.html')) {
|
|
5599
4268
|
try {
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
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');
|
|
4269
|
+
const html = fs.readFileSync(GUIDE_PAGE_PATH, 'utf-8');
|
|
4270
|
+
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
4271
|
+
} catch {
|
|
4272
|
+
sendJson(res, 404, { error: 'Guide page not found' });
|
|
5613
4273
|
}
|
|
5614
4274
|
return;
|
|
5615
4275
|
}
|
|
@@ -5624,28 +4284,6 @@ async function addContext(){
|
|
|
5624
4284
|
return;
|
|
5625
4285
|
}
|
|
5626
4286
|
|
|
5627
|
-
if (isGetLikeRequest && (
|
|
5628
|
-
pathname === '/chatgpt-app'
|
|
5629
|
-
|| pathname === '/chatgpt-app.html'
|
|
5630
|
-
|| pathname === '/chatgpt-plugin'
|
|
5631
|
-
|| pathname === '/chatgpt-plugin.html'
|
|
5632
|
-
)) {
|
|
5633
|
-
try {
|
|
5634
|
-
servePublicMarketingPage({
|
|
5635
|
-
req,
|
|
5636
|
-
res,
|
|
5637
|
-
parsed,
|
|
5638
|
-
hostedConfig,
|
|
5639
|
-
isHeadRequest,
|
|
5640
|
-
renderHtml: () => fs.readFileSync(CHATGPT_APP_PAGE_PATH, 'utf-8'),
|
|
5641
|
-
extraTelemetry: { pageType: 'chatgpt_app' },
|
|
5642
|
-
});
|
|
5643
|
-
} catch {
|
|
5644
|
-
sendJson(res, 404, { error: 'ChatGPT app page not found' });
|
|
5645
|
-
}
|
|
5646
|
-
return;
|
|
5647
|
-
}
|
|
5648
|
-
|
|
5649
4287
|
if (isGetLikeRequest && (pathname === '/compare' || pathname === '/compare.html')) {
|
|
5650
4288
|
try {
|
|
5651
4289
|
const html = fs.readFileSync(COMPARE_PAGE_PATH, 'utf-8');
|
|
@@ -5669,19 +4307,10 @@ async function addContext(){
|
|
|
5669
4307
|
|
|
5670
4308
|
if (isGetLikeRequest && (pathname === '/learn' || pathname === '/learn.html')) {
|
|
5671
4309
|
try {
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
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');
|
|
4310
|
+
const html = fs.readFileSync(LEARN_PAGE_PATH, 'utf-8');
|
|
4311
|
+
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
4312
|
+
} catch {
|
|
4313
|
+
sendJson(res, 404, { error: 'Learn page not found' });
|
|
5685
4314
|
}
|
|
5686
4315
|
return;
|
|
5687
4316
|
}
|
|
@@ -5730,7 +4359,7 @@ async function addContext(){
|
|
|
5730
4359
|
if (isGetLikeRequest && (pathname === '/numbers' || pathname === '/numbers.html')) {
|
|
5731
4360
|
// Route through servePublicMarketingPage so landing_page_view telemetry
|
|
5732
4361
|
// + funnel-events.jsonl `discovery/landing_view` get captured with UTM
|
|
5733
|
-
// attribution — critical for
|
|
4362
|
+
// attribution — critical for Zernio social CTAs that target /numbers.
|
|
5734
4363
|
try {
|
|
5735
4364
|
servePublicMarketingPage({
|
|
5736
4365
|
req,
|
|
@@ -5769,94 +4398,6 @@ async function addContext(){
|
|
|
5769
4398
|
return;
|
|
5770
4399
|
}
|
|
5771
4400
|
|
|
5772
|
-
if (isGetLikeRequest && (pathname === '/agent-manager' || pathname === '/agent-manager.html')) {
|
|
5773
|
-
// ICP landing page for the role Anthropic named (Agent Manager —
|
|
5774
|
-
// hybrid PM/engineer DRI who owns CLAUDE.md hierarchy, plugin
|
|
5775
|
-
// marketplace, permissions policy, and which skills ship). Routed
|
|
5776
|
-
// through servePublicMarketingPage so arrivals via X/Bluesky/LinkedIn
|
|
5777
|
-
// threads about the role capture UTM attribution and
|
|
5778
|
-
// landing_page_view telemetry for downstream pilot-pipeline analysis.
|
|
5779
|
-
try {
|
|
5780
|
-
servePublicMarketingPage({
|
|
5781
|
-
req,
|
|
5782
|
-
res,
|
|
5783
|
-
parsed,
|
|
5784
|
-
hostedConfig,
|
|
5785
|
-
isHeadRequest,
|
|
5786
|
-
renderHtml: () => fs.readFileSync(path.join(PUBLIC_DIR, 'agent-manager.html'), 'utf-8'),
|
|
5787
|
-
extraTelemetry: { pageType: 'agent_manager' },
|
|
5788
|
-
});
|
|
5789
|
-
} catch {
|
|
5790
|
-
sendJson(res, 404, { error: 'Agent Manager page not found' });
|
|
5791
|
-
}
|
|
5792
|
-
return;
|
|
5793
|
-
}
|
|
5794
|
-
|
|
5795
|
-
if (isGetLikeRequest && (pathname === '/codex-enterprise' || pathname === '/codex-enterprise.html')) {
|
|
5796
|
-
// Landing page riding the 2026-05-20 OpenAI×Dell Codex Enterprise
|
|
5797
|
-
// partnership announcement. Dell-distributed Codex expands the TAM
|
|
5798
|
-
// for ThumbGate's governance layer — capture every agent decision,
|
|
5799
|
-
// promote repeat failures to PreToolUse gates, ship the audit trail
|
|
5800
|
-
// procurement requires. Routed through servePublicMarketingPage so
|
|
5801
|
-
// arrivals via the partnership news cycle capture UTM attribution
|
|
5802
|
-
// and landing_page_view telemetry with pageType: 'codex_enterprise'.
|
|
5803
|
-
try {
|
|
5804
|
-
servePublicMarketingPage({
|
|
5805
|
-
req,
|
|
5806
|
-
res,
|
|
5807
|
-
parsed,
|
|
5808
|
-
hostedConfig,
|
|
5809
|
-
isHeadRequest,
|
|
5810
|
-
renderHtml: () => fs.readFileSync(path.join(PUBLIC_DIR, 'codex-enterprise.html'), 'utf-8'),
|
|
5811
|
-
extraTelemetry: { pageType: 'codex_enterprise' },
|
|
5812
|
-
});
|
|
5813
|
-
} catch {
|
|
5814
|
-
sendJson(res, 404, { error: 'Codex Enterprise page not found' });
|
|
5815
|
-
}
|
|
5816
|
-
return;
|
|
5817
|
-
}
|
|
5818
|
-
|
|
5819
|
-
if (isGetLikeRequest && (pathname === '/agents-cost-savings' || pathname === '/agents-cost-savings.html')) {
|
|
5820
|
-
// FinOps-for-AI positioning page. Pairs with the `thumbgate cost` CLI
|
|
5821
|
-
// shipped in #2281: the CLI prints the dollar amount, this page is
|
|
5822
|
-
// the public-facing explanation of why "prevention" (ThumbGate's
|
|
5823
|
-
// PreToolUse gates) is a distinct category from "reporting" (Finout,
|
|
5824
|
-
// Helicone, Vantage, AgentOps). Reply-to-pitch surface for buyers
|
|
5825
|
-
// who get a FinOps-for-AI marketing email and need a frame.
|
|
5826
|
-
try {
|
|
5827
|
-
servePublicMarketingPage({
|
|
5828
|
-
req,
|
|
5829
|
-
res,
|
|
5830
|
-
parsed,
|
|
5831
|
-
hostedConfig,
|
|
5832
|
-
isHeadRequest,
|
|
5833
|
-
renderHtml: () => fs.readFileSync(path.join(PUBLIC_DIR, 'agents-cost-savings.html'), 'utf-8'),
|
|
5834
|
-
extraTelemetry: { pageType: 'agents_cost_savings' },
|
|
5835
|
-
});
|
|
5836
|
-
} catch {
|
|
5837
|
-
sendJson(res, 404, { error: 'Agents Cost Savings page not found' });
|
|
5838
|
-
}
|
|
5839
|
-
return;
|
|
5840
|
-
}
|
|
5841
|
-
|
|
5842
|
-
if (isGetLikeRequest && (pathname === '/ai-malpractice-prevention' || pathname === '/ai-malpractice-prevention.html')) {
|
|
5843
|
-
// Legal-vertical landing page (2026-05-21).
|
|
5844
|
-
try {
|
|
5845
|
-
servePublicMarketingPage({
|
|
5846
|
-
req,
|
|
5847
|
-
res,
|
|
5848
|
-
parsed,
|
|
5849
|
-
hostedConfig,
|
|
5850
|
-
isHeadRequest,
|
|
5851
|
-
renderHtml: () => fs.readFileSync(path.join(PUBLIC_DIR, 'ai-malpractice-prevention.html'), 'utf-8'),
|
|
5852
|
-
extraTelemetry: { pageType: 'ai_malpractice_prevention' },
|
|
5853
|
-
});
|
|
5854
|
-
} catch {
|
|
5855
|
-
sendJson(res, 404, { error: 'AI Malpractice Prevention page not found' });
|
|
5856
|
-
}
|
|
5857
|
-
return;
|
|
5858
|
-
}
|
|
5859
|
-
|
|
5860
4401
|
if (isGetLikeRequest && pathname === '/learn/learn.css') {
|
|
5861
4402
|
try {
|
|
5862
4403
|
const cssPath = path.join(LEARN_DIR, 'learn.css');
|
|
@@ -5872,13 +4413,13 @@ async function addContext(){
|
|
|
5872
4413
|
|
|
5873
4414
|
if (isGetLikeRequest && pathname.startsWith('/learn/')) {
|
|
5874
4415
|
try {
|
|
5875
|
-
const
|
|
5876
|
-
|
|
5877
|
-
|
|
4416
|
+
const slug = normalizePublicPageSlug(pathname.replace('/learn/', ''));
|
|
4417
|
+
const articlePath = path.join(LEARN_DIR, `${slug}.html`);
|
|
4418
|
+
if (!articlePath.startsWith(LEARN_DIR)) {
|
|
4419
|
+
sendJson(res, 403, { error: 'Forbidden' });
|
|
5878
4420
|
return;
|
|
5879
4421
|
}
|
|
5880
|
-
const
|
|
5881
|
-
const html = normalizePublicMarketingHtml(fs.readFileSync(articlePath, 'utf-8'), hostedConfig, requestHost);
|
|
4422
|
+
const html = fs.readFileSync(articlePath, 'utf-8');
|
|
5882
4423
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
5883
4424
|
} catch {
|
|
5884
4425
|
sendJson(res, 404, { error: 'Article not found' });
|
|
@@ -5888,10 +4429,10 @@ async function addContext(){
|
|
|
5888
4429
|
|
|
5889
4430
|
if (isGetLikeRequest && pathname.startsWith('/guides/')) {
|
|
5890
4431
|
try {
|
|
5891
|
-
const
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
const html =
|
|
4432
|
+
const slug = normalizePublicPageSlug(pathname.replace('/guides/', ''));
|
|
4433
|
+
const guidePath = path.join(GUIDES_DIR, `${slug}.html`);
|
|
4434
|
+
if (!guidePath.startsWith(GUIDES_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
|
|
4435
|
+
const html = fs.readFileSync(guidePath, 'utf-8');
|
|
5895
4436
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
5896
4437
|
} catch { sendJson(res, 404, { error: 'Guide not found' }); }
|
|
5897
4438
|
return;
|
|
@@ -5899,10 +4440,10 @@ async function addContext(){
|
|
|
5899
4440
|
|
|
5900
4441
|
if (isGetLikeRequest && pathname.startsWith('/compare/') && pathname !== '/compare') {
|
|
5901
4442
|
try {
|
|
5902
|
-
const
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
const html =
|
|
4443
|
+
const slug = normalizePublicPageSlug(pathname.replace('/compare/', ''));
|
|
4444
|
+
const comparePath = path.join(COMPARE_DIR, `${slug}.html`);
|
|
4445
|
+
if (!comparePath.startsWith(COMPARE_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
|
|
4446
|
+
const html = fs.readFileSync(comparePath, 'utf-8');
|
|
5906
4447
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
5907
4448
|
} catch { sendJson(res, 404, { error: 'Comparison not found' }); }
|
|
5908
4449
|
return;
|
|
@@ -5910,10 +4451,10 @@ async function addContext(){
|
|
|
5910
4451
|
|
|
5911
4452
|
if (isGetLikeRequest && pathname.startsWith('/use-cases/')) {
|
|
5912
4453
|
try {
|
|
5913
|
-
const
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
const html =
|
|
4454
|
+
const slug = normalizePublicPageSlug(pathname.replace('/use-cases/', ''));
|
|
4455
|
+
const useCasePath = path.join(USE_CASES_DIR, `${slug}.html`);
|
|
4456
|
+
if (!useCasePath.startsWith(USE_CASES_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
|
|
4457
|
+
const html = fs.readFileSync(useCasePath, 'utf-8');
|
|
5917
4458
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
5918
4459
|
} catch { sendJson(res, 404, { error: 'Use case not found' }); }
|
|
5919
4460
|
return;
|
|
@@ -5930,18 +4471,6 @@ async function addContext(){
|
|
|
5930
4471
|
return;
|
|
5931
4472
|
}
|
|
5932
4473
|
|
|
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
|
-
|
|
5945
4474
|
if (isGetLikeRequest && (
|
|
5946
4475
|
pathname === '/favicon.ico'
|
|
5947
4476
|
|| pathname === '/thumbgate-logo.png'
|
|
@@ -5960,7 +4489,7 @@ async function addContext(){
|
|
|
5960
4489
|
version: pkg.version,
|
|
5961
4490
|
status: 'ok',
|
|
5962
4491
|
docs: 'https://github.com/IgorGanapolsky/ThumbGate',
|
|
5963
|
-
endpoints: ['/health', '/dashboard', '/guide', '/
|
|
4492
|
+
endpoints: ['/health', '/dashboard', '/guide', '/codex-plugin', '/compare', '/learn', '/v1/feedback/capture', '/v1/feedback/stats', '/v1/feedback/summary', '/v1/lessons/search', '/v1/search', '/v1/documents', '/v1/documents/import', '/v1/documents/{documentId}', '/v1/dashboard', '/v1/dashboard/render-spec', '/v1/decisions/evaluate', '/v1/decisions/outcome', '/v1/decisions/metrics', '/v1/settings/status', '/v1/dpo/export', '/v1/jobs', '/v1/jobs/harness', '/v1/analytics/databricks/export'],
|
|
5964
4493
|
}, {}, {
|
|
5965
4494
|
headOnly: isHeadRequest,
|
|
5966
4495
|
});
|
|
@@ -5985,14 +4514,9 @@ async function addContext(){
|
|
|
5985
4514
|
return;
|
|
5986
4515
|
}
|
|
5987
4516
|
|
|
5988
|
-
|
|
5989
|
-
// to prevent the API-key guard from 401'ing real prospective customers
|
|
5990
|
-
// whose forms or fetch() calls land via POST. Audit: 69 emails submitted
|
|
5991
|
-
// → 0 paid because POST hit the auth gate. Query params still drive the
|
|
5992
|
-
// Stripe session creation; POST bodies are ignored harmlessly.
|
|
5993
|
-
if ((isGetLikeRequest || req.method === 'POST') && pathname === '/checkout/pro') {
|
|
4517
|
+
if (isGetLikeRequest && pathname === '/checkout/pro') {
|
|
5994
4518
|
if (isHeadRequest) {
|
|
5995
|
-
|
|
4519
|
+
sendText(res, 200, '', {}, {
|
|
5996
4520
|
headOnly: true,
|
|
5997
4521
|
});
|
|
5998
4522
|
return;
|
|
@@ -6023,70 +4547,8 @@ async function addContext(){
|
|
|
6023
4547
|
// because no real crawler appends customer_email to discovered URLs.
|
|
6024
4548
|
const hasCustomerEmailHint = !!parsed?.searchParams?.has('customer_email');
|
|
6025
4549
|
const botShouldBypass = !botClassification.isBot || hasCustomerEmailHint;
|
|
6026
|
-
|
|
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)
|
|
4550
|
+
const isConfirmedCheckout = req.method === 'POST'
|
|
6033
4551
|
|| (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
|
-
}
|
|
6090
4552
|
// Plausible funnel event #1 of 3: page view. Fired before interstitial
|
|
6091
4553
|
// deflection so we get the full top-of-funnel count, with isBot as a
|
|
6092
4554
|
// prop so the dashboard can filter human vs. crawler traffic. Fire-and-forget.
|
|
@@ -6142,16 +4604,20 @@ async function addContext(){
|
|
|
6142
4604
|
ctaId: analyticsMetadata.ctaId,
|
|
6143
4605
|
ctaPlacement: analyticsMetadata.ctaPlacement,
|
|
6144
4606
|
planId: analyticsMetadata.planId,
|
|
6145
|
-
billingCycle: analyticsMetadata.billingCycle,
|
|
6146
|
-
landingPath: analyticsMetadata.landingPath,
|
|
6147
4607
|
isBot: botClassification.isBot ? 'true' : 'false',
|
|
6148
|
-
interstitialSampled: interstitialSampled ? 'true' : 'false',
|
|
6149
|
-
interstitialSampleRate,
|
|
6150
4608
|
reason: botClassification.reason,
|
|
6151
4609
|
}, req.headers, eventType);
|
|
6152
|
-
const
|
|
6153
|
-
|
|
6154
|
-
|
|
4610
|
+
const workflowIntakeHref = buildCheckoutIntentHref(`${hostedConfig.appOrigin}/#workflow-sprint-intake`, analyticsMetadata, {
|
|
4611
|
+
utmMedium: 'checkout_interstitial_recovery',
|
|
4612
|
+
utmCampaign: analyticsMetadata.utmCampaign || 'checkout_interstitial_workflow_sprint',
|
|
4613
|
+
ctaId: 'checkout_interstitial_workflow_sprint_intake',
|
|
4614
|
+
ctaPlacement: 'checkout_interstitial',
|
|
4615
|
+
planId: 'team',
|
|
4616
|
+
});
|
|
4617
|
+
const html = renderCheckoutIntentPage({
|
|
4618
|
+
confirmHref: buildCheckoutConfirmHref(parsed),
|
|
4619
|
+
workflowIntakeHref,
|
|
4620
|
+
botClassification,
|
|
6155
4621
|
});
|
|
6156
4622
|
sendHtml(res, 200, html, responseHeaders);
|
|
6157
4623
|
return;
|
|
@@ -6415,13 +4881,10 @@ async function addContext(){
|
|
|
6415
4881
|
// Public-facing broker lead-flow audit landing page. Wedge for the
|
|
6416
4882
|
// real-estate broker outreach. Static HTML served from src/api/static.
|
|
6417
4883
|
try {
|
|
6418
|
-
const
|
|
6419
|
-
const html = normalizePublicMarketingHtml(fillTemplate(fs.readFileSync(
|
|
4884
|
+
const html = fs.readFileSync(
|
|
6420
4885
|
path.resolve(__dirname, '../../assets/static/broker-audit.html'),
|
|
6421
4886
|
'utf8'
|
|
6422
|
-
)
|
|
6423
|
-
'__POSTHOG_API_KEY__': hostedConfig.posthogApiKey || '',
|
|
6424
|
-
}), hostedConfig, host);
|
|
4887
|
+
);
|
|
6425
4888
|
if (isHeadRequest) {
|
|
6426
4889
|
sendHtml(res, 200, html, {}, { headOnly: true });
|
|
6427
4890
|
return;
|
|
@@ -6441,117 +4904,6 @@ async function addContext(){
|
|
|
6441
4904
|
return;
|
|
6442
4905
|
}
|
|
6443
4906
|
|
|
6444
|
-
// OAuth 2.1 discovery metadata for the remote MCP connector (RFC 9728 / RFC 8414).
|
|
6445
|
-
// Lets Claude discover how to authenticate before calling authenticated tools.
|
|
6446
|
-
if (isGetLikeRequest && pathname === '/.well-known/oauth-protected-resource') {
|
|
6447
|
-
sendJson(res, 200, mcpOauth.buildProtectedResourceMetadata(buildPublicUrl(hostedConfig, '')), {}, {
|
|
6448
|
-
headOnly: isHeadRequest,
|
|
6449
|
-
});
|
|
6450
|
-
return;
|
|
6451
|
-
}
|
|
6452
|
-
if (isGetLikeRequest && (pathname === '/.well-known/oauth-authorization-server' || pathname === '/.well-known/openid-configuration')) {
|
|
6453
|
-
sendJson(res, 200, mcpOauth.buildAuthServerMetadata(buildPublicUrl(hostedConfig, '')), {}, {
|
|
6454
|
-
headOnly: isHeadRequest,
|
|
6455
|
-
});
|
|
6456
|
-
return;
|
|
6457
|
-
}
|
|
6458
|
-
|
|
6459
|
-
// --- OAuth 2.1 (PKCE) endpoints for the remote MCP connector ---
|
|
6460
|
-
// RFC 7591 Dynamic Client Registration.
|
|
6461
|
-
if (req.method === 'POST' && pathname === '/oauth/register') {
|
|
6462
|
-
let body = '';
|
|
6463
|
-
req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
|
|
6464
|
-
req.on('end', () => {
|
|
6465
|
-
let parsed = {};
|
|
6466
|
-
try { parsed = body ? JSON.parse(body) : {}; } catch { /* ignore */ }
|
|
6467
|
-
const reg = mcpOauth.registerClient(oauthStore, parsed);
|
|
6468
|
-
if (reg.error) { sendJson(res, 400, reg); return; }
|
|
6469
|
-
sendJson(res, 201, reg);
|
|
6470
|
-
});
|
|
6471
|
-
return;
|
|
6472
|
-
}
|
|
6473
|
-
// Authorization endpoint: GET renders consent, POST issues the code.
|
|
6474
|
-
if (pathname === '/oauth/authorize') {
|
|
6475
|
-
if (isGetLikeRequest) {
|
|
6476
|
-
const authRequestToken = createPendingOauthAuthorizeRequest(
|
|
6477
|
-
getOauthAuthorizeParamsFromQuery(parsed.searchParams)
|
|
6478
|
-
);
|
|
6479
|
-
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Authorize ThumbGate</title>
|
|
6480
|
-
<style>body{font:15px system-ui;margin:0;background:#0b0b0c;color:#eee;display:flex;min-height:100vh;align-items:center;justify-content:center}
|
|
6481
|
-
.card{background:#161618;border:1px solid #2a2a2e;border-radius:12px;padding:28px;max-width:420px}
|
|
6482
|
-
input[type=password]{width:100%;padding:10px;margin:8px 0 16px;border-radius:8px;border:1px solid #2a2a2e;background:#0b0b0c;color:#eee}
|
|
6483
|
-
button{width:100%;padding:11px;border-radius:8px;border:0;background:#10b981;color:#04120c;font-weight:600;cursor:pointer}
|
|
6484
|
-
a{color:#8b9}</style></head><body><form class="card" method="post" action="/oauth/authorize">
|
|
6485
|
-
<h2>Authorize Claude → ThumbGate</h2>
|
|
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>
|
|
6487
|
-
<input type="hidden" name="auth_request_token" value="${escapeHtmlAttribute(authRequestToken)}">
|
|
6488
|
-
<input type="password" name="api_key" placeholder="ThumbGate API key" autocomplete="off" required>
|
|
6489
|
-
<button type="submit" name="approve" value="yes">Approve</button>
|
|
6490
|
-
</form></body></html>`;
|
|
6491
|
-
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
6492
|
-
return;
|
|
6493
|
-
}
|
|
6494
|
-
if (req.method === 'POST') {
|
|
6495
|
-
let body = '';
|
|
6496
|
-
req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
|
|
6497
|
-
req.on('end', () => {
|
|
6498
|
-
const form = new URLSearchParams(body);
|
|
6499
|
-
const authorizationParams = getOauthAuthorizeParamsFromForm(form, hostedConfig);
|
|
6500
|
-
const redirectUri = authorizationParams.redirectUri;
|
|
6501
|
-
const state = authorizationParams.state;
|
|
6502
|
-
// Validate the presented ThumbGate key before issuing a code. When keys
|
|
6503
|
-
// are configured (production) the key MUST match a configured admin /
|
|
6504
|
-
// operator / reviewer key — otherwise OAuth would authenticate nobody.
|
|
6505
|
-
// In insecure/dev mode (no keys configured) any non-empty key is accepted.
|
|
6506
|
-
if (!resolveKeyRole(form.get('api_key') || '')) {
|
|
6507
|
-
sendJson(res, 400, { error: 'access_denied', error_description: 'invalid ThumbGate API key' });
|
|
6508
|
-
return;
|
|
6509
|
-
}
|
|
6510
|
-
const issued = mcpOauth.createAuthorizationCode(oauthStore, {
|
|
6511
|
-
clientId: authorizationParams.clientId,
|
|
6512
|
-
redirectUri,
|
|
6513
|
-
codeChallenge: authorizationParams.codeChallenge,
|
|
6514
|
-
codeChallengeMethod: authorizationParams.codeChallengeMethod,
|
|
6515
|
-
scope: authorizationParams.scope,
|
|
6516
|
-
resource: authorizationParams.resource || buildPublicUrl(hostedConfig, '/mcp'),
|
|
6517
|
-
boundKey: form.get('api_key') || '',
|
|
6518
|
-
state,
|
|
6519
|
-
});
|
|
6520
|
-
if (issued.error) {
|
|
6521
|
-
sendJson(res, 400, { error: issued.error, error_description: issued.error_description });
|
|
6522
|
-
return;
|
|
6523
|
-
}
|
|
6524
|
-
const sep = redirectUri.includes('?') ? '&' : '?';
|
|
6525
|
-
const loc = `${redirectUri}${sep}code=${encodeURIComponent(issued.code)}${state ? `&state=${encodeURIComponent(state)}` : ''}`;
|
|
6526
|
-
res.writeHead(302, { Location: loc });
|
|
6527
|
-
res.end();
|
|
6528
|
-
});
|
|
6529
|
-
return;
|
|
6530
|
-
}
|
|
6531
|
-
}
|
|
6532
|
-
// Token endpoint (authorization_code + PKCE).
|
|
6533
|
-
if (req.method === 'POST' && pathname === '/oauth/token') {
|
|
6534
|
-
let body = '';
|
|
6535
|
-
req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
|
|
6536
|
-
req.on('end', () => {
|
|
6537
|
-
const form = new URLSearchParams(body);
|
|
6538
|
-
if (form.get('grant_type') !== 'authorization_code') {
|
|
6539
|
-
sendJson(res, 400, { error: 'unsupported_grant_type' });
|
|
6540
|
-
return;
|
|
6541
|
-
}
|
|
6542
|
-
const tok = mcpOauth.exchangeCode(oauthStore, {
|
|
6543
|
-
code: form.get('code') || '',
|
|
6544
|
-
codeVerifier: form.get('code_verifier') || '',
|
|
6545
|
-
clientId: form.get('client_id') || '',
|
|
6546
|
-
redirectUri: form.get('redirect_uri') || '',
|
|
6547
|
-
resource: form.get('resource') || undefined,
|
|
6548
|
-
});
|
|
6549
|
-
if (tok.error) { sendJson(res, 400, tok); return; }
|
|
6550
|
-
sendJson(res, 200, tok, { 'Cache-Control': 'no-store' });
|
|
6551
|
-
});
|
|
6552
|
-
return;
|
|
6553
|
-
}
|
|
6554
|
-
|
|
6555
4907
|
if (isGetLikeRequest && pathname === '/.well-known/mcp/tools.json') {
|
|
6556
4908
|
sendJson(res, 200, {
|
|
6557
4909
|
name: 'thumbgate',
|
|
@@ -6664,57 +5016,40 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
6664
5016
|
// was down, when feedback-dir was unwritable, when env was misconfigured.
|
|
6665
5017
|
// The fix is shallow but meaningful: probe each critical subsystem and
|
|
6666
5018
|
// surface failures with HTTP 503 + a per-check breakdown.
|
|
6667
|
-
// Tiered failure classification — not all degradations should kill the
|
|
6668
|
-
// container. A *service-failing* check (feedback dir unwritable,
|
|
6669
|
-
// appOrigin missing) returns 503 → Railway's healthcheck fails → SIGTERM
|
|
6670
|
-
// → restart loop → outage (this exact failure mode took prod down
|
|
6671
|
-
// 2026-05-21 18:21Z → 19:30Z when BUILD_METADATA.buildSha came up empty
|
|
6672
|
-
// after a Railway env-var cleanup). A *telemetry-degraded* check (missing
|
|
6673
|
-
// buildSha) returns 200 with `degraded: true` so observability stays
|
|
6674
|
-
// visible but Railway doesn't kill an otherwise-healthy container over
|
|
6675
|
-
// a SHA gap.
|
|
6676
5019
|
const checks = {};
|
|
6677
|
-
let
|
|
6678
|
-
let degraded = false; // any telemetry-degraded check → 200 + flag
|
|
5020
|
+
let allOk = true;
|
|
6679
5021
|
|
|
6680
5022
|
// Check 1: feedback dir exists and is writable.
|
|
6681
|
-
// SERVICE-FAILING — if we can't write feedback, the API can't function.
|
|
6682
5023
|
try {
|
|
6683
5024
|
const { FEEDBACK_DIR } = getFeedbackPaths();
|
|
6684
5025
|
fs.accessSync(FEEDBACK_DIR, fs.constants.W_OK);
|
|
6685
5026
|
checks.feedbackDir = { ok: true };
|
|
6686
5027
|
} catch (err) {
|
|
6687
|
-
checks.feedbackDir = { ok: false, error: err?.code || 'inaccessible'
|
|
6688
|
-
|
|
5028
|
+
checks.feedbackDir = { ok: false, error: err?.code || 'inaccessible' };
|
|
5029
|
+
allOk = false;
|
|
6689
5030
|
}
|
|
6690
5031
|
|
|
6691
5032
|
// Check 2: hosted config resolves the canonical app origin.
|
|
6692
|
-
//
|
|
6693
|
-
// flow break silently.
|
|
5033
|
+
// If appOrigin is missing/empty, redirects + checkout flow break silently.
|
|
6694
5034
|
if (hostedConfig?.appOrigin) {
|
|
6695
5035
|
checks.hostedConfig = { ok: true };
|
|
6696
5036
|
} else {
|
|
6697
|
-
checks.hostedConfig = { ok: false, error: 'missing_appOrigin'
|
|
6698
|
-
|
|
5037
|
+
checks.hostedConfig = { ok: false, error: 'missing_appOrigin' };
|
|
5038
|
+
allOk = false;
|
|
6699
5039
|
}
|
|
6700
5040
|
|
|
6701
|
-
// Check 3: build metadata loaded.
|
|
6702
|
-
//
|
|
6703
|
-
// container still serves requests fine; we just can't tag responses with
|
|
6704
|
-
// the deployed SHA. Surfaces the gap to monitors via `degraded: true`
|
|
6705
|
-
// without triggering Railway's SIGTERM-on-503 loop.
|
|
5041
|
+
// Check 3: build metadata loaded. If BUILD_METADATA.buildSha is empty,
|
|
5042
|
+
// Railway didn't inject the deploy SHA — observability is degraded.
|
|
6706
5043
|
if (BUILD_METADATA?.buildSha) {
|
|
6707
5044
|
checks.buildMetadata = { ok: true };
|
|
6708
5045
|
} else {
|
|
6709
|
-
checks.buildMetadata = { ok: false, error: 'missing_buildSha'
|
|
6710
|
-
|
|
5046
|
+
checks.buildMetadata = { ok: false, error: 'missing_buildSha' };
|
|
5047
|
+
allOk = false;
|
|
6711
5048
|
}
|
|
6712
5049
|
|
|
6713
|
-
const statusCode =
|
|
6714
|
-
const status = failing ? 'failing' : (degraded ? 'degraded' : 'ok');
|
|
5050
|
+
const statusCode = allOk ? 200 : 503;
|
|
6715
5051
|
sendJson(res, statusCode, {
|
|
6716
|
-
status,
|
|
6717
|
-
degraded: degraded || failing,
|
|
5052
|
+
status: allOk ? 'ok' : 'degraded',
|
|
6718
5053
|
version: pkg.version,
|
|
6719
5054
|
buildSha: BUILD_METADATA.buildSha,
|
|
6720
5055
|
uptime: process.uptime(),
|
|
@@ -6784,7 +5119,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
6784
5119
|
evidence: [err?.message ? err.message : 'unknown_error'],
|
|
6785
5120
|
},
|
|
6786
5121
|
});
|
|
6787
|
-
} catch {
|
|
5122
|
+
} catch (_) {
|
|
6788
5123
|
// Telemetry is best-effort and must never fail the caller.
|
|
6789
5124
|
}
|
|
6790
5125
|
}
|
|
@@ -6812,25 +5147,15 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
6812
5147
|
const bd = require('../../scripts/bot-detector');
|
|
6813
5148
|
const { FEEDBACK_DIR: metricsDir } = getFeedbackPaths();
|
|
6814
5149
|
const telemetryPath = path.join(metricsDir, 'telemetry-pings.jsonl');
|
|
6815
|
-
|
|
6816
|
-
|
|
6817
|
-
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
|
|
6822
|
-
|
|
6823
|
-
|
|
6824
|
-
try {
|
|
6825
|
-
if (fs.existsSync(telemetryPath)) {
|
|
6826
|
-
entries = fs.readFileSync(telemetryPath, 'utf8')
|
|
6827
|
-
.split('\n').filter(Boolean)
|
|
6828
|
-
.map(l => { try { return JSON.parse(l); } catch(_e) { return null; } })
|
|
6829
|
-
.filter(Boolean);
|
|
6830
|
-
}
|
|
6831
|
-
} catch { entries = []; }
|
|
6832
|
-
global[cacheKey] = { entries, ts: Date.now() };
|
|
6833
|
-
}
|
|
5150
|
+
let entries = [];
|
|
5151
|
+
try {
|
|
5152
|
+
if (fs.existsSync(telemetryPath)) {
|
|
5153
|
+
entries = fs.readFileSync(telemetryPath, 'utf8')
|
|
5154
|
+
.split('\n').filter(Boolean)
|
|
5155
|
+
.map(l => { try { return JSON.parse(l); } catch(_e) { return null; } })
|
|
5156
|
+
.filter(Boolean);
|
|
5157
|
+
}
|
|
5158
|
+
} catch { entries = []; }
|
|
6834
5159
|
|
|
6835
5160
|
const now = Date.now();
|
|
6836
5161
|
const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000;
|
|
@@ -6859,52 +5184,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
6859
5184
|
byVisitorType[e.visitorType] = (byVisitorType[e.visitorType] || 0) + 1;
|
|
6860
5185
|
});
|
|
6861
5186
|
|
|
6862
|
-
// ---------------------------------------------------------------
|
|
6863
|
-
// Active user analytics: group by installId to distinguish real
|
|
6864
|
-
// users from bots/mirrors. "Active" = performed a meaningful CLI
|
|
6865
|
-
// action (capture, recall, search, gate-check, stats) — not just init.
|
|
6866
|
-
// ---------------------------------------------------------------
|
|
6867
|
-
// Only events that prove a human used the product beyond init.
|
|
6868
|
-
// cli_pro_view excluded: browsing the upgrade page is not "using" the product.
|
|
6869
|
-
// cli_gate_check excluded: runs as subprocess, never fires trackEvent().
|
|
6870
|
-
// cli_search excluded: event name doesn't exist in production telemetry.
|
|
6871
|
-
const ACTIVE_EVENTS = new Set([
|
|
6872
|
-
'cli_capture', 'cli_recall', 'cli_stats',
|
|
6873
|
-
'activation_first_rule_promoted',
|
|
6874
|
-
]);
|
|
6875
|
-
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
|
|
6876
|
-
const last30 = classified.filter(e => {
|
|
6877
|
-
const ts = e.timestamp || e.receivedAt;
|
|
6878
|
-
return ts && new Date(ts).getTime() > thirtyDaysAgo;
|
|
6879
|
-
});
|
|
6880
|
-
|
|
6881
|
-
// Per-install activity (all-time and recent windows)
|
|
6882
|
-
const activeInstalls7d = new Set();
|
|
6883
|
-
const activeInstalls30d = new Set();
|
|
6884
|
-
const allTimeActiveInstalls = new Set();
|
|
6885
|
-
const uniqueSessions7d = new Set();
|
|
6886
|
-
const uniqueSessions30d = new Set();
|
|
6887
|
-
|
|
6888
|
-
for (const e of classified) {
|
|
6889
|
-
if (!e.installId) continue;
|
|
6890
|
-
const et = e.eventType || '';
|
|
6891
|
-
if (!ACTIVE_EVENTS.has(et)) continue;
|
|
6892
|
-
if (e.visitorType === 'bot' || e.visitorType === 'ci') continue;
|
|
6893
|
-
allTimeActiveInstalls.add(e.installId);
|
|
6894
|
-
}
|
|
6895
|
-
for (const e of recent) {
|
|
6896
|
-
if (!e.installId) continue;
|
|
6897
|
-
if (e.visitorType === 'bot' || e.visitorType === 'ci') continue;
|
|
6898
|
-
if (ACTIVE_EVENTS.has(e.eventType || '')) activeInstalls7d.add(e.installId);
|
|
6899
|
-
if (e.sessionId) uniqueSessions7d.add(e.sessionId);
|
|
6900
|
-
}
|
|
6901
|
-
for (const e of last30) {
|
|
6902
|
-
if (!e.installId) continue;
|
|
6903
|
-
if (e.visitorType === 'bot' || e.visitorType === 'ci') continue;
|
|
6904
|
-
if (ACTIVE_EVENTS.has(e.eventType || '')) activeInstalls30d.add(e.installId);
|
|
6905
|
-
if (e.sessionId) uniqueSessions30d.add(e.sessionId);
|
|
6906
|
-
}
|
|
6907
|
-
|
|
6908
5187
|
sendJson(res, 200, {
|
|
6909
5188
|
allTime: {
|
|
6910
5189
|
total: classified.length,
|
|
@@ -6913,12 +5192,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
6913
5192
|
owner: classified.filter(e => e.visitorType === 'owner').length,
|
|
6914
5193
|
ci: classified.filter(e => e.visitorType === 'ci').length,
|
|
6915
5194
|
uniqueInstalls: uniqueInstallIds.size,
|
|
6916
|
-
activeInstalls: allTimeActiveInstalls.size,
|
|
6917
|
-
},
|
|
6918
|
-
last30Days: {
|
|
6919
|
-
uniqueInstalls: new Set(last30.filter(e => e.installId).map(e => e.installId)).size,
|
|
6920
|
-
activeInstalls: activeInstalls30d.size,
|
|
6921
|
-
uniqueSessions: uniqueSessions30d.size,
|
|
6922
5195
|
},
|
|
6923
5196
|
last7Days: {
|
|
6924
5197
|
total: recent.length,
|
|
@@ -6927,8 +5200,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
6927
5200
|
owner: recent.filter(e => e.visitorType === 'owner').length,
|
|
6928
5201
|
ci: recent.filter(e => e.visitorType === 'ci').length,
|
|
6929
5202
|
uniqueInstalls: recentInstallIds.size,
|
|
6930
|
-
activeInstalls: activeInstalls7d.size,
|
|
6931
|
-
uniqueSessions: uniqueSessions7d.size,
|
|
6932
5203
|
},
|
|
6933
5204
|
byEventType,
|
|
6934
5205
|
byVisitorType,
|
|
@@ -6993,7 +5264,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
6993
5264
|
source,
|
|
6994
5265
|
telemetry: { rows: [], truncated: false, totalAfterSince: 0 },
|
|
6995
5266
|
funnel: { rows: [], truncated: false, totalAfterSince: 0 },
|
|
6996
|
-
journeySummary: null,
|
|
6997
5267
|
};
|
|
6998
5268
|
|
|
6999
5269
|
function readJsonlSince(p) {
|
|
@@ -7019,177 +5289,29 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
7019
5289
|
const tp = path.join(exportDir, 'telemetry-pings.jsonl');
|
|
7020
5290
|
const all = readJsonlSince(tp);
|
|
7021
5291
|
result.telemetry.totalAfterSince = all.length;
|
|
7022
|
-
result.telemetry.rows = all.slice(-limit);
|
|
7023
|
-
result.telemetry.truncated = all.length > limit;
|
|
7024
|
-
}
|
|
7025
|
-
|
|
7026
|
-
if (wantFunnel) {
|
|
7027
|
-
// Use the canonical funnel ledger path from scripts/billing.js so
|
|
7028
|
-
// any test override (THUMBGATE_FUNNEL_LEDGER_PATH / _TEST_FUNNEL_LEDGER_PATH)
|
|
7029
|
-
// resolves correctly. Falls back to the feedback dir's funnel-events.jsonl.
|
|
7030
|
-
let funnelPath;
|
|
7031
|
-
try {
|
|
7032
|
-
const billing = require('../../scripts/billing');
|
|
7033
|
-
funnelPath = (billing._FUNNEL_LEDGER_PATH && billing._FUNNEL_LEDGER_PATH())
|
|
7034
|
-
|| path.join(exportDir, 'funnel-events.jsonl');
|
|
7035
|
-
} catch {
|
|
7036
|
-
funnelPath = path.join(exportDir, 'funnel-events.jsonl');
|
|
7037
|
-
}
|
|
7038
|
-
const all = readJsonlSince(funnelPath);
|
|
7039
|
-
result.funnel.totalAfterSince = all.length;
|
|
7040
|
-
result.funnel.rows = all.slice(-limit);
|
|
7041
|
-
result.funnel.truncated = all.length > limit;
|
|
7042
|
-
}
|
|
7043
|
-
|
|
7044
|
-
try {
|
|
7045
|
-
const { buildVisitorJourneySummary } = require('../../scripts/visitor-journey');
|
|
7046
|
-
result.journeySummary = buildVisitorJourneySummary({
|
|
7047
|
-
telemetryRows: wantTelemetry ? result.telemetry.rows : [],
|
|
7048
|
-
funnelRows: wantFunnel ? result.funnel.rows : [],
|
|
7049
|
-
limit: Math.min(limit, 500),
|
|
7050
|
-
});
|
|
7051
|
-
} catch (err) {
|
|
7052
|
-
result.journeySummary = {
|
|
7053
|
-
error: err && err.message ? err.message : 'journey_summary_unavailable',
|
|
7054
|
-
};
|
|
7055
|
-
}
|
|
7056
|
-
|
|
7057
|
-
sendJson(res, 200, result);
|
|
7058
|
-
return;
|
|
7059
|
-
}
|
|
7060
|
-
|
|
7061
|
-
if (req.method === 'OPTIONS' && pathname === '/v1/marketing/install-email') {
|
|
7062
|
-
// CORS preflight for the npm postinstall email-capture endpoint.
|
|
7063
|
-
// The endpoint is called from `npx thumbgate subscribe <email>` so
|
|
7064
|
-
// the CLI gets the right CORS headers if it ever hits the proxy
|
|
7065
|
-
// path; in practice CLI hits the server directly so this is mostly
|
|
7066
|
-
// defense-in-depth.
|
|
7067
|
-
sendPublicBillingPreflight(res);
|
|
7068
|
-
return;
|
|
7069
|
-
}
|
|
7070
|
-
|
|
7071
|
-
if (req.method === 'POST' && pathname === '/v1/marketing/install-email') {
|
|
7072
|
-
// Email-capture wedge for npm installers. The `subscribe` CLI
|
|
7073
|
-
// subcommand POSTs { email, source, installId, cliVersion } here;
|
|
7074
|
-
// we validate the email, persist it to a dedicated capture ledger
|
|
7075
|
-
// (telemetry sanitizer would strip the email as PII), emit a
|
|
7076
|
-
// privacy-clean telemetry ping for funnel attribution, and fire
|
|
7077
|
-
// a Resend newsletter welcome (if RESEND_API_KEY is configured).
|
|
7078
|
-
// No auth required — this is a public opt-in surface.
|
|
7079
|
-
const { FEEDBACK_DIR } = getFeedbackPaths();
|
|
7080
|
-
let body = '';
|
|
7081
|
-
let total = 0;
|
|
7082
|
-
let oversize = false;
|
|
7083
|
-
const MAX_BODY = 2048;
|
|
7084
|
-
req.on('data', (chunk) => {
|
|
7085
|
-
total += chunk.length;
|
|
7086
|
-
if (total > MAX_BODY) {
|
|
7087
|
-
oversize = true;
|
|
7088
|
-
return; // stop appending; let 'end' fire naturally to send 413
|
|
7089
|
-
}
|
|
7090
|
-
body += chunk;
|
|
7091
|
-
});
|
|
7092
|
-
req.on('end', async () => {
|
|
7093
|
-
if (oversize) {
|
|
7094
|
-
sendJson(res, 413, { error: 'payload_too_large' });
|
|
7095
|
-
return;
|
|
7096
|
-
}
|
|
7097
|
-
let parsed;
|
|
7098
|
-
try {
|
|
7099
|
-
parsed = body ? JSON.parse(body) : {};
|
|
7100
|
-
} catch {
|
|
7101
|
-
sendJson(res, 400, { error: 'invalid_json' });
|
|
7102
|
-
return;
|
|
7103
|
-
}
|
|
7104
|
-
const rawEmail = typeof parsed.email === 'string' ? parsed.email.trim() : '';
|
|
7105
|
-
// RFC 5321-bounded email shape — same regex shape as the CLI side
|
|
7106
|
-
// to keep the contract honest.
|
|
7107
|
-
const emailValid = /^[^\s@]{1,64}@[^\s@]{1,255}\.[^\s@]{1,64}$/.test(rawEmail);
|
|
7108
|
-
if (!emailValid) {
|
|
7109
|
-
sendJson(res, 400, { error: 'invalid_email' });
|
|
7110
|
-
return;
|
|
7111
|
-
}
|
|
7112
|
-
const source = typeof parsed.source === 'string' && parsed.source.length <= 64
|
|
7113
|
-
? parsed.source
|
|
7114
|
-
: 'cli_subscribe';
|
|
7115
|
-
const installId = typeof parsed.installId === 'string' && parsed.installId.length <= 128
|
|
7116
|
-
? parsed.installId
|
|
7117
|
-
: null;
|
|
7118
|
-
const cliVersion = typeof parsed.cliVersion === 'string' && parsed.cliVersion.length <= 32
|
|
7119
|
-
? parsed.cliVersion
|
|
7120
|
-
: null;
|
|
7121
|
-
|
|
7122
|
-
// Persist the capture to a dedicated ledger. The standard
|
|
7123
|
-
// telemetry sanitizer in scripts/telemetry-analytics.js
|
|
7124
|
-
// intentionally strips arbitrary fields (PII protection), so
|
|
7125
|
-
// we cannot rely on appendBestEffortTelemetry to preserve the
|
|
7126
|
-
// email. The capture ledger lives alongside other feedback
|
|
7127
|
-
// artifacts and is the source of truth for the marketing drip.
|
|
7128
|
-
try {
|
|
7129
|
-
const fsModule = require('node:fs');
|
|
7130
|
-
const pathModule = require('node:path');
|
|
7131
|
-
const captureDir = pathModule.resolve(FEEDBACK_DIR);
|
|
7132
|
-
fsModule.mkdirSync(captureDir, { recursive: true });
|
|
7133
|
-
const capturePath = pathModule.join(captureDir, 'marketing-install-emails.jsonl');
|
|
7134
|
-
fsModule.appendFileSync(capturePath, JSON.stringify({
|
|
7135
|
-
capturedAt: new Date().toISOString(),
|
|
7136
|
-
email: rawEmail,
|
|
7137
|
-
source,
|
|
7138
|
-
installId,
|
|
7139
|
-
cliVersion,
|
|
7140
|
-
remoteAddr: req.socket?.remoteAddress || null,
|
|
7141
|
-
userAgent: req.headers['user-agent'] || null,
|
|
7142
|
-
}) + '\n', 'utf-8');
|
|
7143
|
-
} catch (err) {
|
|
7144
|
-
// Capture failure is recoverable — we still want to fire the
|
|
7145
|
-
// welcome email and surface the failure to ops via diagnostic.
|
|
7146
|
-
try {
|
|
7147
|
-
const { appendDiagnosticRecord } = require('../../scripts/feedback-loop');
|
|
7148
|
-
appendDiagnosticRecord({
|
|
7149
|
-
source: 'install_email_capture',
|
|
7150
|
-
step: 'capture_persist',
|
|
7151
|
-
context: 'failed to persist install-email capture to ledger',
|
|
7152
|
-
metadata: { error: err?.message || 'unknown' },
|
|
7153
|
-
});
|
|
7154
|
-
} catch {}
|
|
7155
|
-
}
|
|
5292
|
+
result.telemetry.rows = all.slice(-limit);
|
|
5293
|
+
result.telemetry.truncated = all.length > limit;
|
|
5294
|
+
}
|
|
7156
5295
|
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
installId,
|
|
7163
|
-
cliVersion,
|
|
7164
|
-
utmSource: source,
|
|
7165
|
-
utmMedium: 'npm_postinstall',
|
|
7166
|
-
utmCampaign: 'install_email_capture',
|
|
7167
|
-
}, req.headers, 'marketing_install_email_captured');
|
|
7168
|
-
|
|
7169
|
-
// Fire Resend welcome. If RESEND_API_KEY is unset the mailer
|
|
7170
|
-
// returns { sent: false, reason: 'no_api_key' } and the
|
|
7171
|
-
// capture still succeeds — the operator can drip later from
|
|
7172
|
-
// the captured ledger.
|
|
7173
|
-
let mailerResult = { sent: false, reason: 'not_attempted' };
|
|
5296
|
+
if (wantFunnel) {
|
|
5297
|
+
// Use the canonical funnel ledger path from scripts/billing.js so
|
|
5298
|
+
// any test override (THUMBGATE_FUNNEL_LEDGER_PATH / _TEST_FUNNEL_LEDGER_PATH)
|
|
5299
|
+
// resolves correctly. Falls back to the feedback dir's funnel-events.jsonl.
|
|
5300
|
+
let funnelPath;
|
|
7174
5301
|
try {
|
|
7175
|
-
const
|
|
7176
|
-
|
|
7177
|
-
|
|
7178
|
-
|
|
7179
|
-
|
|
7180
|
-
cliVersion,
|
|
7181
|
-
});
|
|
7182
|
-
} catch (err) {
|
|
7183
|
-
mailerResult = { sent: false, reason: `mailer_error:${err.message || 'unknown'}` };
|
|
5302
|
+
const billing = require('../../scripts/billing');
|
|
5303
|
+
funnelPath = (billing._FUNNEL_LEDGER_PATH && billing._FUNNEL_LEDGER_PATH())
|
|
5304
|
+
|| path.join(exportDir, 'funnel-events.jsonl');
|
|
5305
|
+
} catch {
|
|
5306
|
+
funnelPath = path.join(exportDir, 'funnel-events.jsonl');
|
|
7184
5307
|
}
|
|
5308
|
+
const all = readJsonlSince(funnelPath);
|
|
5309
|
+
result.funnel.totalAfterSince = all.length;
|
|
5310
|
+
result.funnel.rows = all.slice(-limit);
|
|
5311
|
+
result.funnel.truncated = all.length > limit;
|
|
5312
|
+
}
|
|
7185
5313
|
|
|
7186
|
-
|
|
7187
|
-
ok: true,
|
|
7188
|
-
captured: true,
|
|
7189
|
-
mailerSent: !!mailerResult.sent,
|
|
7190
|
-
mailerReason: mailerResult.reason || null,
|
|
7191
|
-
});
|
|
7192
|
-
});
|
|
5314
|
+
sendJson(res, 200, result);
|
|
7193
5315
|
return;
|
|
7194
5316
|
}
|
|
7195
5317
|
|
|
@@ -7198,130 +5320,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
7198
5320
|
return;
|
|
7199
5321
|
}
|
|
7200
5322
|
|
|
7201
|
-
if (req.method === 'OPTIONS' && pathname === '/v1/intake/broker-audit') {
|
|
7202
|
-
sendPublicBillingPreflight(res);
|
|
7203
|
-
return;
|
|
7204
|
-
}
|
|
7205
|
-
|
|
7206
|
-
if (req.method === 'POST' && pathname === '/v1/intake/broker-audit') {
|
|
7207
|
-
const { FEEDBACK_DIR } = getFeedbackPaths();
|
|
7208
|
-
const traceId = createTraceId('broker_audit');
|
|
7209
|
-
const journeyState = resolveJourneyState(req, parsed);
|
|
7210
|
-
const referrerAttribution = buildReferrerAttribution(req);
|
|
7211
|
-
const contentType = String(req.headers['content-type'] || '').toLowerCase();
|
|
7212
|
-
const isFormSubmission = contentType.includes('application/x-www-form-urlencoded');
|
|
7213
|
-
try {
|
|
7214
|
-
const body = isFormSubmission
|
|
7215
|
-
? await parseFormBody(req, 16 * 1024)
|
|
7216
|
-
: await parseJsonBody(req, 16 * 1024);
|
|
7217
|
-
const lead = appendBrokerAuditLead(FEEDBACK_DIR, {
|
|
7218
|
-
...body,
|
|
7219
|
-
traceId: body.traceId || traceId,
|
|
7220
|
-
acquisitionId: body.acquisitionId || journeyState.acquisitionId,
|
|
7221
|
-
visitorId: body.visitorId || journeyState.visitorId,
|
|
7222
|
-
sessionId: body.sessionId || journeyState.sessionId,
|
|
7223
|
-
page: body.page || referrerAttribution.page || '/broker-audit',
|
|
7224
|
-
landingPath: body.landingPath || referrerAttribution.landingPath || '/broker-audit',
|
|
7225
|
-
ctaId: body.ctaId || 'broker_audit_form',
|
|
7226
|
-
ctaPlacement: body.ctaPlacement || 'broker_audit',
|
|
7227
|
-
source: body.source || body.utmSource || referrerAttribution.source || 'website',
|
|
7228
|
-
utmSource: body.utmSource || body.source || referrerAttribution.utmSource || 'website',
|
|
7229
|
-
utmMedium: body.utmMedium || referrerAttribution.utmMedium || 'broker_audit',
|
|
7230
|
-
utmCampaign: body.utmCampaign || referrerAttribution.utmCampaign || 'broker_audit_free',
|
|
7231
|
-
utmContent: body.utmContent || referrerAttribution.utmContent || null,
|
|
7232
|
-
utmTerm: body.utmTerm || referrerAttribution.utmTerm || null,
|
|
7233
|
-
referrerHost: body.referrerHost || referrerAttribution.referrerHost || null,
|
|
7234
|
-
referrer: body.referrer || referrerAttribution.referrer || null,
|
|
7235
|
-
});
|
|
7236
|
-
|
|
7237
|
-
appendBestEffortTelemetry(FEEDBACK_DIR, {
|
|
7238
|
-
eventType: 'broker_audit_lead_submitted',
|
|
7239
|
-
clientType: 'web',
|
|
7240
|
-
traceId: lead.attribution.traceId,
|
|
7241
|
-
acquisitionId: lead.attribution.acquisitionId,
|
|
7242
|
-
visitorId: lead.attribution.visitorId,
|
|
7243
|
-
sessionId: lead.attribution.sessionId,
|
|
7244
|
-
source: lead.attribution.source,
|
|
7245
|
-
utmSource: lead.attribution.utmSource,
|
|
7246
|
-
utmMedium: lead.attribution.utmMedium,
|
|
7247
|
-
utmCampaign: lead.attribution.utmCampaign,
|
|
7248
|
-
utmContent: lead.attribution.utmContent,
|
|
7249
|
-
utmTerm: lead.attribution.utmTerm,
|
|
7250
|
-
ctaId: lead.attribution.ctaId,
|
|
7251
|
-
ctaPlacement: lead.attribution.ctaPlacement,
|
|
7252
|
-
page: lead.attribution.page,
|
|
7253
|
-
landingPath: lead.attribution.landingPath,
|
|
7254
|
-
referrerHost: lead.attribution.referrerHost,
|
|
7255
|
-
referrer: lead.attribution.referrer,
|
|
7256
|
-
}, req.headers, 'broker_audit_lead_submitted');
|
|
7257
|
-
|
|
7258
|
-
if (isFormSubmission && !wantsJson(req, parsed)) {
|
|
7259
|
-
sendHtml(
|
|
7260
|
-
res,
|
|
7261
|
-
201,
|
|
7262
|
-
renderBrokerAuditIntakeResultPage(hostedConfig, {
|
|
7263
|
-
title: 'Broker audit request received',
|
|
7264
|
-
detail: 'Your free broker lead-flow audit request is captured. The next step is the 1-page PDF with the specific lead leaks.',
|
|
7265
|
-
leadId: lead.leadId,
|
|
7266
|
-
}),
|
|
7267
|
-
journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}
|
|
7268
|
-
);
|
|
7269
|
-
return;
|
|
7270
|
-
}
|
|
7271
|
-
|
|
7272
|
-
sendJson(res, 201, {
|
|
7273
|
-
ok: true,
|
|
7274
|
-
leadId: lead.leadId,
|
|
7275
|
-
status: lead.status,
|
|
7276
|
-
offer: lead.offer,
|
|
7277
|
-
nextStep: 'deliver_1_page_pdf',
|
|
7278
|
-
}, {
|
|
7279
|
-
...getPublicBillingHeaders(lead.attribution.traceId),
|
|
7280
|
-
...(journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}),
|
|
7281
|
-
});
|
|
7282
|
-
} catch (err) {
|
|
7283
|
-
appendBestEffortTelemetry(FEEDBACK_DIR, {
|
|
7284
|
-
eventType: 'broker_audit_lead_failed',
|
|
7285
|
-
clientType: 'web',
|
|
7286
|
-
traceId,
|
|
7287
|
-
acquisitionId: journeyState.acquisitionId,
|
|
7288
|
-
visitorId: journeyState.visitorId,
|
|
7289
|
-
sessionId: journeyState.sessionId,
|
|
7290
|
-
source: referrerAttribution.source || 'website',
|
|
7291
|
-
utmSource: referrerAttribution.utmSource || 'website',
|
|
7292
|
-
utmMedium: referrerAttribution.utmMedium || 'broker_audit',
|
|
7293
|
-
utmCampaign: referrerAttribution.utmCampaign || 'broker_audit_free',
|
|
7294
|
-
ctaId: 'broker_audit_form',
|
|
7295
|
-
ctaPlacement: 'broker_audit',
|
|
7296
|
-
page: referrerAttribution.page || '/broker-audit',
|
|
7297
|
-
landingPath: referrerAttribution.landingPath || '/broker-audit',
|
|
7298
|
-
referrerHost: referrerAttribution.referrerHost,
|
|
7299
|
-
referrer: referrerAttribution.referrer,
|
|
7300
|
-
failureCode: err?.message ? err.message : 'broker_audit_lead_failed',
|
|
7301
|
-
httpStatus: err && err.statusCode ? err.statusCode : null,
|
|
7302
|
-
}, req.headers, 'broker_audit_lead_failed');
|
|
7303
|
-
if (isFormSubmission && !wantsJson(req, parsed)) {
|
|
7304
|
-
sendHtml(
|
|
7305
|
-
res,
|
|
7306
|
-
err.statusCode || 500,
|
|
7307
|
-
renderBrokerAuditIntakeResultPage(hostedConfig, {
|
|
7308
|
-
title: 'Broker audit request failed',
|
|
7309
|
-
detail: err.message || 'Unable to capture broker audit request.',
|
|
7310
|
-
}),
|
|
7311
|
-
journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}
|
|
7312
|
-
);
|
|
7313
|
-
return;
|
|
7314
|
-
}
|
|
7315
|
-
sendProblem(res, {
|
|
7316
|
-
type: !err.statusCode || err.statusCode >= 500 ? PROBLEM_TYPES.INTERNAL : PROBLEM_TYPES.BAD_REQUEST,
|
|
7317
|
-
title: !err.statusCode || err.statusCode >= 500 ? 'Internal Server Error' : 'Request Error',
|
|
7318
|
-
status: err.statusCode || 500,
|
|
7319
|
-
detail: err.message || 'Unable to capture broker audit request.',
|
|
7320
|
-
}, getPublicBillingHeaders(traceId));
|
|
7321
|
-
}
|
|
7322
|
-
return;
|
|
7323
|
-
}
|
|
7324
|
-
|
|
7325
5323
|
if (req.method === 'POST' && pathname === '/v1/intake/workflow-sprint') {
|
|
7326
5324
|
const { FEEDBACK_DIR } = getFeedbackPaths();
|
|
7327
5325
|
const traceId = createTraceId('sprint_intake');
|
|
@@ -7469,19 +5467,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
7469
5467
|
|
|
7470
5468
|
// Public OpenAPI spec — no auth required (needed for ChatGPT GPT Store import)
|
|
7471
5469
|
if (isGetLikeRequest && (pathname === '/openapi.json' || pathname === '/openapi.yaml')) {
|
|
7472
|
-
const specPath =
|
|
7473
|
-
path.join(__dirname, '../../openapi/openapi.yaml'),
|
|
7474
|
-
path.join(__dirname, '../../adapters/chatgpt/openapi.yaml'),
|
|
7475
|
-
].find((candidate) => fs.existsSync(candidate));
|
|
7476
|
-
if (!specPath) {
|
|
7477
|
-
sendProblem(res, {
|
|
7478
|
-
type: PROBLEM_TYPES.NOT_FOUND,
|
|
7479
|
-
title: 'Not Found',
|
|
7480
|
-
status: 404,
|
|
7481
|
-
detail: 'OpenAPI spec not found.',
|
|
7482
|
-
});
|
|
7483
|
-
return;
|
|
7484
|
-
}
|
|
5470
|
+
const specPath = path.join(__dirname, '../../adapters/chatgpt/openapi.yaml');
|
|
7485
5471
|
try {
|
|
7486
5472
|
const yaml = renderOpenApiYamlForRequest(fs.readFileSync(specPath, 'utf8'), req);
|
|
7487
5473
|
if (pathname === '/openapi.yaml') {
|
|
@@ -7557,14 +5543,38 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
7557
5543
|
// surface that was missing: thumbgate.ai had no /case-studies, so visitors
|
|
7558
5544
|
// landed on CLI install commands without seeing whether anyone actually
|
|
7559
5545
|
// got value. First entry is the Aiventyx Teams listing integration: real
|
|
7560
|
-
// third-party CTR signal (5/8 clicks before the /go/teams fix,
|
|
7561
|
-
//
|
|
5546
|
+
// third-party CTR signal (5/8 clicks before the /go/teams fix, end-to-end
|
|
5547
|
+
// verified after).
|
|
7562
5548
|
if (isGetLikeRequest && pathname === '/case-studies') {
|
|
7563
5549
|
sendHtml(res, 200, `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Case Studies — ThumbGate</title><meta name="description" content="Real integrations of ThumbGate's pre-action checks for AI coding agents. Proof, not promises."><style>body{font-family:system-ui,-apple-system,sans-serif;max-width:780px;margin:0 auto;padding:32px 20px;line-height:1.55;color:#1f2937}h1{font-size:32px;margin:0 0 8px}.lede{color:#6b7280;font-size:18px;margin:0 0 32px}article{border:1px solid #e5e7eb;border-radius:12px;padding:24px;margin-bottom:24px;background:#fff}article h2{margin:0 0 4px;font-size:22px}.meta{color:#6b7280;font-size:13px;margin-bottom:16px}h3{font-size:15px;margin:20px 0 8px;color:#374151;text-transform:uppercase;letter-spacing:0.5px}.metric{display:inline-block;background:#0f172a;color:#fff;padding:4px 10px;border-radius:6px;font-weight:600;font-size:14px;margin:0 4px 4px 0}p{margin:8px 0}a{color:#0066cc}code{background:#f3f4f6;padding:1px 6px;border-radius:4px;font-size:13.5px}footer{margin-top:48px;padding-top:24px;border-top:1px solid #e5e7eb;color:#6b7280;font-size:14px}</style></head><body>
|
|
7564
5550
|
<h1>Case Studies</h1>
|
|
7565
5551
|
<p class="lede">Real integrations. No fabricated logos, no aspirational numbers — every claim below is reproducible.</p>
|
|
7566
5552
|
|
|
7567
|
-
<
|
|
5553
|
+
<article>
|
|
5554
|
+
<h2>Aiventyx marketplace — Teams listing CTR recovery</h2>
|
|
5555
|
+
<p class="meta">Integration partner: <a href="https://www.aiventyx.com">Aiventyx</a> · Reported by: Qaiser Mehdi · Verified: 2026-05-13</p>
|
|
5556
|
+
|
|
5557
|
+
<h3>The problem</h3>
|
|
5558
|
+
<p>Aiventyx is a marketplace for AI tools. ThumbGate's Teams listing was their highest-CTR external surface — <span class="metric">62% CTR</span> (5 clicks on 8 views, May 7–9 window). When their integrator rolled out canonical tracked URLs, every Teams click started landing on:</p>
|
|
5559
|
+
<p><code>{"error":"Tracked link not found","allowed":["gpt","pro","install","reddit","linkedin","x","github"]}</code></p>
|
|
5560
|
+
<p>The <code>/go/teams</code> slug wasn't registered in our redirector — a 404 was eating every paid-intent click from their strongest external surface.</p>
|
|
5561
|
+
|
|
5562
|
+
<h3>The fix</h3>
|
|
5563
|
+
<p>Added <code>teams</code> to <code>TRACKED_LINK_TARGETS</code>: HTTP 302 redirect to <code>/checkout/pro?plan_id=team&seat_count=3&billing_cycle=monthly</code> — the 3-seat $147/mo self-serve Stripe Team checkout. Caller-supplied UTMs flow through to Stripe metadata end-to-end.</p>
|
|
5564
|
+
|
|
5565
|
+
<h3>The verification</h3>
|
|
5566
|
+
<p>Qaiser's own incognito test, May 13 6:04 AM (full email on record):</p>
|
|
5567
|
+
<p><code>https://thumbgate.ai/go/teams?utm_source=aiventyx</code><br>
|
|
5568
|
+
→ 302 to Stripe checkout<br>
|
|
5569
|
+
→ "Subscribe to ThumbGate Team" page loads<br>
|
|
5570
|
+
→ $147/mo, 3-seat Team plan confirmed<br>
|
|
5571
|
+
→ Aiventyx UTMs intact in URL</p>
|
|
5572
|
+
|
|
5573
|
+
<h3>What this proves</h3>
|
|
5574
|
+
<p>End-to-end attribution from a third-party marketplace through ThumbGate's redirector into Stripe checkout, with the caller's UTM chain preserved. Two regression tests pin the redirect contract so it can't silently break.</p>
|
|
5575
|
+
|
|
5576
|
+
<p><a href="/go/teams?utm_source=case-study">Try the live redirect →</a></p>
|
|
5577
|
+
</article>
|
|
7568
5578
|
|
|
7569
5579
|
<footer>
|
|
7570
5580
|
<p>Want to be the next case study? The product is real, the integration is 30 seconds: <code>npx thumbgate init</code>. If you ship something with ThumbGate and want it documented here, email <a href="mailto:igor.ganapolsky@gmail.com">igor.ganapolsky@gmail.com</a>.</p>
|
|
@@ -7583,66 +5593,87 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
7583
5593
|
// Sprint (proof-pack, sales-led) → Pro (self-serve recurring) → Team
|
|
7584
5594
|
// (after qualification). Every paid CTA across the site should funnel
|
|
7585
5595
|
// here OR directly into Stripe checkout — never a different price.
|
|
7586
|
-
if (isGetLikeRequest &&
|
|
7587
|
-
|
|
7588
|
-
|
|
7589
|
-
|
|
7590
|
-
|
|
7591
|
-
|
|
7592
|
-
|
|
7593
|
-
|
|
7594
|
-
|
|
7595
|
-
|
|
7596
|
-
|
|
7597
|
-
|
|
7598
|
-
|
|
7599
|
-
|
|
7600
|
-
|
|
7601
|
-
|
|
7602
|
-
|
|
7603
|
-
|
|
5596
|
+
if (isGetLikeRequest && pathname === '/pricing') {
|
|
5597
|
+
sendHtml(res, 200, `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Pricing — ThumbGate</title><meta name="description" content="ThumbGate pricing: free CLI, $19/mo Pro, $49/seat Team. One subscription decision, no consulting upsell."><style>body{font-family:system-ui,-apple-system,sans-serif;max-width:980px;margin:0 auto;padding:32px 20px;line-height:1.55;color:#1f2937}h1{font-size:36px;margin:0 0 8px;text-align:center}.lede{color:#6b7280;font-size:18px;margin:0 0 32px;text-align:center;max-width:560px;margin-left:auto;margin-right:auto}.grid{display:grid;grid-template-columns:1fr;gap:20px;margin-bottom:24px}@media(min-width:720px){.grid{grid-template-columns:repeat(3,1fr)}}.card{border:1px solid #e5e7eb;border-radius:12px;padding:24px;background:#fff;display:flex;flex-direction:column}.hero{border:2px solid #3b82f6;background:linear-gradient(135deg,#eff6ff,#fff);position:relative}.hero::before{content:"Most popular";position:absolute;top:-10px;left:50%;transform:translateX(-50%);background:#3b82f6;color:#fff;padding:3px 12px;border-radius:6px;font-size:11px;font-weight:700;letter-spacing:0.5px}.tag{display:inline-block;background:#0f172a;color:#fff;padding:3px 10px;border-radius:6px;font-size:12px;font-weight:600;letter-spacing:0.5px;margin-bottom:12px}.tag-free{background:#10b981}.tag-pro{background:#3b82f6}.tag-team{background:#8b5cf6}h2{margin:0 0 4px;font-size:22px}.price{font-size:30px;font-weight:700;margin:8px 0 12px}.price small{font-size:14px;color:#6b7280;font-weight:400}.tagline{color:#374151;margin:0 0 16px;font-size:15px}ul{margin:0 0 20px;padding-left:18px}li{margin:6px 0;font-size:14px}.cta{display:inline-block;background:#0f172a;color:#fff;padding:12px 24px;border-radius:8px;font-weight:600;text-decoration:none;text-align:center;margin-top:auto}.cta-primary{background:#3b82f6}.cta-secondary{background:#fff;color:#0f172a;border:1px solid #0f172a}.cta-free{background:#10b981}details.consulting{margin-top:32px;border:1px solid #e5e7eb;border-radius:12px;padding:18px 22px;background:#fafafa}details.consulting summary{cursor:pointer;font-size:15px;font-weight:600;color:#475569;list-style:none}details.consulting summary::-webkit-details-marker{display:none}details.consulting[open] summary{margin-bottom:16px}.consulting-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;margin:8px 0}.consulting-card{border:1px solid #e5e7eb;border-radius:8px;padding:14px;background:#fff}.consulting-card .cp{font-size:18px;font-weight:700;color:#0f172a}.consulting-card .cl{font-size:13px;color:#6b7280;margin:4px 0 10px}.consulting-card a{color:#0066cc;font-size:13px;text-decoration:none;font-weight:600}footer{margin-top:32px;padding-top:24px;border-top:1px solid #e5e7eb;color:#6b7280;font-size:14px;text-align:center}footer a{color:#0066cc}</style></head><body>
|
|
5598
|
+
<h1>Pricing</h1>
|
|
5599
|
+
<p class="lede">Three tiers. Pick the one that matches your scale. Self-serve checkout on every subscription plan — no calls.</p>
|
|
5600
|
+
|
|
5601
|
+
<div class="grid">
|
|
5602
|
+
|
|
5603
|
+
<div class="card">
|
|
5604
|
+
<span class="tag tag-free">Free — Solo developer</span>
|
|
5605
|
+
<h2>ThumbGate CLI</h2>
|
|
5606
|
+
<div class="price">$0 <small>forever</small></div>
|
|
5607
|
+
<p class="tagline">The pre-action gate layer for one developer. Block your first repeated AI mistake in 5 minutes.</p>
|
|
5608
|
+
<ul>
|
|
5609
|
+
<li>Unlimited feedback captures</li>
|
|
5610
|
+
<li>Up to 5 active prevention rules</li>
|
|
5611
|
+
<li>All MCP integrations (Claude Code, Cursor, Codex, Gemini, Amp)</li>
|
|
5612
|
+
<li>No account, no signup, no data leaves your machine</li>
|
|
5613
|
+
</ul>
|
|
5614
|
+
<a class="cta cta-free" href="/go/install?utm_source=pricing">Install free →</a>
|
|
5615
|
+
</div>
|
|
7604
5616
|
|
|
5617
|
+
<div class="card hero">
|
|
5618
|
+
<span class="tag tag-pro">Pro — Self-serve recurring</span>
|
|
5619
|
+
<h2>ThumbGate Pro</h2>
|
|
5620
|
+
<div class="price">$19 <small>/ month · $149 / year</small></div>
|
|
5621
|
+
<p class="tagline">For developers running multiple agents who hit the 5-rule wall. Unlimited rules, lesson recall, audit-ready evidence.</p>
|
|
5622
|
+
<ul>
|
|
5623
|
+
<li>Unlimited active prevention rules</li>
|
|
5624
|
+
<li>Local dashboard + lesson search across sessions</li>
|
|
5625
|
+
<li>DPO export for offline preference fine-tuning</li>
|
|
5626
|
+
<li>Visual check debugger — see every blocked action</li>
|
|
5627
|
+
<li>7-day refund window. Cancel anytime.</li>
|
|
5628
|
+
</ul>
|
|
5629
|
+
<a class="cta cta-primary" href="/go/pro?utm_source=pricing&utm_medium=hero_card">Start Pro — $19/mo →</a>
|
|
5630
|
+
</div>
|
|
7605
5631
|
|
|
7606
|
-
|
|
7607
|
-
|
|
7608
|
-
|
|
7609
|
-
|
|
7610
|
-
|
|
7611
|
-
const cardUrl = buildPublicUrl(hostedConfig, '/.well-known/mcp/server-card.json');
|
|
7612
|
-
const prmUrl = buildPublicUrl(hostedConfig, '/.well-known/oauth-protected-resource');
|
|
7613
|
-
sendHtml(res, 200, `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Remote MCP Connector — ThumbGate</title><meta name="description" content="Connect ThumbGate to Claude as a remote MCP server: OAuth 2.1 (PKCE) authorization, available tools, and the read-only reviewer credential."><style>body{font-family:system-ui,-apple-system,sans-serif;max-width:780px;margin:0 auto;padding:32px 20px;line-height:1.55;color:#1f2937}h1{font-size:30px;margin:0 0 8px}.lede{color:#6b7280;font-size:18px;margin:0 0 28px}h2{font-size:20px;margin:28px 0 8px}code{background:#f3f4f6;padding:1px 6px;border-radius:4px;font-size:13.5px}pre{background:#0f172a;color:#e2e8f0;padding:14px 16px;border-radius:8px;overflow:auto;font-size:13px}a{color:#0066cc}ol,ul{padding-left:22px}li{margin:6px 0}.note{border-left:3px solid #22d3ee;background:#ecfeff;padding:10px 14px;border-radius:0 8px 8px 0;margin:16px 0}footer{margin-top:40px;padding-top:20px;border-top:1px solid #e5e7eb;color:#6b7280;font-size:14px}</style></head><body>
|
|
7614
|
-
<h1>ThumbGate — Remote MCP Connector</h1>
|
|
7615
|
-
<p class="lede">ThumbGate is a remote Model Context Protocol (MCP) server. Add it as a connector in Claude to give an agent governed access to ThumbGate's feedback capture, lesson retrieval, context assembly, and pre-action gate tools — over HTTP, authenticated with OAuth 2.1.</p>
|
|
7616
|
-
|
|
7617
|
-
<h2>Connect URL</h2>
|
|
7618
|
-
<pre>${esc(mcpUrl)}</pre>
|
|
7619
|
-
<p>In Claude, add a custom connector and paste the URL above. Claude discovers the authorization server automatically via the protected-resource metadata at <a href="${esc(prmUrl)}">${esc(prmUrl)}</a>.</p>
|
|
7620
|
-
|
|
7621
|
-
<h2>Authorization (OAuth 2.1 + PKCE)</h2>
|
|
7622
|
-
<ol>
|
|
7623
|
-
<li>Claude reads the <code>resource</code> and <code>authorization_servers</code> from the protected-resource metadata.</li>
|
|
7624
|
-
<li>It runs the standard OAuth 2.1 authorization-code flow with PKCE (<code>S256</code> only — <code>plain</code> is rejected).</li>
|
|
7625
|
-
<li>The issued access token is audience-bound to the <code>${esc(mcpUrl)}</code> resource (RFC 8707); a token for any other audience is rejected.</li>
|
|
7626
|
-
<li>The bearer token is then sent on the <code>Authorization</code> header for every <code>tools/call</code>.</li>
|
|
7627
|
-
</ol>
|
|
7628
|
-
|
|
7629
|
-
<h2>Available tools</h2>
|
|
7630
|
-
<p>The full, machine-readable tool registry — names, input schemas, and the <code>readOnlyHint</code> / <code>destructiveHint</code> annotations — is published at <a href="${esc(cardUrl)}">${esc(cardUrl)}</a>. Tools fall into these groups:</p>
|
|
5632
|
+
<div class="card">
|
|
5633
|
+
<span class="tag tag-team">Team — Shared enforcement</span>
|
|
5634
|
+
<h2>ThumbGate Team</h2>
|
|
5635
|
+
<div class="price">$49 <small>/ seat / month · 3-seat min</small></div>
|
|
5636
|
+
<p class="tagline">For engineering teams with shared AI-agent workflows. One engineer's save protects the whole team.</p>
|
|
7631
5637
|
<ul>
|
|
7632
|
-
|
|
7633
|
-
|
|
7634
|
-
|
|
7635
|
-
|
|
5638
|
+
<li>Shared lesson database across all seats</li>
|
|
5639
|
+
<li>Org dashboard + audit-ready policy rollout</li>
|
|
5640
|
+
<li>Self-serve checkout starts at $147/mo</li>
|
|
5641
|
+
<li>Email support during pilot rollout</li>
|
|
7636
5642
|
</ul>
|
|
5643
|
+
<a class="cta cta-secondary" href="/go/teams?utm_source=pricing">Start Team →</a>
|
|
5644
|
+
</div>
|
|
7637
5645
|
|
|
7638
|
-
|
|
7639
|
-
<div class="note">For directory reviewers: ThumbGate issues a dedicated <strong>read-only</strong> reviewer credential. A token bound to that credential may invoke only tools annotated <code>readOnlyHint: true</code>; any write or mutating tool call is rejected. This makes the credential safe to share for review without granting the ability to mutate shared server state. Request it from the contact below.</div>
|
|
5646
|
+
</div>
|
|
7640
5647
|
|
|
7641
|
-
<
|
|
7642
|
-
<
|
|
5648
|
+
<details class="consulting">
|
|
5649
|
+
<summary>▸ Need a one-off diagnostic, hardening sprint, or governance setup? View paid services</summary>
|
|
5650
|
+
<p style="color:#6b7280;font-size:14px;margin:0 0 14px;">For teams that want a stakeholder-visible artifact (PR, briefing, deployed hooks) before subscribing. Each is one-time, fixed-scope, refundable if we can't extract a rule from your failure trace.</p>
|
|
5651
|
+
<div class="consulting-grid">
|
|
5652
|
+
<div class="consulting-card">
|
|
5653
|
+
<div class="cp">$499</div>
|
|
5654
|
+
<div class="cl">Sprint Diagnostic — 2-day triage on one workflow, top-5 rules ranked, PR delivered.</div>
|
|
5655
|
+
<a href="mailto:igor.ganapolsky@gmail.com?subject=ThumbGate%20Sprint%20Diagnostic">Email to start →</a>
|
|
5656
|
+
</div>
|
|
5657
|
+
<div class="consulting-card">
|
|
5658
|
+
<div class="cp">$1,500</div>
|
|
5659
|
+
<div class="cl">Workflow Hardening Sprint — full hardening engagement, hooks deployed, rollout review.</div>
|
|
5660
|
+
<a href="mailto:igor.ganapolsky@gmail.com?subject=ThumbGate%20Workflow%20Hardening%20Sprint">Email to start →</a>
|
|
5661
|
+
</div>
|
|
5662
|
+
<div class="consulting-card">
|
|
5663
|
+
<div class="cp">$97</div>
|
|
5664
|
+
<div class="cl">OpenClaw Agent Governance Kit — self-serve digital kit, prevention-rule starters, proof report template.</div>
|
|
5665
|
+
<a href="https://buy.stripe.com/bJe14naiE9Lo7xT49Z3sI12">Buy kit →</a>
|
|
5666
|
+
</div>
|
|
5667
|
+
</div>
|
|
5668
|
+
</details>
|
|
7643
5669
|
|
|
7644
|
-
<footer
|
|
7645
|
-
|
|
5670
|
+
<footer>
|
|
5671
|
+
<p>One source of truth for ThumbGate pricing. Numbers here override anything stale elsewhere on the site.</p>
|
|
5672
|
+
<p><a href="/">Home</a> · <a href="/case-studies">Case Studies</a> · <a href="/support">Support</a> · <a href="/privacy">Privacy</a> · <a href="/terms">Terms</a></p>
|
|
5673
|
+
</footer>
|
|
5674
|
+
</body></html>`, {}, {
|
|
5675
|
+
headOnly: isHeadRequest,
|
|
5676
|
+
});
|
|
7646
5677
|
return;
|
|
7647
5678
|
}
|
|
7648
5679
|
|
|
@@ -7885,14 +5916,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
7885
5916
|
return;
|
|
7886
5917
|
}
|
|
7887
5918
|
|
|
7888
|
-
// Catch non-API GET requests that didn't match any public page route above.
|
|
7889
|
-
// Without this, /about, /docs, /demo etc. fall through to the API auth gate
|
|
7890
|
-
// and return a raw JSON 401 instead of a user-friendly 404.
|
|
7891
|
-
if (isGetLikeRequest && !pathname.startsWith('/v1/') && !pathname.startsWith('/api/')) {
|
|
7892
|
-
sendHtml(res, 404, `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Page Not Found — ThumbGate</title><style>body{background:#0a0a0a;color:#e2e8f0;font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}main{text-align:center;padding:2rem}h1{font-size:4rem;margin:0;color:#22d3ee}p{font-size:1.1rem;color:#94a3b8;margin:1rem 0}a{color:#22d3ee;text-decoration:underline}</style></head><body><main><h1>404</h1><p>This page doesn't exist.</p><p><a href="/">Back to ThumbGate</a></p></main></body></html>`);
|
|
7893
|
-
return;
|
|
7894
|
-
}
|
|
7895
|
-
|
|
7896
5919
|
// Operator key is allowed to bypass the general admin gate for its dedicated endpoint
|
|
7897
5920
|
const _reqToken = extractApiKey(req);
|
|
7898
5921
|
const isOperatorBillingRequest = Boolean(expectedOperatorKey)
|
|
@@ -7918,161 +5941,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
7918
5941
|
|
|
7919
5942
|
try {
|
|
7920
5943
|
if (req.method === 'GET' && pathname === '/v1/feedback/stats') {
|
|
7921
|
-
|
|
7922
|
-
? computeAggregateFeedbackStats({ feedbackDir: requestFeedbackPaths.FEEDBACK_DIR })
|
|
7923
|
-
: analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH);
|
|
7924
|
-
try {
|
|
7925
|
-
const { getStatuslineMeta } = require('../../scripts/statusline-meta');
|
|
7926
|
-
const meta = getStatuslineMeta({ env: process.env });
|
|
7927
|
-
stats.tier = meta.tier;
|
|
7928
|
-
} catch (error) {
|
|
7929
|
-
debugApiFallback('statusline meta unavailable', error);
|
|
7930
|
-
stats.tier = 'Pro';
|
|
7931
|
-
}
|
|
7932
|
-
|
|
7933
|
-
const projectChatSettings = readProjectChatSettings(req, parsed);
|
|
7934
|
-
|
|
7935
|
-
stats.geminiConfigured = Boolean(
|
|
7936
|
-
projectChatSettings.geminiKey ||
|
|
7937
|
-
process.env.GEMINI_API_KEY ||
|
|
7938
|
-
process.env.THUMBGATE_GEMINI_API_KEY ||
|
|
7939
|
-
process.env.GOOGLE_API_KEY
|
|
7940
|
-
);
|
|
7941
|
-
stats.perplexityConfigured = Boolean(
|
|
7942
|
-
projectChatSettings.perplexityKey ||
|
|
7943
|
-
process.env.PERPLEXITY_API_KEY ||
|
|
7944
|
-
process.env.THUMBGATE_PERPLEXITY_API_KEY
|
|
7945
|
-
);
|
|
7946
|
-
stats.geminiValidatedAt = projectChatSettings.geminiValidatedAt;
|
|
7947
|
-
stats.geminiKeyStatus = 'none';
|
|
7948
|
-
if (projectChatSettings.geminiKey) {
|
|
7949
|
-
stats.geminiKeyStatus = 'present';
|
|
7950
|
-
}
|
|
7951
|
-
if (projectChatSettings.geminiValidatedAt) {
|
|
7952
|
-
stats.geminiKeyStatus = 'validated';
|
|
7953
|
-
}
|
|
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;
|
|
7958
|
-
sendJson(res, 200, stats);
|
|
7959
|
-
return;
|
|
7960
|
-
}
|
|
7961
|
-
|
|
7962
|
-
// Chat with your data — LOCAL-FIRST. Powers the dashboard "Chat with your
|
|
7963
|
-
// data" panel. Factual/metric questions (gates, blocks, feedback, token
|
|
7964
|
-
// savings, team) are answered DETERMINISTICALLY from this install's own
|
|
7965
|
-
// dashboard data — no cloud, no LLM, no API key (the local-first thesis).
|
|
7966
|
-
// Only open-ended/qualitative questions fall through to lesson retrieval +
|
|
7967
|
-
// the user's configured LOCAL model (a BYO cloud key is optional, not required).
|
|
7968
|
-
if (req.method === 'POST' && pathname === '/v1/chat') {
|
|
7969
|
-
const body = await parseJsonBody(req);
|
|
7970
|
-
const question = body.question || body.q || body.message;
|
|
7971
|
-
const normalizedChatPrompt = normalizeEnterpriseChatPrompt(question);
|
|
7972
|
-
const chatFeedbackDir = requestFeedbackPaths.FEEDBACK_DIR;
|
|
7973
|
-
|
|
7974
|
-
// Local-first: factual/metric questions (gates, blocks, feedback, cost,
|
|
7975
|
-
// team) are answered deterministically from local data — no cloud/LLM/key.
|
|
7976
|
-
if (
|
|
7977
|
-
normalizedChatPrompt
|
|
7978
|
-
&& !containsUnsafeEnterpriseChatInput(normalizedChatPrompt)
|
|
7979
|
-
&& classifyEnterpriseChatTopic(normalizedChatPrompt) !== 'overview'
|
|
7980
|
-
&& await trySendLocalDashboardChat(res, parsed, chatFeedbackDir, normalizedChatPrompt)
|
|
7981
|
-
) {
|
|
7982
|
-
return;
|
|
7983
|
-
}
|
|
7984
|
-
|
|
7985
|
-
const { answerDataQuestion } = require('../../scripts/dashboard-chat');
|
|
7986
|
-
|
|
7987
|
-
const projectChatSettings = readProjectChatSettings(req, parsed);
|
|
7988
|
-
|
|
7989
|
-
const result = await answerDataQuestion(question, {
|
|
7990
|
-
feedbackDir: chatFeedbackDir,
|
|
7991
|
-
model: typeof body.model === 'string' ? body.model : undefined,
|
|
7992
|
-
apiKey: projectChatSettings.perplexityKey || projectChatSettings.geminiKey || process.env.PERPLEXITY_API_KEY || process.env.THUMBGATE_PERPLEXITY_API_KEY || process.env.GEMINI_API_KEY || process.env.THUMBGATE_GEMINI_API_KEY || process.env.GOOGLE_API_KEY || '',
|
|
7993
|
-
localEndpoint: projectChatSettings.localEndpoint || process.env.THUMBGATE_LOCAL_LLM_ENDPOINT || '',
|
|
7994
|
-
localModel: projectChatSettings.localModel || process.env.THUMBGATE_LOCAL_LLM_MODEL || '',
|
|
7995
|
-
});
|
|
7996
|
-
|
|
7997
|
-
// Local-first guarantee: if no model is configured, never hard-fail with
|
|
7998
|
-
// "no_api_key" — fall back to a deterministic local answer. No cloud required.
|
|
7999
|
-
if (
|
|
8000
|
-
!result.ok
|
|
8001
|
-
&& (result.error === 'no_api_key' || result.error === 'no_model')
|
|
8002
|
-
&& await trySendLocalDashboardChat(res, parsed, chatFeedbackDir, normalizedChatPrompt || question, '(Connect a local model via THUMBGATE_LOCAL_LLM_ENDPOINT for open-ended analysis over your lessons.)')
|
|
8003
|
-
) {
|
|
8004
|
-
return;
|
|
8005
|
-
}
|
|
8006
|
-
|
|
8007
|
-
sendJson(res, result.ok ? 200 : (result.error === 'no_api_key' ? 503 : 400), result);
|
|
8008
|
-
return;
|
|
8009
|
-
}
|
|
8010
|
-
|
|
8011
|
-
// Save Gemini API key from the dashboard UI
|
|
8012
|
-
if (req.method === 'POST' && pathname === '/v1/settings/gemini-key') {
|
|
8013
|
-
const body = await parseJsonBody(req);
|
|
8014
|
-
const key = String(body.key || '').trim();
|
|
8015
|
-
if (!key) {
|
|
8016
|
-
sendJson(res, 400, { ok: false, error: 'missing_key', message: 'No API key provided.' });
|
|
8017
|
-
return;
|
|
8018
|
-
}
|
|
8019
|
-
|
|
8020
|
-
// Validate the candidate key using the *exact* same code path as /v1/chat
|
|
8021
|
-
// (project-scoped .env read + RAG + Gemini call). This prevents saving a
|
|
8022
|
-
// key that will later produce the confusing "API key not valid" error in chat.
|
|
8023
|
-
let validation;
|
|
8024
|
-
try {
|
|
8025
|
-
const { answerDataQuestion } = require('../../scripts/dashboard-chat');
|
|
8026
|
-
validation = await answerDataQuestion('Reply with the single word: PONG', {
|
|
8027
|
-
feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
|
|
8028
|
-
apiKey: key,
|
|
8029
|
-
});
|
|
8030
|
-
} catch (e) {
|
|
8031
|
-
validation = { ok: false, error: 'validation_exception', message: String(e?.message || e) };
|
|
8032
|
-
}
|
|
8033
|
-
|
|
8034
|
-
if (!validation.ok) {
|
|
8035
|
-
const detail = validation.error === 'gemini_error'
|
|
8036
|
-
? (validation.message || 'Gemini rejected the key')
|
|
8037
|
-
: (validation.message || validation.error || 'unknown error');
|
|
8038
|
-
sendJson(res, 400, {
|
|
8039
|
-
ok: false,
|
|
8040
|
-
error: 'invalid_key',
|
|
8041
|
-
message: 'Key validation failed: ' + detail + '. Get a fresh key from https://aistudio.google.com/app/apikey (or run `npx thumbgate setup-vertex` for Vertex) and try again.'
|
|
8042
|
-
});
|
|
8043
|
-
return;
|
|
8044
|
-
}
|
|
8045
|
-
|
|
8046
|
-
try {
|
|
8047
|
-
const projectDir = resolveRequestProjectDir(req, parsed);
|
|
8048
|
-
const envPath = path.join(projectDir, '.env');
|
|
8049
|
-
let content = '';
|
|
8050
|
-
if (fs.existsSync(envPath)) {
|
|
8051
|
-
content = fs.readFileSync(envPath, 'utf8');
|
|
8052
|
-
}
|
|
8053
|
-
const regex = /^GEMINI_API_KEY=.*$/m;
|
|
8054
|
-
if (regex.test(content)) {
|
|
8055
|
-
content = content.replace(regex, `GEMINI_API_KEY=${key}`);
|
|
8056
|
-
} else {
|
|
8057
|
-
content = content.trim() + `\nGEMINI_API_KEY=${key}\n`;
|
|
8058
|
-
}
|
|
8059
|
-
fs.writeFileSync(envPath, content, 'utf8');
|
|
8060
|
-
// Also set it in the current process so it takes effect immediately without restart
|
|
8061
|
-
process.env.GEMINI_API_KEY = key;
|
|
8062
|
-
// Persist validation success for reliable "configured" status in stats/hints
|
|
8063
|
-
try {
|
|
8064
|
-
const statusPath = path.join(projectDir, '.gemini-validated.json');
|
|
8065
|
-
fs.writeFileSync(statusPath, JSON.stringify({
|
|
8066
|
-
validatedAt: new Date().toISOString(),
|
|
8067
|
-
validatedBy: 'dashboard-save'
|
|
8068
|
-
}, null, 2));
|
|
8069
|
-
} catch (error) {
|
|
8070
|
-
debugApiFallback('Gemini validation marker unavailable', error);
|
|
8071
|
-
}
|
|
8072
|
-
sendJson(res, 200, { ok: true, message: 'Key saved and validated.' });
|
|
8073
|
-
} catch (e) {
|
|
8074
|
-
sendJson(res, 500, { ok: false, error: 'fs_error', message: 'Failed to write to .env file: ' + e.message });
|
|
8075
|
-
}
|
|
5944
|
+
sendJson(res, 200, analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH));
|
|
8076
5945
|
return;
|
|
8077
5946
|
}
|
|
8078
5947
|
|
|
@@ -8131,28 +6000,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
8131
6000
|
return;
|
|
8132
6001
|
}
|
|
8133
6002
|
|
|
8134
|
-
if (req.method === 'GET' && (
|
|
8135
|
-
pathname === '/v1/enterprise/data-chat/status'
|
|
8136
|
-
|| pathname === '/v1/enterprise/dialogflow/status'
|
|
8137
|
-
)) {
|
|
8138
|
-
sendJson(res, 200, buildEnterpriseDataChatStatus());
|
|
8139
|
-
return;
|
|
8140
|
-
}
|
|
8141
|
-
|
|
8142
|
-
if (req.method === 'POST' && (
|
|
8143
|
-
pathname === '/v1/enterprise/data-chat/chat'
|
|
8144
|
-
|| pathname === '/v1/enterprise/dialogflow/chat'
|
|
8145
|
-
)) {
|
|
8146
|
-
const body = await parseJsonBody(req, 16 * 1024);
|
|
8147
|
-
const result = await answerEnterpriseDataChat({
|
|
8148
|
-
prompt: body.prompt || body.message || body.query,
|
|
8149
|
-
feedbackDir: requestFeedbackDir,
|
|
8150
|
-
parsed,
|
|
8151
|
-
});
|
|
8152
|
-
sendJson(res, 200, result);
|
|
8153
|
-
return;
|
|
8154
|
-
}
|
|
8155
|
-
|
|
8156
6003
|
if (req.method === 'GET' && pathname === '/v1/intents/catalog') {
|
|
8157
6004
|
const mcpProfile = parsed.searchParams.get('mcpProfile') || undefined;
|
|
8158
6005
|
const bundleId = parsed.searchParams.get('bundleId') || undefined;
|
|
@@ -8178,7 +6025,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
8178
6025
|
bundleId: body.bundleId,
|
|
8179
6026
|
partnerProfile: body.partnerProfile,
|
|
8180
6027
|
delegationMode: body.delegationMode,
|
|
8181
|
-
enforcePlanQuality: body.enforcePlanQuality === true,
|
|
8182
6028
|
approved: body.approved === true,
|
|
8183
6029
|
repoPath: body.repoPath,
|
|
8184
6030
|
});
|
|
@@ -8437,7 +6283,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
8437
6283
|
summary: body.summary,
|
|
8438
6284
|
allowedPaths: body.allowedPaths,
|
|
8439
6285
|
protectedPaths: body.protectedPaths,
|
|
8440
|
-
workflowContract: body.workflowContract,
|
|
8441
6286
|
repoPath: body.repoPath,
|
|
8442
6287
|
localOnly: body.localOnly === true,
|
|
8443
6288
|
clear: body.clear === true,
|
|
@@ -8515,11 +6360,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
8515
6360
|
}
|
|
8516
6361
|
|
|
8517
6362
|
if (req.method === 'GET' && pathname === '/v1/lessons/search') {
|
|
8518
|
-
const lessonLimit = checkLimit('search_lessons');
|
|
8519
|
-
if (!lessonLimit.allowed) {
|
|
8520
|
-
sendJson(res, 429, { error: 'Free tier: lesson search requires Pro. Upgrade at /pricing', code: 'PRO_REQUIRED' });
|
|
8521
|
-
return;
|
|
8522
|
-
}
|
|
8523
6363
|
const query = parsed.searchParams.get('q') || parsed.searchParams.get('query') || '';
|
|
8524
6364
|
const limit = Number(parsed.searchParams.get('limit') || 10);
|
|
8525
6365
|
const category = parsed.searchParams.get('category') || '';
|
|
@@ -8539,24 +6379,17 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
8539
6379
|
}
|
|
8540
6380
|
|
|
8541
6381
|
if (req.method === 'GET' && pathname === '/v1/search') {
|
|
8542
|
-
const searchLimit = checkLimit('search_thumbgate');
|
|
8543
|
-
if (!searchLimit.allowed) {
|
|
8544
|
-
sendJson(res, 429, { error: 'Free tier: search requires Pro. Upgrade at /pricing', code: 'PRO_REQUIRED' });
|
|
8545
|
-
return;
|
|
8546
|
-
}
|
|
8547
6382
|
const query = parsed.searchParams.get('q') || parsed.searchParams.get('query') || '';
|
|
8548
6383
|
const limit = Number(parsed.searchParams.get('limit') || 10);
|
|
8549
6384
|
const source = parsed.searchParams.get('source') || 'all';
|
|
8550
6385
|
const signal = parsed.searchParams.get('signal') || null;
|
|
8551
6386
|
let results;
|
|
8552
6387
|
try {
|
|
8553
|
-
const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
|
|
8554
6388
|
results = searchThumbgate({
|
|
8555
6389
|
query,
|
|
8556
6390
|
limit: Number.isFinite(limit) ? limit : 10,
|
|
8557
6391
|
source,
|
|
8558
6392
|
signal,
|
|
8559
|
-
feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
|
|
8560
6393
|
});
|
|
8561
6394
|
} catch (err) {
|
|
8562
6395
|
throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
|
|
@@ -8566,21 +6399,14 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
8566
6399
|
}
|
|
8567
6400
|
|
|
8568
6401
|
if (req.method === 'POST' && pathname === '/v1/search') {
|
|
8569
|
-
const searchLimit = checkLimit('search_thumbgate');
|
|
8570
|
-
if (!searchLimit.allowed) {
|
|
8571
|
-
sendJson(res, 429, { error: 'Free tier: search requires Pro. Upgrade at /pricing', code: 'PRO_REQUIRED' });
|
|
8572
|
-
return;
|
|
8573
|
-
}
|
|
8574
6402
|
const body = await parseJsonBody(req);
|
|
8575
6403
|
let results;
|
|
8576
6404
|
try {
|
|
8577
|
-
const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
|
|
8578
6405
|
results = searchThumbgate({
|
|
8579
6406
|
query: body.query || body.q || '',
|
|
8580
6407
|
limit: body.limit,
|
|
8581
6408
|
source: body.source,
|
|
8582
6409
|
signal: body.signal,
|
|
8583
|
-
feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
|
|
8584
6410
|
});
|
|
8585
6411
|
} catch (err) {
|
|
8586
6412
|
throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
|
|
@@ -8606,14 +6432,11 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
8606
6432
|
{
|
|
8607
6433
|
const documentId = normalizeDocumentIdFromPath(pathname);
|
|
8608
6434
|
if (req.method === 'GET' && documentId) {
|
|
8609
|
-
if (!/^[a-zA-Z0-9-_]+$/.test(documentId)) {
|
|
8610
|
-
throw createHttpError(400, 'Invalid document ID format');
|
|
8611
|
-
}
|
|
8612
6435
|
const document = readImportedDocument(documentId, {
|
|
8613
6436
|
feedbackDir: requestFeedbackDir,
|
|
8614
6437
|
});
|
|
8615
6438
|
if (!document) {
|
|
8616
|
-
throw createHttpError(404, `Imported document not found: ${
|
|
6439
|
+
throw createHttpError(404, `Imported document not found: ${documentId}`);
|
|
8617
6440
|
}
|
|
8618
6441
|
sendJson(res, 200, { document });
|
|
8619
6442
|
return;
|
|
@@ -8640,7 +6463,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
8640
6463
|
feedbackDir: getSafeDataDir(),
|
|
8641
6464
|
limit: 10,
|
|
8642
6465
|
});
|
|
8643
|
-
} catch { /* best-effort — conversation window is optional */ }
|
|
6466
|
+
} catch (_) { /* best-effort — conversation window is optional */ }
|
|
8644
6467
|
}
|
|
8645
6468
|
const result = captureFeedback({
|
|
8646
6469
|
signal: body.signal,
|
|
@@ -8659,18 +6482,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
8659
6482
|
tags: extractTags(body.tags),
|
|
8660
6483
|
skill: body.skill,
|
|
8661
6484
|
});
|
|
8662
|
-
const actionIntegration = inferActionIntegration(body, req.headers);
|
|
8663
|
-
appendBestEffortTelemetry(requestFeedbackDir, {
|
|
8664
|
-
eventType: chatgptActionEventType(actionIntegration, 'capture_feedback'),
|
|
8665
|
-
clientType: 'api',
|
|
8666
|
-
source: actionIntegration,
|
|
8667
|
-
integration: actionIntegration,
|
|
8668
|
-
actionOperation: 'captureFeedback',
|
|
8669
|
-
endpoint: '/v1/feedback/capture',
|
|
8670
|
-
actionStatus: result.accepted ? 'accepted' : 'clarification_required',
|
|
8671
|
-
accepted: Boolean(result.accepted),
|
|
8672
|
-
failureCode: result.accepted ? null : 'clarification_required',
|
|
8673
|
-
}, req.headers, 'api_action_capture_feedback');
|
|
8674
6485
|
if (result?.accepted) {
|
|
8675
6486
|
// Fan out to any connected dashboard clients so they re-render
|
|
8676
6487
|
// without polling. Non-sensitive summary only (no chat history,
|
|
@@ -8751,11 +6562,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
8751
6562
|
}
|
|
8752
6563
|
|
|
8753
6564
|
if (req.method === 'POST' && pathname === '/v1/dpo/export') {
|
|
8754
|
-
const dpoLimit = checkLimit('export_dpo');
|
|
8755
|
-
if (!dpoLimit.allowed) {
|
|
8756
|
-
sendJson(res, 429, { error: 'Free tier: DPO export requires Pro. Upgrade at /pricing', code: 'PRO_REQUIRED' });
|
|
8757
|
-
return;
|
|
8758
|
-
}
|
|
8759
6565
|
const body = await parseJsonBody(req);
|
|
8760
6566
|
const paths = resolveDpoExportPaths(body, {
|
|
8761
6567
|
safeDataDir: requestSafeDataDir,
|
|
@@ -9333,40 +7139,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
9333
7139
|
return;
|
|
9334
7140
|
}
|
|
9335
7141
|
|
|
9336
|
-
// GET /v1/dashboard/ai-inventory -- Enterprise AI inventory evidence
|
|
9337
|
-
if (req.method === 'GET' && pathname === '/v1/dashboard/ai-inventory') {
|
|
9338
|
-
try {
|
|
9339
|
-
const {
|
|
9340
|
-
scanAiComponents,
|
|
9341
|
-
buildCycloneDxMlBom,
|
|
9342
|
-
} = require('../../scripts/ai-component-inventory');
|
|
9343
|
-
const requestedRoot = parsed.searchParams.get('root');
|
|
9344
|
-
const serverRoot = process.cwd();
|
|
9345
|
-
const rootDir = requestedRoot ? path.resolve(requestedRoot) : serverRoot;
|
|
9346
|
-
const rootRel = path.relative(serverRoot, rootDir);
|
|
9347
|
-
if (rootRel.startsWith('..') || path.isAbsolute(rootRel)) {
|
|
9348
|
-
sendJson(res, 400, {
|
|
9349
|
-
error: 'ai_inventory_root_out_of_scope',
|
|
9350
|
-
message: 'Dashboard AI inventory root must stay within the server working directory. Use the CLI for explicit cross-project scans.',
|
|
9351
|
-
});
|
|
9352
|
-
return;
|
|
9353
|
-
}
|
|
9354
|
-
const inventory = scanAiComponents({
|
|
9355
|
-
rootDir,
|
|
9356
|
-
maxFiles: parsed.searchParams.get('maxFiles') ? Number(parsed.searchParams.get('maxFiles')) : undefined,
|
|
9357
|
-
includeSnippets: parsed.searchParams.get('snippets') !== '0',
|
|
9358
|
-
});
|
|
9359
|
-
const format = String(parsed.searchParams.get('format') || 'json').toLowerCase();
|
|
9360
|
-
sendJson(res, 200, format === 'cyclonedx' ? buildCycloneDxMlBom(inventory, { version: pkg.version }) : inventory);
|
|
9361
|
-
} catch (err) {
|
|
9362
|
-
sendJson(res, 500, {
|
|
9363
|
-
error: 'ai_inventory_failed',
|
|
9364
|
-
message: err?.message || 'Unable to scan AI component inventory.',
|
|
9365
|
-
});
|
|
9366
|
-
}
|
|
9367
|
-
return;
|
|
9368
|
-
}
|
|
9369
|
-
|
|
9370
7142
|
// GET /v1/dashboard/review-state -- incremental review baseline and deltas
|
|
9371
7143
|
if (req.method === 'GET' && pathname === '/v1/dashboard/review-state') {
|
|
9372
7144
|
const reviewState = readDashboardReviewState(requestFeedbackDir);
|
|
@@ -9383,12 +7155,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
9383
7155
|
|
|
9384
7156
|
// POST /v1/dashboard/review-state -- mark current dashboard state as reviewed
|
|
9385
7157
|
if (req.method === 'POST' && pathname === '/v1/dashboard/review-state') {
|
|
9386
|
-
const body = await parseJsonBody(req);
|
|
9387
7158
|
const snapshot = buildReviewSnapshot(requestFeedbackDir);
|
|
9388
|
-
// Override snapshot timestamp with client-provided one if available
|
|
9389
|
-
if (body?.reviewedAt) {
|
|
9390
|
-
snapshot.reviewedAt = body.reviewedAt;
|
|
9391
|
-
}
|
|
9392
7159
|
writeDashboardReviewState(requestFeedbackDir, snapshot);
|
|
9393
7160
|
const data = generateDashboard(requestFeedbackDir, {
|
|
9394
7161
|
reviewBaseline: snapshot,
|
|
@@ -9520,18 +7287,6 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
|
|
|
9520
7287
|
});
|
|
9521
7288
|
report.actionId = evaluation.actionId;
|
|
9522
7289
|
if (report.decisionControl) report.decisionControl.actionId = evaluation.actionId;
|
|
9523
|
-
const actionIntegration = inferActionIntegration(body, req.headers);
|
|
9524
|
-
appendBestEffortTelemetry(requestFeedbackDir, {
|
|
9525
|
-
eventType: chatgptActionEventType(actionIntegration, 'evaluate_decision'),
|
|
9526
|
-
clientType: 'api',
|
|
9527
|
-
source: actionIntegration,
|
|
9528
|
-
integration: actionIntegration,
|
|
9529
|
-
actionOperation: 'evaluateDecision',
|
|
9530
|
-
endpoint: '/v1/decisions/evaluate',
|
|
9531
|
-
actionStatus: 'accepted',
|
|
9532
|
-
accepted: true,
|
|
9533
|
-
decisionMode: report.decisionControl && report.decisionControl.executionMode,
|
|
9534
|
-
}, req.headers, 'api_action_evaluate_decision');
|
|
9535
7290
|
sendJson(res, 200, report);
|
|
9536
7291
|
return;
|
|
9537
7292
|
}
|
|
@@ -9636,7 +7391,6 @@ function startServer({ port, host } = {}) {
|
|
|
9636
7391
|
const listenPort = Number(port ?? process.env.PORT ?? 8787);
|
|
9637
7392
|
const listenHost = String(host ?? process.env.HOST ?? '0.0.0.0').trim() || '0.0.0.0';
|
|
9638
7393
|
const server = createApiServer();
|
|
9639
|
-
registerGracefulShutdown(server);
|
|
9640
7394
|
return new Promise((resolve) => {
|
|
9641
7395
|
server.listen(listenPort, listenHost, () => {
|
|
9642
7396
|
const address = server.address();
|
|
@@ -9652,39 +7406,6 @@ function startServer({ port, host } = {}) {
|
|
|
9652
7406
|
});
|
|
9653
7407
|
}
|
|
9654
7408
|
|
|
9655
|
-
// Railway / Cloud Run / Kubernetes deploy rotations send SIGTERM to swap
|
|
9656
|
-
// containers. Without a handler, Node exits immediately — in-flight requests
|
|
9657
|
-
// are killed and the orchestrator may mark the container as "crashed" (instead
|
|
9658
|
-
// of "gracefully stopped"), wasting its restart-policy budget on a healthy
|
|
9659
|
-
// shutdown. Drain HTTP, give a deadline, then force-exit if anything hangs.
|
|
9660
|
-
function registerGracefulShutdown(server, { gracePeriodMs = 25_000 } = {}) {
|
|
9661
|
-
if (server[GRACEFUL_SHUTDOWN_KEY]) return;
|
|
9662
|
-
server[GRACEFUL_SHUTDOWN_KEY] = true;
|
|
9663
|
-
let shuttingDown = false;
|
|
9664
|
-
const stop = (signal) => {
|
|
9665
|
-
if (shuttingDown) return;
|
|
9666
|
-
shuttingDown = true;
|
|
9667
|
-
console.log(`[shutdown] ${signal} received — draining connections (deadline ${gracePeriodMs}ms)`);
|
|
9668
|
-
const forceTimer = setTimeout(() => {
|
|
9669
|
-
console.error('[shutdown] grace period elapsed — forcing exit');
|
|
9670
|
-
process.exit(1);
|
|
9671
|
-
}, gracePeriodMs);
|
|
9672
|
-
if (typeof forceTimer.unref === 'function') forceTimer.unref();
|
|
9673
|
-
server.close((err) => {
|
|
9674
|
-
if (err) {
|
|
9675
|
-
console.error('[shutdown] server.close error:', err.message);
|
|
9676
|
-
process.exit(1);
|
|
9677
|
-
}
|
|
9678
|
-
console.log('[shutdown] drained cleanly');
|
|
9679
|
-
process.exit(0);
|
|
9680
|
-
});
|
|
9681
|
-
};
|
|
9682
|
-
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
9683
|
-
process.on('SIGINT', () => stop('SIGINT'));
|
|
9684
|
-
}
|
|
9685
|
-
|
|
9686
|
-
const GRACEFUL_SHUTDOWN_KEY = Symbol.for('thumbgate.gracefulShutdownRegistered');
|
|
9687
|
-
|
|
9688
7409
|
module.exports = {
|
|
9689
7410
|
createApiServer,
|
|
9690
7411
|
startServer,
|
|
@@ -9702,15 +7423,6 @@ module.exports = {
|
|
|
9702
7423
|
renderPackagedLessonsHtml,
|
|
9703
7424
|
readOptionalPublicTemplate,
|
|
9704
7425
|
resolveLocalPageBootstrap,
|
|
9705
|
-
getPublicMcpTools,
|
|
9706
|
-
getServerCardTools,
|
|
9707
|
-
buildEnterpriseDataChatStatus,
|
|
9708
|
-
buildEnterpriseDialogflowStatus,
|
|
9709
|
-
buildEnterpriseChatAnswer,
|
|
9710
|
-
answerEnterpriseDataChat,
|
|
9711
|
-
answerEnterpriseDialogflowChat,
|
|
9712
|
-
buildLossAnalyticsResponse,
|
|
9713
|
-
sanitizeHtmlUnsafeJsonValue,
|
|
9714
7426
|
},
|
|
9715
7427
|
};
|
|
9716
7428
|
|