thumbgate 1.27.18 → 1.27.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/dashboard.md +15 -0
- package/.claude/commands/thumbgate-blocked.md +27 -0
- package/.claude/commands/thumbgate-dashboard.md +15 -0
- package/.claude/commands/thumbgate-doctor.md +30 -0
- package/.claude/commands/thumbgate-guard.md +36 -0
- package/.claude/commands/thumbgate-protect.md +30 -0
- package/.claude/commands/thumbgate-rules.md +30 -0
- package/.claude-plugin/plugin.json +4 -3
- package/.well-known/agentic-verify.txt +1 -0
- package/.well-known/llms.txt +33 -12
- package/.well-known/mcp/server-card.json +8 -8
- package/README.md +246 -30
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +2 -2
- package/adapters/gcp/dfcx-webhook-gate.js +295 -0
- package/adapters/gemini/function-declarations.json +1 -0
- package/adapters/letta/README.md +41 -0
- package/adapters/letta/thumbgate-letta-adapter.js +133 -0
- package/adapters/mcp/server-stdio.js +263 -11
- package/adapters/opencode/opencode.json +1 -1
- package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
- package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
- package/bench/observability-eval-suite.json +26 -0
- package/bench/thumbgate-bench.json +2 -2
- package/bin/cli.js +1456 -122
- package/bin/dashboard-cli.js +7 -0
- package/bin/postinstall.js +11 -27
- package/commands/dashboard.md +15 -0
- package/commands/thumbgate-dashboard.md +15 -0
- package/config/gate-classifier-routing.json +98 -0
- package/config/gate-templates.json +216 -0
- package/config/gates/claim-verification.json +12 -0
- package/config/gates/default.json +31 -2
- package/config/github-about.json +2 -2
- package/config/mcp-allowlists.json +23 -13
- package/config/merge-quality-checks.json +0 -1
- package/config/model-candidates.json +121 -6
- package/config/post-deploy-marketing-pages.json +80 -0
- package/config/tessl-tiles.json +1 -3
- package/openapi/openapi.yaml +12 -0
- package/package.json +225 -100
- package/public/about.html +162 -0
- package/public/agent-manager.html +179 -0
- package/public/agents-cost-savings.html +153 -0
- package/public/ai-malpractice-prevention.html +818 -0
- 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 +28 -0
- package/public/assets/brand/thumbgate-mark-inline-v3.svg +18 -0
- package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
- package/public/assets/brand/thumbgate-mark-team.svg +26 -0
- package/public/assets/brand/thumbgate-mark.svg +21 -0
- package/public/assets/brand/thumbgate-wordmark.svg +20 -0
- package/public/assets/claude-thumbgate-statusbar.svg +8 -0
- package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
- package/public/assets/legal-intake-control-flow.svg +66 -0
- package/public/blog.html +4 -4
- package/public/brand/thumbgate-mark.svg +19 -0
- package/public/brand/thumbgate-og.svg +16 -0
- package/public/chatgpt-app.html +330 -0
- package/public/codex-enterprise.html +123 -0
- package/public/codex-plugin.html +72 -20
- package/public/compare.html +31 -8
- package/public/dashboard.html +930 -166
- package/public/diagnostic.html +345 -0
- package/public/federal.html +2 -2
- package/public/guide.html +33 -13
- package/public/index.html +469 -111
- package/public/install.html +193 -0
- package/public/js/buyer-intent.js +672 -0
- package/public/learn.html +183 -18
- package/public/lessons.html +168 -10
- package/public/numbers.html +7 -7
- package/public/pricing.html +399 -0
- package/public/pro.html +34 -11
- package/scripts/action-receipts.js +324 -0
- package/scripts/activation-quickstart.js +187 -0
- package/scripts/agent-memory-lifecycle.js +211 -0
- package/scripts/agent-operations-planner.js +621 -0
- package/scripts/agent-readiness.js +20 -3
- package/scripts/agent-reward-model.js +53 -1
- package/scripts/ai-component-inventory.js +367 -0
- package/scripts/async-eval-observability.js +236 -0
- package/scripts/audit.js +65 -0
- package/scripts/auto-promote-gates.js +82 -10
- package/scripts/auto-wire-hooks.js +14 -0
- package/scripts/aws-blocks-guardrails.js +272 -0
- package/scripts/billing.js +93 -1
- package/scripts/bot-detection.js +61 -3
- package/scripts/build-metadata.js +50 -10
- package/scripts/classifier-routing.js +130 -0
- package/scripts/cli-feedback.js +4 -2
- package/scripts/cli-schema.js +97 -0
- package/scripts/cli-telemetry.js +6 -1
- package/scripts/commercial-offer.js +82 -2
- package/scripts/context-manager.js +74 -6
- package/scripts/dashboard-chat.js +332 -0
- package/scripts/dashboard.js +68 -2
- package/scripts/export-databricks-bundle.js +5 -1
- package/scripts/export-dpo-pairs.js +7 -2
- package/scripts/feedback-aggregate.js +281 -0
- package/scripts/feedback-loop.js +123 -1
- package/scripts/feedback-quality.js +87 -0
- package/scripts/feedback-sanitizer.js +105 -0
- package/scripts/filesystem-search.js +35 -10
- package/scripts/gate-stats.js +89 -0
- package/scripts/gates-engine.js +1176 -85
- package/scripts/gemini-embedding-policy.js +2 -1
- package/scripts/hook-runtime.js +20 -14
- package/scripts/hook-stop-anti-claim.js +301 -0
- package/scripts/hook-thumbgate-cache-updater.js +18 -2
- package/scripts/hybrid-feedback-context.js +142 -7
- package/scripts/install-shim.js +87 -0
- package/scripts/lesson-inference.js +8 -3
- package/scripts/lesson-search.js +17 -1
- package/scripts/license.js +10 -10
- package/scripts/llm-client.js +169 -4
- package/scripts/local-model-profile.js +15 -8
- package/scripts/mcp-config.js +7 -1
- package/scripts/mcp-oauth.js +293 -0
- package/scripts/memory-scope-readiness.js +159 -0
- package/scripts/meta-agent-loop.js +36 -0
- package/scripts/noop-detect.js +285 -0
- package/scripts/operational-integrity.js +39 -5
- package/scripts/oss-pr-opportunity-scout.js +35 -5
- package/scripts/parallel-workflow-orchestrator.js +293 -0
- package/scripts/plan-gate.js +243 -0
- package/scripts/plausible-domain-config.js +99 -0
- package/scripts/plausible-server-events.js +9 -6
- package/scripts/pro-local-dashboard.js +4 -4
- package/scripts/proxy-pointer-rag-guardrails.js +42 -1
- package/scripts/published-cli.js +0 -8
- package/scripts/qa-scenario-planner.js +136 -0
- package/scripts/rate-limiter.js +64 -13
- package/scripts/repeat-metric.js +137 -0
- package/scripts/secret-fixture-tokens.js +61 -0
- package/scripts/secret-redaction.js +166 -0
- package/scripts/secret-scanner.js +44 -5
- package/scripts/security-scanner.js +260 -10
- package/scripts/self-distill-agent.js +3 -1
- package/scripts/self-harness-optimizer.js +141 -0
- package/scripts/self-healing-check.js +193 -0
- package/scripts/self-protection.js +90 -0
- package/scripts/seo-gsd.js +916 -7
- package/scripts/silent-failure-cluster.js +531 -0
- package/scripts/statusline-cache-path.js +17 -2
- package/scripts/statusline-cache-read.js +57 -0
- package/scripts/statusline-local-stats.js +9 -1
- package/scripts/statusline-meta.js +28 -2
- package/scripts/statusline.sh +20 -4
- package/scripts/sync-telemetry-from-prod.js +374 -0
- package/scripts/telemetry-analytics.js +357 -0
- package/scripts/thompson-sampling.js +31 -10
- package/scripts/thumbgate-bench.js +16 -1
- package/scripts/thumbgate-search.js +85 -19
- package/scripts/tool-contract-validator.js +76 -0
- package/scripts/tool-registry.js +169 -1
- package/scripts/trajectory-scorer.js +63 -0
- package/scripts/vector-store.js +45 -0
- package/scripts/verify-marketing-pages-deployed.js +212 -0
- package/scripts/visitor-journey.js +172 -0
- package/scripts/workflow-sentinel.js +286 -53
- package/scripts/workspace-evolver.js +62 -2
- package/src/api/server.js +2683 -319
- package/.claude-plugin/marketplace.json +0 -85
- package/adapters/chatgpt/openapi.yaml +0 -1695
- package/scripts/bot-detector.js +0 -50
package/src/api/server.js
CHANGED
|
@@ -3,6 +3,7 @@ const http = require('http');
|
|
|
3
3
|
const https = require('https');
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
6
7
|
const { EventEmitter } = require('node:events');
|
|
7
8
|
const pkg = require('../../package.json');
|
|
8
9
|
const {
|
|
@@ -19,7 +20,7 @@ const POSTHOG_STATIC_PATH_PREFIX = '/static/';
|
|
|
19
20
|
// Stripe catalog, with the per-tier thumbnails wired in. Re-run the
|
|
20
21
|
// bootstrap workflow to regenerate; the new URLs surface in the workflow
|
|
21
22
|
// summary log.
|
|
22
|
-
const FIRST_FAILURE_RULE_CHECKOUT_URL = 'https://buy.stripe.com/
|
|
23
|
+
const FIRST_FAILURE_RULE_CHECKOUT_URL = 'https://buy.stripe.com/7sY6oHaiEbTw6tP5e33sI3e';
|
|
23
24
|
const QUICK_READ_CHECKOUT_URL = 'https://buy.stripe.com/5kQ7sL76s1eSaK55e33sI2H';
|
|
24
25
|
const WORKFLOW_TEARDOWN_CHECKOUT_URL = 'https://buy.stripe.com/8x214n2Qc4r44lHayn3sI2I';
|
|
25
26
|
const SPRINT_DIAGNOSTIC_CHECKOUT_URL = 'https://buy.stripe.com/28E00j3Uge1E2dzgWL3sI2J';
|
|
@@ -96,6 +97,9 @@ const {
|
|
|
96
97
|
const {
|
|
97
98
|
recordCheckoutFunnelEvent,
|
|
98
99
|
} = require('../../scripts/plausible-server-events');
|
|
100
|
+
const {
|
|
101
|
+
resolvePlausibleDataDomain,
|
|
102
|
+
} = require('../../scripts/plausible-domain-config');
|
|
99
103
|
const {
|
|
100
104
|
buildCloudflareSandboxPlan,
|
|
101
105
|
} = require('../../scripts/cloudflare-dynamic-sandbox');
|
|
@@ -165,7 +169,16 @@ const {
|
|
|
165
169
|
buildReviewSnapshot,
|
|
166
170
|
readDashboardReviewState,
|
|
167
171
|
writeDashboardReviewState,
|
|
172
|
+
collectAllFeedbackEntries,
|
|
168
173
|
} = 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');
|
|
169
182
|
const {
|
|
170
183
|
buildDashboardRenderSpec,
|
|
171
184
|
} = require('../../scripts/dashboard-render-spec');
|
|
@@ -199,6 +212,12 @@ const {
|
|
|
199
212
|
} = require('../../scripts/rate-limiter');
|
|
200
213
|
const { sendProblem, PROBLEM_TYPES } = require('../../scripts/problem-detail');
|
|
201
214
|
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;
|
|
202
221
|
const resendMailer = require('../../scripts/mailer/resend-mailer');
|
|
203
222
|
const {
|
|
204
223
|
buildContextFootprintReport,
|
|
@@ -214,17 +233,26 @@ const PRO_PAGE_PATH = path.resolve(__dirname, '../../public/pro.html');
|
|
|
214
233
|
const DASHBOARD_PAGE_PATH = path.resolve(__dirname, '../../public/dashboard.html');
|
|
215
234
|
const LESSONS_PAGE_PATH = path.resolve(__dirname, '../../public/lessons.html');
|
|
216
235
|
const GUIDE_PAGE_PATH = path.resolve(__dirname, '../../public/guide.html');
|
|
236
|
+
const CHATGPT_APP_PAGE_PATH = path.resolve(__dirname, '../../public/chatgpt-app.html');
|
|
217
237
|
const CODEX_PLUGIN_PAGE_PATH = path.resolve(__dirname, '../../public/codex-plugin.html');
|
|
218
238
|
const COMPARE_PAGE_PATH = path.resolve(__dirname, '../../public/compare.html');
|
|
219
239
|
const LEARN_PAGE_PATH = path.resolve(__dirname, '../../public/learn.html');
|
|
220
240
|
const NUMBERS_PAGE_PATH = path.resolve(__dirname, '../../public/numbers.html');
|
|
221
241
|
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
|
+
const DIAGNOSTIC_PAGE_PATH = path.resolve(__dirname, '../../public/diagnostic.html');
|
|
245
|
+
const INSTALL_PAGE_PATH = path.resolve(__dirname, '../../public/install.html');
|
|
222
246
|
const LEARN_DIR = path.resolve(__dirname, '../../public/learn');
|
|
223
247
|
const GUIDES_DIR = path.resolve(__dirname, '../../public/guides');
|
|
224
248
|
const COMPARE_DIR = path.resolve(__dirname, '../../public/compare');
|
|
225
249
|
const USE_CASES_DIR = path.resolve(__dirname, '../../public/use-cases');
|
|
226
250
|
const PUBLIC_DIR = path.resolve(__dirname, '../../public');
|
|
227
251
|
const PUBLIC_ASSETS_DIR = path.resolve(__dirname, '../../public/assets');
|
|
252
|
+
const LEARN_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(LEARN_DIR);
|
|
253
|
+
const GUIDE_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(GUIDES_DIR);
|
|
254
|
+
const COMPARE_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(COMPARE_DIR);
|
|
255
|
+
const USE_CASE_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(USE_CASES_DIR);
|
|
228
256
|
const BUYER_INTENT_SCRIPT_PATH = path.resolve(__dirname, '../../public/js/buyer-intent.js');
|
|
229
257
|
const STATIC_MIME_BY_EXT = Object.freeze({
|
|
230
258
|
'.png': 'image/png',
|
|
@@ -343,6 +371,7 @@ function serveStaticFile(res, filePath, { headOnly = false, cacheSeconds = 86400
|
|
|
343
371
|
res.setHeader('Content-Type', contentType);
|
|
344
372
|
res.setHeader('Content-Length', stat.size);
|
|
345
373
|
res.setHeader('Cache-Control', `public, max-age=${cacheSeconds}, immutable`);
|
|
374
|
+
res.setHeader('Referrer-Policy', 'same-origin');
|
|
346
375
|
if (headOnly) {
|
|
347
376
|
res.end();
|
|
348
377
|
return;
|
|
@@ -413,27 +442,75 @@ const TRACKED_LINK_TARGETS = Object.freeze({
|
|
|
413
442
|
},
|
|
414
443
|
allowCustomerEmail: true,
|
|
415
444
|
},
|
|
416
|
-
// 2026-
|
|
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).
|
|
445
|
+
// 2026-06-02: Teams/Aiventyx deprecated. Redirect legacy links to Pro.
|
|
422
446
|
teams: {
|
|
447
|
+
path: '/go/pro?utm_source=legacy_teams&utm_medium=redirect',
|
|
448
|
+
ctaId: 'go_pro',
|
|
449
|
+
ctaPlacement: 'link_router',
|
|
450
|
+
eventType: 'cta_click',
|
|
451
|
+
},
|
|
452
|
+
team: {
|
|
453
|
+
path: '/go/pro?utm_source=legacy_teams&utm_medium=redirect',
|
|
454
|
+
ctaId: 'go_pro',
|
|
455
|
+
ctaPlacement: 'link_router',
|
|
456
|
+
eventType: 'cta_click',
|
|
457
|
+
},
|
|
458
|
+
checkout: {
|
|
423
459
|
path: '/checkout/pro',
|
|
424
|
-
ctaId: '
|
|
460
|
+
ctaId: 'go_checkout',
|
|
425
461
|
ctaPlacement: 'link_router',
|
|
426
462
|
eventType: 'cta_click',
|
|
427
463
|
defaults: {
|
|
428
464
|
utm_source: 'website',
|
|
429
465
|
utm_medium: 'link_router',
|
|
430
|
-
utm_campaign: '
|
|
431
|
-
plan_id: '
|
|
432
|
-
seat_count: '3',
|
|
466
|
+
utm_campaign: 'pro_upgrade',
|
|
467
|
+
plan_id: 'pro',
|
|
433
468
|
billing_cycle: 'monthly',
|
|
434
469
|
},
|
|
435
470
|
allowCustomerEmail: true,
|
|
436
471
|
},
|
|
472
|
+
diagnostic: {
|
|
473
|
+
configUrlKey: 'sprintDiagnosticCheckoutUrl',
|
|
474
|
+
fallbackHref: SPRINT_DIAGNOSTIC_CHECKOUT_URL,
|
|
475
|
+
external: true,
|
|
476
|
+
ctaId: 'go_diagnostic',
|
|
477
|
+
ctaPlacement: 'link_router',
|
|
478
|
+
eventType: 'cta_click',
|
|
479
|
+
defaults: {
|
|
480
|
+
utm_source: 'website',
|
|
481
|
+
utm_medium: 'link_router',
|
|
482
|
+
utm_campaign: 'sprint_diagnostic',
|
|
483
|
+
plan_id: 'sprint_diagnostic',
|
|
484
|
+
},
|
|
485
|
+
allowCustomerEmail: true,
|
|
486
|
+
},
|
|
487
|
+
sprint: {
|
|
488
|
+
configUrlKey: 'workflowSprintCheckoutUrl',
|
|
489
|
+
fallbackHref: WORKFLOW_SPRINT_CHECKOUT_URL,
|
|
490
|
+
external: true,
|
|
491
|
+
ctaId: 'go_sprint',
|
|
492
|
+
ctaPlacement: 'link_router',
|
|
493
|
+
eventType: 'cta_click',
|
|
494
|
+
defaults: {
|
|
495
|
+
utm_source: 'website',
|
|
496
|
+
utm_medium: 'link_router',
|
|
497
|
+
utm_campaign: 'workflow_sprint',
|
|
498
|
+
plan_id: 'workflow_sprint',
|
|
499
|
+
},
|
|
500
|
+
allowCustomerEmail: true,
|
|
501
|
+
},
|
|
502
|
+
trial: {
|
|
503
|
+
path: '/guide',
|
|
504
|
+
ctaId: 'go_trial',
|
|
505
|
+
ctaPlacement: 'link_router',
|
|
506
|
+
eventType: 'trial_start_click',
|
|
507
|
+
defaults: {
|
|
508
|
+
utm_source: 'website',
|
|
509
|
+
utm_medium: 'link_router',
|
|
510
|
+
utm_campaign: 'trial_start',
|
|
511
|
+
plan_id: 'free_trial',
|
|
512
|
+
},
|
|
513
|
+
},
|
|
437
514
|
install: {
|
|
438
515
|
path: '/guide',
|
|
439
516
|
ctaId: 'go_install',
|
|
@@ -648,6 +725,51 @@ function resolveRequestProjectDir(req, parsed) {
|
|
|
648
725
|
});
|
|
649
726
|
}
|
|
650
727
|
|
|
728
|
+
function debugApiFallback(label, error) {
|
|
729
|
+
if (process.env.THUMBGATE_DEBUG_API !== '1') return;
|
|
730
|
+
console.warn(`[api] ${label}: ${error?.message || String(error)}`);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function stripEnvQuotes(value) {
|
|
734
|
+
return String(value || '').trim().replace(/^["']|["']$/g, '');
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function extractEnvValue(content, names) {
|
|
738
|
+
const pattern = new RegExp(`^(?:${names.join('|')})=(.*)$`, 'm');
|
|
739
|
+
const match = pattern.exec(content);
|
|
740
|
+
return match ? stripEnvQuotes(match[1]) : '';
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function readProjectChatSettings(req, parsed) {
|
|
744
|
+
const settings = {
|
|
745
|
+
geminiKey: '',
|
|
746
|
+
perplexityKey: '',
|
|
747
|
+
localEndpoint: '',
|
|
748
|
+
localModel: '',
|
|
749
|
+
geminiValidatedAt: null,
|
|
750
|
+
};
|
|
751
|
+
try {
|
|
752
|
+
const projectDir = resolveRequestProjectDir(req, parsed);
|
|
753
|
+
const envPath = path.join(projectDir, '.env');
|
|
754
|
+
if (fs.existsSync(envPath)) {
|
|
755
|
+
const content = fs.readFileSync(envPath, 'utf8');
|
|
756
|
+
settings.geminiKey = extractEnvValue(content, ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'THUMBGATE_GEMINI_API_KEY']);
|
|
757
|
+
settings.perplexityKey = extractEnvValue(content, ['PERPLEXITY_API_KEY', 'THUMBGATE_PERPLEXITY_API_KEY']);
|
|
758
|
+
settings.localEndpoint = extractEnvValue(content, ['THUMBGATE_LOCAL_LLM_ENDPOINT']);
|
|
759
|
+
settings.localModel = extractEnvValue(content, ['THUMBGATE_LOCAL_LLM_MODEL']);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const statusPath = path.join(projectDir, '.gemini-validated.json');
|
|
763
|
+
if (fs.existsSync(statusPath)) {
|
|
764
|
+
const status = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
|
|
765
|
+
settings.geminiValidatedAt = status.validatedAt || null;
|
|
766
|
+
}
|
|
767
|
+
} catch (error) {
|
|
768
|
+
debugApiFallback('project chat settings unavailable', error);
|
|
769
|
+
}
|
|
770
|
+
return settings;
|
|
771
|
+
}
|
|
772
|
+
|
|
651
773
|
function shouldPreferProjectScopedFeedback(req, parsed) {
|
|
652
774
|
const explicitProject = getEffectiveRequestedProjectSelection(req, parsed);
|
|
653
775
|
if (explicitProject) return true;
|
|
@@ -713,16 +835,23 @@ function updateLessonRecord(feedbackDir, lessonId, updater) {
|
|
|
713
835
|
function getPublicMcpTools() {
|
|
714
836
|
return MCP_TOOLS.map((tool) => ({
|
|
715
837
|
name: tool.name,
|
|
838
|
+
...(tool.title ? { title: tool.title } : {}),
|
|
716
839
|
description: tool.description,
|
|
717
840
|
inputSchema: tool.inputSchema,
|
|
841
|
+
// Serve the tool-registry annotations (readOnlyHint/destructiveHint). Required
|
|
842
|
+
// by the Claude Connectors Directory (missing annotations = the #1 rejection
|
|
843
|
+
// cause) and used by MCP clients for permission prompts. Was being dropped here.
|
|
844
|
+
...(tool.annotations ? { annotations: tool.annotations } : {}),
|
|
718
845
|
}));
|
|
719
846
|
}
|
|
720
847
|
|
|
721
848
|
function getServerCardTools() {
|
|
722
849
|
return MCP_TOOLS.map((tool) => ({
|
|
723
850
|
name: tool.name,
|
|
851
|
+
...(tool.title ? { title: tool.title } : {}),
|
|
724
852
|
description: tool.description,
|
|
725
853
|
inputSchema: tool.inputSchema,
|
|
854
|
+
...(tool.annotations ? { annotations: tool.annotations } : {}),
|
|
726
855
|
}));
|
|
727
856
|
}
|
|
728
857
|
|
|
@@ -762,7 +891,7 @@ function getMcpSkillManifests(hostedConfig) {
|
|
|
762
891
|
'Inspect prevention_rules after repeats.',
|
|
763
892
|
],
|
|
764
893
|
installCommand: 'npx thumbgate init',
|
|
765
|
-
contextUrl: buildPublicUrl(hostedConfig, '/
|
|
894
|
+
contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
|
|
766
895
|
proofUrl: VERIFICATION_EVIDENCE_URL,
|
|
767
896
|
},
|
|
768
897
|
{
|
|
@@ -789,7 +918,7 @@ function getMcpSkillManifests(hostedConfig) {
|
|
|
789
918
|
'Evaluate NDCG@10 on visual hard negatives.',
|
|
790
919
|
'Require artifact links before using retrieved evidence in claims.',
|
|
791
920
|
],
|
|
792
|
-
contextUrl: buildPublicUrl(hostedConfig, '/
|
|
921
|
+
contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
|
|
793
922
|
proofUrl: VERIFICATION_EVIDENCE_URL,
|
|
794
923
|
},
|
|
795
924
|
{
|
|
@@ -803,7 +932,7 @@ function getMcpSkillManifests(hostedConfig) {
|
|
|
803
932
|
'Compact feedback context with anchors for proof-critical lessons.',
|
|
804
933
|
'Record estimated token savings next to the workflow evidence.',
|
|
805
934
|
],
|
|
806
|
-
contextUrl: buildPublicUrl(hostedConfig, '/
|
|
935
|
+
contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
|
|
807
936
|
footprintUrl: buildPublicUrl(hostedConfig, '/.well-known/mcp/footprint.json'),
|
|
808
937
|
proofUrl: VERIFICATION_EVIDENCE_URL,
|
|
809
938
|
},
|
|
@@ -818,7 +947,7 @@ function getMcpSkillManifests(hostedConfig) {
|
|
|
818
947
|
'Require baseline evals before adding autonomy or subagents.',
|
|
819
948
|
'Classify tool risk before allowing writes, money movement, production changes, or outbound actions.',
|
|
820
949
|
],
|
|
821
|
-
contextUrl: buildPublicUrl(hostedConfig, '/
|
|
950
|
+
contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
|
|
822
951
|
proofUrl: VERIFICATION_EVIDENCE_URL,
|
|
823
952
|
},
|
|
824
953
|
{
|
|
@@ -832,7 +961,7 @@ function getMcpSkillManifests(hostedConfig) {
|
|
|
832
961
|
'Grade goal inference separately from intervention timing.',
|
|
833
962
|
'Block multi-app proactive writes until rollback and orchestration evidence exists.',
|
|
834
963
|
],
|
|
835
|
-
contextUrl: buildPublicUrl(hostedConfig, '/
|
|
964
|
+
contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
|
|
836
965
|
proofUrl: VERIFICATION_EVIDENCE_URL,
|
|
837
966
|
},
|
|
838
967
|
{
|
|
@@ -846,7 +975,7 @@ function getMcpSkillManifests(hostedConfig) {
|
|
|
846
975
|
'Map every proxy metric to the real user objective.',
|
|
847
976
|
'Require holdout or regression proof before treating benchmark gains as product gains.',
|
|
848
977
|
],
|
|
849
|
-
contextUrl: buildPublicUrl(hostedConfig, '/
|
|
978
|
+
contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
|
|
850
979
|
proofUrl: VERIFICATION_EVIDENCE_URL,
|
|
851
980
|
},
|
|
852
981
|
{
|
|
@@ -860,7 +989,7 @@ function getMcpSkillManifests(hostedConfig) {
|
|
|
860
989
|
'Reproduce locally before claiming a fix.',
|
|
861
990
|
'Open one focused PR with tests, proof, and transparent ThumbGate context only when relevant.',
|
|
862
991
|
],
|
|
863
|
-
contextUrl: buildPublicUrl(hostedConfig, '/
|
|
992
|
+
contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
|
|
864
993
|
proofUrl: VERIFICATION_EVIDENCE_URL,
|
|
865
994
|
},
|
|
866
995
|
{
|
|
@@ -874,7 +1003,7 @@ function getMcpSkillManifests(hostedConfig) {
|
|
|
874
1003
|
'Route self-serve intent to the guide and team pain to Workflow Hardening Sprint intake.',
|
|
875
1004
|
'Block unsupported ad and landing-page claims before spend scales.',
|
|
876
1005
|
],
|
|
877
|
-
contextUrl: buildPublicUrl(hostedConfig, '/
|
|
1006
|
+
contextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
|
|
878
1007
|
proofUrl: VERIFICATION_EVIDENCE_URL,
|
|
879
1008
|
},
|
|
880
1009
|
];
|
|
@@ -1008,7 +1137,7 @@ function getMcpDiscoveryManifest(hostedConfig) {
|
|
|
1008
1137
|
footprint: getContextFootprintReport(hostedConfig),
|
|
1009
1138
|
proof: {
|
|
1010
1139
|
verificationEvidenceUrl: VERIFICATION_EVIDENCE_URL,
|
|
1011
|
-
llmContextUrl: buildPublicUrl(hostedConfig, '/
|
|
1140
|
+
llmContextUrl: buildPublicUrl(hostedConfig, '/llm-context.md'),
|
|
1012
1141
|
},
|
|
1013
1142
|
};
|
|
1014
1143
|
}
|
|
@@ -1418,8 +1547,450 @@ async function loadLiveDashboardDataOrRespondProblem(res, parsed, feedbackDir, i
|
|
|
1418
1547
|
}
|
|
1419
1548
|
}
|
|
1420
1549
|
|
|
1421
|
-
function
|
|
1550
|
+
function buildEnterpriseDataChatStatus(env = process.env) {
|
|
1551
|
+
const vertexProject = normalizeNullableText(env.VERTEX_PROJECT_ID)
|
|
1552
|
+
|| normalizeNullableText(env.GOOGLE_VERTEX_PROJECT);
|
|
1553
|
+
const vertexLocation = normalizeNullableText(env.GOOGLE_VERTEX_LOCATION)
|
|
1554
|
+
|| normalizeNullableText(env.VERTEX_LOCATION)
|
|
1555
|
+
|| 'us-central1';
|
|
1556
|
+
const dfcxFulfillmentUrl = normalizeNullableText(env.THUMBGATE_DFCX_FULFILLMENT_URL);
|
|
1557
|
+
const dfcxAgentId = normalizeNullableText(env.THUMBGATE_DFCX_AGENT_ID);
|
|
1558
|
+
const dfcxLocation = normalizeNullableText(env.THUMBGATE_DFCX_LOCATION);
|
|
1559
|
+
|
|
1560
|
+
return {
|
|
1561
|
+
mode: 'local-dashboard',
|
|
1562
|
+
vertex: {
|
|
1563
|
+
configured: Boolean(vertexProject),
|
|
1564
|
+
projectId: vertexProject,
|
|
1565
|
+
location: vertexLocation,
|
|
1566
|
+
providerMode: normalizeNullableText(env.THUMBGATE_PROVIDER_MODE) || null,
|
|
1567
|
+
},
|
|
1568
|
+
dfcx: {
|
|
1569
|
+
apiSurface: 'Dialogflow CX REST API: projects.locations.agents',
|
|
1570
|
+
liveAgentConfigured: Boolean(dfcxAgentId && dfcxLocation),
|
|
1571
|
+
agentId: dfcxAgentId,
|
|
1572
|
+
location: dfcxLocation,
|
|
1573
|
+
fulfillmentProxyConfigured: Boolean(dfcxFulfillmentUrl),
|
|
1574
|
+
fulfillmentUrlConfigured: Boolean(dfcxFulfillmentUrl),
|
|
1575
|
+
gcloudCxCommandSupported: false,
|
|
1576
|
+
verification: dfcxAgentId && dfcxLocation
|
|
1577
|
+
? 'Agent metadata is present in env; verify via REST/console before production claims.'
|
|
1578
|
+
: 'No live DFCX agent env configured. Use REST/console/deployed webhook evidence before claiming a live agent.',
|
|
1579
|
+
},
|
|
1580
|
+
chat: {
|
|
1581
|
+
available: true,
|
|
1582
|
+
source: 'local ThumbGate dashboard data with LanceDB-backed retrieval',
|
|
1583
|
+
guard: 'local data-access guard; DFCX adapter optional for customer Dialogflow deployments',
|
|
1584
|
+
providerRequired: false,
|
|
1585
|
+
localLlmEndpointConfigured: Boolean(normalizeNullableText(env.THUMBGATE_LOCAL_LLM_ENDPOINT)),
|
|
1586
|
+
},
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
const buildEnterpriseDialogflowStatus = buildEnterpriseDataChatStatus;
|
|
1591
|
+
|
|
1592
|
+
function normalizeEnterpriseChatPrompt(value) {
|
|
1593
|
+
const text = normalizeNullableText(value);
|
|
1594
|
+
if (!text) return null;
|
|
1595
|
+
return text.slice(0, 800);
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function classifyEnterpriseChatTopic(prompt) {
|
|
1599
|
+
const lower = String(prompt || '').toLowerCase();
|
|
1600
|
+
// Feedback-specific words run FIRST: "what mistakes were blocked today" is a
|
|
1601
|
+
// feedback question, not a gates question — must not be hijacked by /block/.
|
|
1602
|
+
if (/mistake|lesson|memory|feedback|thumb|negative|positive|what (?:went )?wrong|fail|win|success|worked|good/.test(lower)) return 'feedback';
|
|
1603
|
+
if (/gate|block|deny|prevent|guard|enforce/.test(lower)) return 'gates';
|
|
1604
|
+
if (/team|agent|org|enterprise|rollout/.test(lower)) return 'team';
|
|
1605
|
+
if (/token|cost|saving|budget|spend/.test(lower)) return 'cost';
|
|
1606
|
+
if (/vertex|gcp|google|dialogflow|dfcx|cloud/.test(lower)) return 'cloud';
|
|
1607
|
+
return 'overview';
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// Parse intent: LIST vs COUNT, and time window. Lets us answer "what mistakes
|
|
1611
|
+
// today?" with an actual filtered list instead of a canned total.
|
|
1612
|
+
function parseChatIntent(prompt) {
|
|
1613
|
+
const lower = String(prompt || '').toLowerCase();
|
|
1614
|
+
const terms = new Set(lower.split(/[^a-z0-9]+/).filter(Boolean));
|
|
1615
|
+
const hasTerm = (term) => terms.has(term);
|
|
1616
|
+
const wantsList = lower.includes('tell me about') ||
|
|
1617
|
+
['what', 'which', 'list', 'show', 'example', 'examples'].some(hasTerm);
|
|
1618
|
+
let windowMs = null;
|
|
1619
|
+
let windowLabel = 'across all time';
|
|
1620
|
+
if (/\btoday\b/.test(lower)) { windowMs = 24 * 60 * 60 * 1000; windowLabel = 'today'; }
|
|
1621
|
+
else if (/\byesterday\b/.test(lower)) { windowMs = 48 * 60 * 60 * 1000; windowLabel = 'yesterday'; }
|
|
1622
|
+
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'; }
|
|
1623
|
+
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'; }
|
|
1624
|
+
return { wantsList, windowMs, windowLabel };
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// Read recent feedback entries (signal-filtered, time-filtered) directly from
|
|
1628
|
+
// the feedback log. Bounded + best-effort — never throws into the chat handler.
|
|
1629
|
+
// Treat short/placeholder context values as "no real description" so we don't
|
|
1630
|
+
// surface `"thumbs down"` × 3 as a useful list. Real feedback always has a
|
|
1631
|
+
// concrete sentence somewhere (whatWentWrong, distillation, whatToChange) —
|
|
1632
|
+
// pick the longest informative one.
|
|
1633
|
+
// Short tokens that mean "no real description" — kept as a plain Set, not a
|
|
1634
|
+
// big regex, to keep complexity low and easy to extend.
|
|
1635
|
+
const PLACEHOLDER_TOKENS = new Set([
|
|
1636
|
+
'thumbs down', 'thumbs up', 'thumb down', 'thumb up',
|
|
1637
|
+
'good', 'bad', 'ok', 'nice', 'verify', 'verifies', 'verification', 'test', 'testing',
|
|
1638
|
+
]);
|
|
1639
|
+
|
|
1640
|
+
function isPlaceholder(text) {
|
|
1641
|
+
const t = String(text || '').trim();
|
|
1642
|
+
if (!t || t.length < 20) return true;
|
|
1643
|
+
return PLACEHOLDER_TOKENS.has(t.toLowerCase().replace(/\.$/, ''));
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
function bestFeedbackDescription(row) {
|
|
1647
|
+
const candidates = [row.whatWentWrong, row.distillation, row.context, row.whatToChange, row.whatWorked, row.reasoning]
|
|
1648
|
+
.map((c) => String(c || '').trim());
|
|
1649
|
+
// Prefer the longest non-placeholder candidate; fall back to any non-empty.
|
|
1650
|
+
const informative = candidates.filter((c) => c && !isPlaceholder(c));
|
|
1651
|
+
const fallback = candidates.find(Boolean) || '';
|
|
1652
|
+
const best = informative.reduce((a, b) => (b.length > a.length ? b : a), '') || fallback;
|
|
1653
|
+
return best.slice(0, 220);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
function readRecentFeedbackEntries(feedbackDir, signal, windowMs, limit = 5, opts = {}) {
|
|
1657
|
+
try {
|
|
1658
|
+
if (!feedbackDir) return [];
|
|
1659
|
+
const rows = shouldAggregateFeedback()
|
|
1660
|
+
? collectAggregateLogEntries('feedback-log.jsonl', { feedbackDir }).entries
|
|
1661
|
+
: (() => {
|
|
1662
|
+
const fsLocal = require('node:fs');
|
|
1663
|
+
const pathLocal = require('node:path');
|
|
1664
|
+
const logPath = pathLocal.join(feedbackDir, 'feedback-log.jsonl');
|
|
1665
|
+
if (!fsLocal.existsSync(logPath)) return [];
|
|
1666
|
+
const { readJsonl } = require('../../scripts/fs-utils');
|
|
1667
|
+
return readJsonl(logPath) || [];
|
|
1668
|
+
})();
|
|
1669
|
+
const cutoff = windowMs ? Date.now() - windowMs : 0;
|
|
1670
|
+
const filtered = rows
|
|
1671
|
+
.filter((r) => !signal || r.signal === signal)
|
|
1672
|
+
.filter((r) => {
|
|
1673
|
+
if (!cutoff) return true;
|
|
1674
|
+
const t = r.timestamp ? Date.parse(r.timestamp) : Number.NaN;
|
|
1675
|
+
return Number.isFinite(t) && t >= cutoff;
|
|
1676
|
+
})
|
|
1677
|
+
.reverse()
|
|
1678
|
+
.map((r) => {
|
|
1679
|
+
const out = {
|
|
1680
|
+
timestamp: r.timestamp,
|
|
1681
|
+
context: bestFeedbackDescription(r),
|
|
1682
|
+
tags: Array.isArray(r.tags) ? r.tags : []
|
|
1683
|
+
};
|
|
1684
|
+
if (opts.includeSignal) out.signal = r.signal;
|
|
1685
|
+
return out;
|
|
1686
|
+
});
|
|
1687
|
+
// For list display, drop entries with no real description so the list is useful,
|
|
1688
|
+
// not three "thumbs down" placeholders. For count-only (high limit), keep all.
|
|
1689
|
+
const useful = opts.skipPlaceholders === false || limit > 50
|
|
1690
|
+
? filtered
|
|
1691
|
+
: filtered.filter((e) => e.context && !isPlaceholder(e.context));
|
|
1692
|
+
return useful.slice(0, limit);
|
|
1693
|
+
} catch {
|
|
1694
|
+
return [];
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
function containsUnsafeEnterpriseChatInput(prompt) {
|
|
1699
|
+
return /[;&|`$<>\\]/.test(String(prompt || ''));
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
function compactNumber(value) {
|
|
1703
|
+
const n = Number(value || 0);
|
|
1704
|
+
return Number.isFinite(n) ? n : 0;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// Pick a signal preference from the prompt. Returns 'negative' | 'positive' | null.
|
|
1708
|
+
function detectFeedbackSignalFromPrompt(prompt) {
|
|
1709
|
+
const lower = String(prompt || '').toLowerCase();
|
|
1710
|
+
const wantsNeg = /mistake|wrong|fail|negative|thumbs? *down|block/.test(lower);
|
|
1711
|
+
const wantsPos = /positive|thumbs? *up|worked|success|wins?\b|good/.test(lower);
|
|
1712
|
+
if (wantsNeg && !wantsPos) return 'negative';
|
|
1713
|
+
if (wantsPos && !wantsNeg) return 'positive';
|
|
1714
|
+
return null;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
const FEEDBACK_LIST_LABELS = Object.freeze({
|
|
1718
|
+
negative: 'Recent mistakes',
|
|
1719
|
+
positive: 'Recent wins',
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
const FEEDBACK_OMITTED_TAGS = Object.freeze(['audit-trail', 'auto-capture']);
|
|
1723
|
+
|
|
1724
|
+
function formatChatTimestamp(isoString) {
|
|
1725
|
+
if (!isoString) return 'unknown time';
|
|
1726
|
+
try {
|
|
1727
|
+
const d = new Date(isoString);
|
|
1728
|
+
if (Number.isNaN(d.getTime())) return isoString;
|
|
1729
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
1730
|
+
const yyyy = d.getFullYear();
|
|
1731
|
+
const mm = pad(d.getMonth() + 1);
|
|
1732
|
+
const dd = pad(d.getDate());
|
|
1733
|
+
const hh = pad(d.getHours());
|
|
1734
|
+
const min = pad(d.getMinutes());
|
|
1735
|
+
const ss = pad(d.getSeconds());
|
|
1736
|
+
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}`;
|
|
1737
|
+
} catch {
|
|
1738
|
+
return isoString;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
function buildFeedbackEntries(windowed, signal) {
|
|
1743
|
+
return (signal ? windowed.filter((r) => r.signal === signal) : windowed)
|
|
1744
|
+
.filter((r) => r.context && !isPlaceholder(r.context))
|
|
1745
|
+
.slice(0, 5);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
function formatFeedbackEntry(entry) {
|
|
1749
|
+
const tsFormatted = formatChatTimestamp(entry.timestamp);
|
|
1750
|
+
const signalLabel = entry.signal ? ` [${entry.signal}]` : '';
|
|
1751
|
+
const tagsList = Array.isArray(entry.tags)
|
|
1752
|
+
? entry.tags.filter((tag) => !FEEDBACK_OMITTED_TAGS.includes(tag))
|
|
1753
|
+
: [];
|
|
1754
|
+
const tagsStr = tagsList.length ? ` (${tagsList.join(', ')})` : '';
|
|
1755
|
+
return ` • ${tsFormatted}${signalLabel}${tagsStr} — ${entry.context}`;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
function appendFeedbackListLines(lines, { entries, signal, intent }) {
|
|
1759
|
+
if (!entries.length) {
|
|
1760
|
+
lines.push(`No ${signal || 'feedback'} entries found ${intent.windowLabel}.`);
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
lines.push(`${FEEDBACK_LIST_LABELS[signal] || 'Recent feedback'} (${intent.windowLabel}):`);
|
|
1764
|
+
lines.push(...entries.map(formatFeedbackEntry));
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
function buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeline }) {
|
|
1768
|
+
const signal = detectFeedbackSignalFromPrompt(ctx.prompt);
|
|
1769
|
+
// One read of the time-windowed log, then in-memory counts + (signal-filtered,
|
|
1770
|
+
// placeholder-stripped) list. Counts include ALL entries (so "Feedback today: 5"
|
|
1771
|
+
// matches the dashboard tile); the list drops vague entries like literal "thumbs
|
|
1772
|
+
// down" / "good" so it surfaces only entries with real, actionable description.
|
|
1773
|
+
const windowed = readRecentFeedbackEntries(feedbackDir, null, intent.windowMs, 10000, { includeSignal: true });
|
|
1774
|
+
const windowPos = windowed.filter((r) => r.signal === 'positive').length;
|
|
1775
|
+
const windowNeg = windowed.filter((r) => r.signal === 'negative').length;
|
|
1776
|
+
const entries = buildFeedbackEntries(windowed, signal);
|
|
1777
|
+
|
|
1778
|
+
const lines = [
|
|
1779
|
+
intent.windowMs
|
|
1780
|
+
? `Feedback ${intent.windowLabel}: ${windowed.length} (${windowPos} positive, ${windowNeg} negative).`
|
|
1781
|
+
: `Feedback total: ${compactNumber(approval.total)} (${compactNumber(approval.positive)} positive, ${compactNumber(approval.negative)} negative).`,
|
|
1782
|
+
];
|
|
1783
|
+
|
|
1784
|
+
if (intent.wantsList) {
|
|
1785
|
+
appendFeedbackListLines(lines, { entries, signal, intent });
|
|
1786
|
+
} else {
|
|
1787
|
+
lines.push(`Lesson pipeline: ${compactNumber(lessonPipeline.lessons || lessonPipeline.generated || 0)} lessons visible in the current dashboard snapshot.`);
|
|
1788
|
+
}
|
|
1789
|
+
return { lines, sources: ['feedback log', intent.wantsList ? 'feedback contexts' : 'lesson pipeline'] };
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
const GATE_EVENTS_REGEX = /activat|fired|trigger|block|denied|prevent|enforce|hit/;
|
|
1793
|
+
|
|
1794
|
+
function describeGate(g) {
|
|
1795
|
+
return `${g.name || g.id || 'unnamed'}${g.severity ? ' [' + g.severity + ']' : ''}`;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function buildGatesSection({ ctx, intent, gates, gateStats }) {
|
|
1799
|
+
const asksEvents = GATE_EVENTS_REGEX.test(String(ctx.prompt || '').toLowerCase());
|
|
1800
|
+
const totalActive = gates.length || compactNumber(gateStats.totalGates);
|
|
1801
|
+
const totalBlocked = compactNumber(gateStats.blocked || gateStats.denied || gateStats.totalBlocked);
|
|
1802
|
+
|
|
1803
|
+
const lines = [];
|
|
1804
|
+
if (asksEvents) {
|
|
1805
|
+
lines.push(`Blocked actions recorded (all time): ${totalBlocked}.`);
|
|
1806
|
+
if (intent.windowMs) {
|
|
1807
|
+
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.)`);
|
|
1808
|
+
}
|
|
1809
|
+
} else {
|
|
1810
|
+
lines.push(`Active gates: ${totalActive}.`);
|
|
1811
|
+
}
|
|
1812
|
+
if (intent.wantsList && gates.length) {
|
|
1813
|
+
lines.push('Active gates:');
|
|
1814
|
+
for (const g of gates.slice(0, 8)) lines.push(` • ${describeGate(g)}`);
|
|
1815
|
+
} else if (gates[0] && !intent.wantsList) {
|
|
1816
|
+
lines.push(`Example gate: ${describeGate(gates[0])}.`);
|
|
1817
|
+
}
|
|
1818
|
+
return { lines, sources: ['gate stats'] };
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function buildEnterpriseChatSection(topic, dashboardData, status, ctx = {}) {
|
|
1822
|
+
const approval = dashboardData.approval || {};
|
|
1823
|
+
const gates = Array.isArray(dashboardData.gates) ? dashboardData.gates : [];
|
|
1824
|
+
const gateStats = dashboardData.gateStats || {};
|
|
1825
|
+
const team = dashboardData.team || {};
|
|
1826
|
+
const tokenSavings = dashboardData.tokenSavings || {};
|
|
1827
|
+
const lessonPipeline = dashboardData.lessonPipeline || {};
|
|
1828
|
+
const intent = ctx.intent || { wantsList: false, windowMs: null, windowLabel: 'across all time' };
|
|
1829
|
+
const feedbackDir = ctx.feedbackDir || null;
|
|
1830
|
+
|
|
1831
|
+
if (topic === 'feedback') {
|
|
1832
|
+
return buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeline });
|
|
1833
|
+
}
|
|
1834
|
+
if (topic === 'gates') {
|
|
1835
|
+
return buildGatesSection({ ctx, intent, gates, gateStats });
|
|
1836
|
+
}
|
|
1837
|
+
if (topic === 'team') {
|
|
1838
|
+
return {
|
|
1839
|
+
lines: [
|
|
1840
|
+
'Team dashboard is available in this local Enterprise view.',
|
|
1841
|
+
`Tracked agents: ${compactNumber(team.totalAgents || team.agentCount || 0)}; risky agents: ${compactNumber(team.riskyAgents || team.highRiskAgents || 0)}.`,
|
|
1842
|
+
],
|
|
1843
|
+
sources: ['team dashboard'],
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
if (topic === 'cost') {
|
|
1847
|
+
return {
|
|
1848
|
+
lines: [
|
|
1849
|
+
`Estimated token savings: ${tokenSavings.dollarsSavedDisplay || '$0.00'} from ${compactNumber(tokenSavings.blockedCalls)} blocked calls.`,
|
|
1850
|
+
'Google Cloud budget alerts are evidence for spend visibility; ThumbGate-side stop conditions must be verified separately before calling them a hard cap.',
|
|
1851
|
+
],
|
|
1852
|
+
sources: ['token savings', 'budget posture'],
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
if (topic === 'cloud') {
|
|
1856
|
+
const vertexLine = status.vertex.configured
|
|
1857
|
+
? `Vertex routing config is present for project ${status.vertex.projectId} (${status.vertex.location}).`
|
|
1858
|
+
: 'Vertex routing config is not present in this server environment.';
|
|
1859
|
+
const dfcxLine = status.dfcx.liveAgentConfigured
|
|
1860
|
+
? `DFCX env has agent ${status.dfcx.agentId} in ${status.dfcx.location}; verify it with REST/console before production claims.`
|
|
1861
|
+
: '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.';
|
|
1862
|
+
return {
|
|
1863
|
+
lines: [vertexLine, dfcxLine],
|
|
1864
|
+
sources: ['enterprise cloud status'],
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
return {
|
|
1868
|
+
lines: [
|
|
1869
|
+
'Ask about feedback, lessons, active gates, team rollout, token savings, or Vertex/DFCX readiness.',
|
|
1870
|
+
`Current local snapshot: ${compactNumber(approval.total)} feedback events and ${gates.length || compactNumber(gateStats.totalGates)} active gates.`,
|
|
1871
|
+
],
|
|
1872
|
+
sources: [],
|
|
1873
|
+
};
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
function buildEnterpriseChatAnswer(prompt, dashboardData, status, opts = {}) {
|
|
1877
|
+
const topic = classifyEnterpriseChatTopic(prompt);
|
|
1878
|
+
const intent = parseChatIntent(prompt);
|
|
1879
|
+
const section = buildEnterpriseChatSection(topic, dashboardData, status, {
|
|
1880
|
+
intent,
|
|
1881
|
+
feedbackDir: opts.feedbackDir,
|
|
1882
|
+
prompt,
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
// List-style answers want newlines; single-line answers join with space.
|
|
1886
|
+
const hasList = section.lines.some((l) => /^\s*•/.test(l));
|
|
1887
|
+
const answer = hasList ? section.lines.join('\n') : section.lines.join(' ');
|
|
1888
|
+
|
|
1889
|
+
return {
|
|
1890
|
+
topic,
|
|
1891
|
+
answer,
|
|
1892
|
+
sources: ['local dashboard data', ...section.sources],
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
// Answer the dashboard "Chat with your data" panel LOCALLY (deterministic, no
|
|
1897
|
+
// cloud/LLM) from this install's own per-project dashboard data. Returns true if
|
|
1898
|
+
// it sent a response; false if the local snapshot was unavailable (caller then
|
|
1899
|
+
// falls through). Shared by the local-first path and the no-model fallback.
|
|
1900
|
+
async function trySendLocalDashboardChat(res, parsed, feedbackDir, prompt, suffix) {
|
|
1901
|
+
try {
|
|
1902
|
+
const dashboardResult = await buildLiveDashboardData(parsed, feedbackDir);
|
|
1903
|
+
const localChat = buildEnterpriseChatAnswer(
|
|
1904
|
+
prompt,
|
|
1905
|
+
dashboardResult.data,
|
|
1906
|
+
buildEnterpriseDataChatStatus(),
|
|
1907
|
+
{ feedbackDir },
|
|
1908
|
+
);
|
|
1909
|
+
sendJson(res, 200, {
|
|
1910
|
+
ok: true,
|
|
1911
|
+
answer: suffix ? `${localChat.answer} ${suffix}` : localChat.answer,
|
|
1912
|
+
sources: (localChat.sources || []).map((title) => ({ title })),
|
|
1913
|
+
topic: localChat.topic,
|
|
1914
|
+
provider: 'local-data',
|
|
1915
|
+
llm: 'none',
|
|
1916
|
+
grounded: true,
|
|
1917
|
+
});
|
|
1918
|
+
return true;
|
|
1919
|
+
} catch {
|
|
1920
|
+
return false;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
async function answerEnterpriseDataChat({ prompt, feedbackDir, parsed }) {
|
|
1925
|
+
const normalizedPrompt = normalizeEnterpriseChatPrompt(prompt);
|
|
1926
|
+
if (!normalizedPrompt) {
|
|
1927
|
+
throw createHttpError(400, 'prompt is required');
|
|
1928
|
+
}
|
|
1929
|
+
const status = buildEnterpriseDataChatStatus();
|
|
1930
|
+
if (containsUnsafeEnterpriseChatInput(normalizedPrompt)) {
|
|
1931
|
+
return {
|
|
1932
|
+
ok: false,
|
|
1933
|
+
blocked: true,
|
|
1934
|
+
answer: 'This prompt contains unsafe control characters and was blocked before data access.',
|
|
1935
|
+
status,
|
|
1936
|
+
dfcx: {
|
|
1937
|
+
blocked: true,
|
|
1938
|
+
evaluation: {
|
|
1939
|
+
allowed: false,
|
|
1940
|
+
gate: 'enterprise-chat-unsafe-input',
|
|
1941
|
+
severity: 'critical',
|
|
1942
|
+
},
|
|
1943
|
+
},
|
|
1944
|
+
sources: ['enterprise input guard'],
|
|
1945
|
+
};
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
const dashboardResult = await buildLiveDashboardData(parsed, feedbackDir);
|
|
1949
|
+
const dashboardData = dashboardResult.data;
|
|
1950
|
+
|
|
1951
|
+
// This guarded stats endpoint stays deterministic for API compatibility.
|
|
1952
|
+
// The dashboard's real local/open-source chatbot turn goes through /v1/chat,
|
|
1953
|
+
// which uses lesson retrieval + optional LanceDB vector search + the user's
|
|
1954
|
+
// configured local or BYO model.
|
|
1955
|
+
const chat = buildEnterpriseChatAnswer(normalizedPrompt, dashboardData, status, { feedbackDir });
|
|
1956
|
+
const dfcxRequest = {
|
|
1957
|
+
fulfillmentInfo: { tag: 'chat-with-data' },
|
|
1958
|
+
sessionInfo: {
|
|
1959
|
+
session: 'local-dashboard/enterprise-chat',
|
|
1960
|
+
parameters: {
|
|
1961
|
+
topic: chat.topic,
|
|
1962
|
+
prompt_key: normalizedPrompt.toLowerCase().replace(/[^a-z0-9._ -]/g, '').slice(0, 64),
|
|
1963
|
+
},
|
|
1964
|
+
},
|
|
1965
|
+
languageCode: 'en',
|
|
1966
|
+
};
|
|
1967
|
+
const guarded = await guardDfcxWebhook(
|
|
1968
|
+
dfcxRequest,
|
|
1969
|
+
async () => ({
|
|
1970
|
+
fulfillment_response: { messages: [{ text: { text: [chat.answer] } }] },
|
|
1971
|
+
session_info: { parameters: { thumbgate_topic: chat.topic } },
|
|
1972
|
+
}),
|
|
1973
|
+
{ blockOnRepeat: false },
|
|
1974
|
+
);
|
|
1975
|
+
|
|
1422
1976
|
return {
|
|
1977
|
+
ok: !guarded.blocked,
|
|
1978
|
+
blocked: Boolean(guarded.blocked),
|
|
1979
|
+
answer: guarded.blocked ? 'ThumbGate blocked this enterprise chat turn before data access.' : chat.answer,
|
|
1980
|
+
status,
|
|
1981
|
+
dfcx: {
|
|
1982
|
+
blocked: Boolean(guarded.blocked),
|
|
1983
|
+
evaluation: guarded.evaluation,
|
|
1984
|
+
response: guarded.response,
|
|
1985
|
+
},
|
|
1986
|
+
sources: chat.sources,
|
|
1987
|
+
};
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
const answerEnterpriseDialogflowChat = answerEnterpriseDataChat;
|
|
1991
|
+
|
|
1992
|
+
function buildLossAnalyticsResponse(data, summaryOptions) {
|
|
1993
|
+
return sanitizeHtmlUnsafeJsonValue({
|
|
1423
1994
|
window: data.analytics.window || summaryOptions,
|
|
1424
1995
|
lossAnalysis: data.analytics.lossAnalysis || null,
|
|
1425
1996
|
buyerLoss: data.analytics.buyerLoss || null,
|
|
@@ -1431,13 +2002,50 @@ function buildLossAnalyticsResponse(data, summaryOptions) {
|
|
|
1431
2002
|
ctas: data.analytics.telemetry && data.analytics.telemetry.ctas,
|
|
1432
2003
|
visitors: data.analytics.telemetry && data.analytics.telemetry.visitors,
|
|
1433
2004
|
},
|
|
1434
|
-
};
|
|
2005
|
+
});
|
|
1435
2006
|
}
|
|
1436
2007
|
|
|
1437
2008
|
function createJourneyId(prefix) {
|
|
1438
2009
|
return createTraceId(prefix).replace(/^trace_/, `${prefix}_`);
|
|
1439
2010
|
}
|
|
1440
2011
|
|
|
2012
|
+
function normalizeCheckoutInterstitialSampleRate(value) {
|
|
2013
|
+
const parsed = Number.parseFloat(String(value || '').trim());
|
|
2014
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
2015
|
+
return 0;
|
|
2016
|
+
}
|
|
2017
|
+
if (parsed > 1) {
|
|
2018
|
+
return Math.min(parsed / 100, 1);
|
|
2019
|
+
}
|
|
2020
|
+
return Math.min(parsed, 1);
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
function stableUnitInterval(value) {
|
|
2024
|
+
const text = String(value || '');
|
|
2025
|
+
let hash = 2166136261;
|
|
2026
|
+
for (const character of text) {
|
|
2027
|
+
hash ^= character.codePointAt(0);
|
|
2028
|
+
hash = Math.imul(hash, 16777619);
|
|
2029
|
+
}
|
|
2030
|
+
return (hash >>> 0) / 4294967296;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
function shouldSampleCheckoutInterstitial({ sampleRate, traceId, analyticsMetadata }) {
|
|
2034
|
+
if (sampleRate <= 0) {
|
|
2035
|
+
return false;
|
|
2036
|
+
}
|
|
2037
|
+
if (sampleRate >= 1) {
|
|
2038
|
+
return true;
|
|
2039
|
+
}
|
|
2040
|
+
const seed = [
|
|
2041
|
+
analyticsMetadata?.visitorId,
|
|
2042
|
+
analyticsMetadata?.sessionId,
|
|
2043
|
+
analyticsMetadata?.acquisitionId,
|
|
2044
|
+
traceId,
|
|
2045
|
+
].filter(Boolean).join(':');
|
|
2046
|
+
return stableUnitInterval(seed || traceId) < sampleRate;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
1441
2049
|
function appendQueryParam(url, key, value) {
|
|
1442
2050
|
const normalized = normalizeNullableText(value);
|
|
1443
2051
|
if (normalized) {
|
|
@@ -1497,63 +2105,294 @@ function buildCheckoutIntentHref(baseUrl, metadata = {}, overrides = {}) {
|
|
|
1497
2105
|
});
|
|
1498
2106
|
}
|
|
1499
2107
|
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
2108
|
+
const CHECKOUT_HIDDEN_ATTRIBUTION_KEYS = Object.freeze([
|
|
2109
|
+
'trace_id',
|
|
2110
|
+
'acquisition_id',
|
|
2111
|
+
'visitor_id',
|
|
2112
|
+
'session_id',
|
|
2113
|
+
'visitor_session_id',
|
|
2114
|
+
'install_id',
|
|
2115
|
+
'utm_source',
|
|
2116
|
+
'utm_medium',
|
|
2117
|
+
'utm_campaign',
|
|
2118
|
+
'utm_content',
|
|
2119
|
+
'utm_term',
|
|
2120
|
+
'creator',
|
|
2121
|
+
'community',
|
|
2122
|
+
'post_id',
|
|
2123
|
+
'comment_id',
|
|
2124
|
+
'campaign_variant',
|
|
2125
|
+
'offer_code',
|
|
2126
|
+
'cta_id',
|
|
2127
|
+
'cta_placement',
|
|
2128
|
+
'plan_id',
|
|
2129
|
+
'billing_cycle',
|
|
2130
|
+
'seat_count',
|
|
2131
|
+
'landing_path',
|
|
2132
|
+
'referrer_host',
|
|
2133
|
+
]);
|
|
1508
2134
|
|
|
1509
|
-
function
|
|
1510
|
-
const
|
|
1511
|
-
|
|
1512
|
-
|
|
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
|
-
};
|
|
2135
|
+
function normalizeHiddenAttributionValue(value) {
|
|
2136
|
+
const normalized = normalizeNullableText(value);
|
|
2137
|
+
if (!normalized) return '';
|
|
2138
|
+
return normalized.slice(0, 512);
|
|
1549
2139
|
}
|
|
1550
2140
|
|
|
1551
|
-
function
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
2141
|
+
function buildCheckoutHiddenAttributionInputs(parsed = null) {
|
|
2142
|
+
if (!parsed?.searchParams) return '';
|
|
2143
|
+
|
|
2144
|
+
const inputs = [];
|
|
2145
|
+
for (const key of CHECKOUT_HIDDEN_ATTRIBUTION_KEYS) {
|
|
2146
|
+
const value = normalizeHiddenAttributionValue(parsed.searchParams.get(key));
|
|
2147
|
+
if (value) {
|
|
2148
|
+
inputs.push(`<input type="hidden" name="${key}" value="${escapeHtmlAttribute(value)}">`);
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
return inputs.join('');
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
function prunePendingOauthAuthorizeRequests(now = Date.now()) {
|
|
2155
|
+
for (const [token, entry] of pendingOauthAuthorizeRequests.entries()) {
|
|
2156
|
+
if (!entry || entry.expiresAt <= now) {
|
|
2157
|
+
pendingOauthAuthorizeRequests.delete(token);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
function createPendingOauthAuthorizeRequest(params, now = Date.now()) {
|
|
2163
|
+
prunePendingOauthAuthorizeRequests(now);
|
|
2164
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
2165
|
+
pendingOauthAuthorizeRequests.set(token, {
|
|
2166
|
+
params,
|
|
2167
|
+
expiresAt: now + OAUTH_AUTHORIZE_REQUEST_TTL_MS,
|
|
2168
|
+
});
|
|
2169
|
+
return token;
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
function consumePendingOauthAuthorizeRequest(token, now = Date.now()) {
|
|
2173
|
+
if (!token) return null;
|
|
2174
|
+
prunePendingOauthAuthorizeRequests(now);
|
|
2175
|
+
const entry = pendingOauthAuthorizeRequests.get(token);
|
|
2176
|
+
if (!entry) return null;
|
|
2177
|
+
pendingOauthAuthorizeRequests.delete(token);
|
|
2178
|
+
return entry.params;
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
function getOauthAuthorizeParamsFromQuery(searchParams) {
|
|
2182
|
+
return {
|
|
2183
|
+
clientId: searchParams.get('client_id') || '',
|
|
2184
|
+
redirectUri: searchParams.get('redirect_uri') || '',
|
|
2185
|
+
codeChallenge: searchParams.get('code_challenge') || '',
|
|
2186
|
+
codeChallengeMethod: searchParams.get('code_challenge_method') || '',
|
|
2187
|
+
scope: searchParams.get('scope') || undefined,
|
|
2188
|
+
state: searchParams.get('state') || '',
|
|
2189
|
+
resource: searchParams.get('resource') || '',
|
|
2190
|
+
};
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
function getOauthAuthorizeParamsFromForm(form, hostedConfig) {
|
|
2194
|
+
const pending = consumePendingOauthAuthorizeRequest(form.get('auth_request_token') || '');
|
|
2195
|
+
if (pending) return pending;
|
|
2196
|
+
return {
|
|
2197
|
+
clientId: form.get('client_id') || '',
|
|
2198
|
+
redirectUri: form.get('redirect_uri') || '',
|
|
2199
|
+
codeChallenge: form.get('code_challenge') || '',
|
|
2200
|
+
codeChallengeMethod: form.get('code_challenge_method') || '',
|
|
2201
|
+
scope: form.get('scope') || undefined,
|
|
2202
|
+
state: form.get('state') || '',
|
|
2203
|
+
resource: form.get('resource') || buildPublicUrl(hostedConfig, '/mcp'),
|
|
2204
|
+
};
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
function renderCheckoutIntentPage(prefilledEmail = '', parsed = null, options = {}) {
|
|
2208
|
+
const plausibleDomain = escapeHtmlAttribute(resolvePlausibleDataDomain({ host: 'thumbgate.ai' }));
|
|
2209
|
+
const includeHiddenAttribution = options.includeHiddenAttribution === true;
|
|
2210
|
+
const hiddenInputs = includeHiddenAttribution
|
|
2211
|
+
? buildCheckoutHiddenAttributionInputs(parsed)
|
|
2212
|
+
: '';
|
|
2213
|
+
return `<!doctype html>
|
|
2214
|
+
<html lang="en">
|
|
2215
|
+
<head>
|
|
2216
|
+
<meta charset="utf-8">
|
|
2217
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
2218
|
+
<title>Confirm - ThumbGate Pro</title>
|
|
2219
|
+
<script defer data-domain="${plausibleDomain}" src="https://plausible.io/js/script.tagged-events.js"></script>
|
|
2220
|
+
<script>window.plausible=window.plausible||function(){(window.plausible.q=window.plausible.q||[]).push(arguments)};</script>
|
|
2221
|
+
<style>
|
|
2222
|
+
body{background:#0a0a0a;color:#eee;font-family:system-ui,-apple-system,sans-serif;line-height:1.5}
|
|
2223
|
+
main{max-width:520px;margin:8vh auto;padding:0 20px}
|
|
2224
|
+
.brand{display:flex;align-items:center;gap:10px;margin-bottom:24px;font-size:14px;color:#94a3b8}
|
|
2225
|
+
.brand-mark{width:24px;height:24px;background:#22d3ee;border-radius:6px;display:inline-block}
|
|
2226
|
+
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}
|
|
2227
|
+
p{color:#cbd5e1;margin:8px 0}form{margin:0}
|
|
2228
|
+
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}
|
|
2229
|
+
input[type=email]:focus{border-color:#22d3ee}input[type=email]::placeholder{color:#64748b}
|
|
2230
|
+
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%}
|
|
2231
|
+
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}
|
|
2232
|
+
.trust,.objection-box{margin:24px 0;padding:16px;border:1px solid #1f2937;border-radius:8px;background:#0f172a}
|
|
2233
|
+
.trust-item{font-size:13px;color:#cbd5e1;padding:4px 0;display:flex;gap:8px}.trust-item::before{content:"✓";color:#22d3ee;font-weight:700}
|
|
2234
|
+
.choice-note,.email-note,.objection-note{font-size:13px;color:#94a3b8;margin-top:10px}
|
|
2235
|
+
.objection-grid{display:grid;gap:8px;margin-top:12px}
|
|
2236
|
+
.objection-grid button{border:1px solid #374151;background:#111827;color:#e5e7eb;border-radius:8px;padding:10px 12px;text-align:left;cursor:pointer}
|
|
2237
|
+
.objection-grid button:hover,.objection-grid button:focus{border-color:#22d3ee;outline:none}
|
|
2238
|
+
.feedback-saved{display:none;color:#22d3ee;font-size:13px;margin-top:10px}
|
|
2239
|
+
.back{text-align:center;color:#64748b;font-size:12px;margin-top:24px}.back a{color:#64748b;display:inline}
|
|
2240
|
+
</style>
|
|
2241
|
+
</head>
|
|
2242
|
+
<body>
|
|
2243
|
+
<main>
|
|
2244
|
+
<div class="brand"><span class="brand-mark"></span><span>ThumbGate</span></div>
|
|
2245
|
+
<h1>Start ThumbGate Pro</h1>
|
|
2246
|
+
<div class="price">$19<small>/mo</small></div>
|
|
2247
|
+
<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>
|
|
2248
|
+
<form action="https://buy.stripe.com/8x2dR91M84r4cSd9uj3sI3f" method="GET" data-i="pro_checkout_confirmed">
|
|
2249
|
+
${hiddenInputs}
|
|
2250
|
+
<input type="email" name="prefilled_email" value="${escapeHtmlAttribute(prefilledEmail)}" placeholder="you@company.com" autocomplete="email">
|
|
2251
|
+
<p class="email-note">Optional. Stripe can collect your email on the secure checkout page.</p>
|
|
2252
|
+
<button type="submit" class="primary">Pay $19/mo with Stripe →</button>
|
|
2253
|
+
</form>
|
|
2254
|
+
<a class="secondary" data-i="workflow_sprint_intake" href="/#workflow-sprint-intake">Not sure yet? Send the workflow first</a>
|
|
2255
|
+
<p class="choice-note">Cancel anytime. 7-day refund, no questions. Diagnostics and sprints have their own pages.</p>
|
|
2256
|
+
<div class="objection-box" aria-label="Checkout feedback">
|
|
2257
|
+
<strong>Not buying today?</strong>
|
|
2258
|
+
<p class="objection-note">Tap one reason so we know what to fix. This does not sign you up.</p>
|
|
2259
|
+
<div class="objection-grid">
|
|
2260
|
+
<button type="button" data-reason="price_unclear">Price or scope is unclear</button>
|
|
2261
|
+
<button type="button" data-reason="need_more_proof">Need more proof first</button>
|
|
2262
|
+
<button type="button" data-reason="need_team_plan">Need a team/workflow plan instead</button>
|
|
2263
|
+
<button type="button" data-reason="not_urgent">Not urgent right now</button>
|
|
2264
|
+
</div>
|
|
2265
|
+
<div class="feedback-saved" id="feedback-saved">Feedback saved.</div>
|
|
2266
|
+
</div>
|
|
2267
|
+
<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>
|
|
2268
|
+
<p class="back"><a href="/">← Back to thumbgate.ai</a></p>
|
|
2269
|
+
</main>
|
|
2270
|
+
<script>
|
|
2271
|
+
(function(){
|
|
2272
|
+
var params = new URLSearchParams(window.location.search);
|
|
2273
|
+
var submitted = false;
|
|
2274
|
+
var feedbackSent = false;
|
|
2275
|
+
function pick(key, fallback) { return params.get(key) || fallback || null; }
|
|
2276
|
+
function sendTelemetry(eventType, extra) {
|
|
2277
|
+
var payload = Object.assign({
|
|
2278
|
+
eventType: eventType,
|
|
2279
|
+
clientType: 'web',
|
|
2280
|
+
page: '/checkout/pro',
|
|
2281
|
+
traceId: pick('trace_id'),
|
|
2282
|
+
acquisitionId: pick('acquisition_id'),
|
|
2283
|
+
visitorId: pick('visitor_id'),
|
|
2284
|
+
sessionId: pick('visitor_session_id') || pick('session_id'),
|
|
2285
|
+
installId: pick('install_id'),
|
|
2286
|
+
source: pick('utm_source') || pick('source') || 'website',
|
|
2287
|
+
utmSource: pick('utm_source') || pick('source') || 'website',
|
|
2288
|
+
utmMedium: pick('utm_medium') || 'checkout_interstitial',
|
|
2289
|
+
utmCampaign: pick('utm_campaign') || 'pro_pack',
|
|
2290
|
+
utmContent: pick('utm_content'),
|
|
2291
|
+
utmTerm: pick('utm_term'),
|
|
2292
|
+
creator: pick('creator') || pick('creator_handle'),
|
|
2293
|
+
community: pick('community') || pick('subreddit'),
|
|
2294
|
+
postId: pick('post_id'),
|
|
2295
|
+
commentId: pick('comment_id'),
|
|
2296
|
+
campaignVariant: pick('campaign_variant'),
|
|
2297
|
+
offerCode: pick('offer_code'),
|
|
2298
|
+
ctaId: pick('cta_id') || 'pricing_pro',
|
|
2299
|
+
ctaPlacement: pick('cta_placement') || 'checkout_interstitial',
|
|
2300
|
+
planId: pick('plan_id') || 'pro',
|
|
2301
|
+
billingCycle: pick('billing_cycle') || 'monthly',
|
|
2302
|
+
landingPath: pick('landing_path') || '/',
|
|
2303
|
+
referrerHost: pick('referrer_host'),
|
|
2304
|
+
referrer: document.referrer || null
|
|
2305
|
+
}, extra || {});
|
|
2306
|
+
var body = JSON.stringify(payload);
|
|
2307
|
+
if (navigator.sendBeacon) {
|
|
2308
|
+
navigator.sendBeacon('/v1/telemetry/ping', new Blob([body], { type: 'application/json' }));
|
|
2309
|
+
return;
|
|
2310
|
+
}
|
|
2311
|
+
fetch('/v1/telemetry/ping', { method:'POST', headers:{ 'content-type':'application/json' }, body: body, keepalive: true }).catch(function(){});
|
|
2312
|
+
}
|
|
2313
|
+
document.querySelector('form').addEventListener('submit', function(){
|
|
2314
|
+
submitted = true;
|
|
2315
|
+
var emailInput = document.querySelector('input[name=customer_email]');
|
|
2316
|
+
sendTelemetry('checkout_interstitial_cta_clicked', {
|
|
2317
|
+
ctaId: 'pro_checkout_confirmed',
|
|
2318
|
+
ctaPlacement: 'checkout_interstitial',
|
|
2319
|
+
customerEmail: emailInput ? emailInput.value : null
|
|
2320
|
+
});
|
|
2321
|
+
try { window.plausible && window.plausible('Checkout Pro Email Submitted', { props: { page:'/checkout/pro', source:'interstitial' } }); } catch(_) {}
|
|
2322
|
+
});
|
|
2323
|
+
document.querySelectorAll('[data-reason]').forEach(function(button) {
|
|
2324
|
+
button.addEventListener('click', function(){
|
|
2325
|
+
var reason = button.getAttribute('data-reason');
|
|
2326
|
+
feedbackSent = true;
|
|
2327
|
+
sendTelemetry('reason_not_buying', {
|
|
2328
|
+
reasonCode: reason,
|
|
2329
|
+
ctaId: 'checkout_interstitial_reason_' + reason,
|
|
2330
|
+
ctaPlacement: 'checkout_interstitial_feedback'
|
|
2331
|
+
});
|
|
2332
|
+
try { window.plausible && window.plausible('Checkout Pro Reason Not Buying', { props: { page:'/checkout/pro', reasonCode: reason } }); } catch(_) {}
|
|
2333
|
+
var saved = document.getElementById('feedback-saved');
|
|
2334
|
+
if (saved) saved.style.display = 'block';
|
|
2335
|
+
});
|
|
2336
|
+
});
|
|
2337
|
+
window.addEventListener('pagehide', function(){
|
|
2338
|
+
if (!submitted && !feedbackSent) {
|
|
2339
|
+
sendTelemetry('checkout_interstitial_abandoned', { reasonCode: 'left_without_confirming' });
|
|
2340
|
+
}
|
|
2341
|
+
});
|
|
2342
|
+
})();
|
|
2343
|
+
</script>
|
|
2344
|
+
</body>
|
|
2345
|
+
</html>`;
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
function buildCheckoutBootstrapBody(parsed, req, journeyState = resolveJourneyState(req, parsed)) {
|
|
2349
|
+
const params = parsed.searchParams;
|
|
2350
|
+
const traceId = pickFirstText(params.get('trace_id')) || createJourneyId('checkout');
|
|
2351
|
+
const planId = normalizePlanId(pickFirstText(params.get('plan_id'), 'pro'));
|
|
2352
|
+
const billingCycle = normalizeBillingCycle(pickFirstText(params.get('billing_cycle'), 'monthly'));
|
|
2353
|
+
const seatCount = planId === 'team'
|
|
2354
|
+
? normalizeSeatCount(pickFirstText(params.get('seat_count')))
|
|
2355
|
+
: 1;
|
|
2356
|
+
return {
|
|
2357
|
+
traceId,
|
|
2358
|
+
installId: pickFirstText(params.get('install_id')),
|
|
2359
|
+
acquisitionId: journeyState.acquisitionId,
|
|
2360
|
+
visitorId: journeyState.visitorId,
|
|
2361
|
+
sessionId: journeyState.sessionId,
|
|
2362
|
+
customerEmail: pickFirstText(params.get('customer_email')),
|
|
2363
|
+
source: pickFirstText(params.get('source'), params.get('utm_source'), 'website'),
|
|
2364
|
+
utmSource: pickFirstText(params.get('utm_source'), params.get('source'), 'website'),
|
|
2365
|
+
utmMedium: pickFirstText(params.get('utm_medium'), 'cta_button'),
|
|
2366
|
+
utmCampaign: pickFirstText(params.get('utm_campaign'), 'pro_pack'),
|
|
2367
|
+
utmContent: pickFirstText(params.get('utm_content')),
|
|
2368
|
+
utmTerm: pickFirstText(params.get('utm_term')),
|
|
2369
|
+
creator: pickFirstText(params.get('creator'), params.get('creator_handle')),
|
|
2370
|
+
community: pickFirstText(params.get('community'), params.get('subreddit')),
|
|
2371
|
+
postId: pickFirstText(params.get('post_id')),
|
|
2372
|
+
commentId: pickFirstText(params.get('comment_id')),
|
|
2373
|
+
campaignVariant: pickFirstText(params.get('campaign_variant')),
|
|
2374
|
+
offerCode: pickFirstText(params.get('offer_code')),
|
|
2375
|
+
landingPath: pickFirstText(params.get('landing_path'), req.headers.referer ? '/' : '/'),
|
|
2376
|
+
referrerHost: pickFirstText(params.get('referrer_host')),
|
|
2377
|
+
ctaId: pickFirstText(params.get('cta_id'), 'pricing_pro'),
|
|
2378
|
+
ctaPlacement: pickFirstText(params.get('cta_placement'), 'pricing'),
|
|
2379
|
+
planId,
|
|
2380
|
+
billingCycle,
|
|
2381
|
+
seatCount,
|
|
2382
|
+
metadata: {
|
|
2383
|
+
referrer: pickFirstText(params.get('referrer'), req.headers.referer, req.headers.referrer),
|
|
2384
|
+
landingPath: pickFirstText(params.get('landing_path'), '/'),
|
|
2385
|
+
referrerHost: pickFirstText(params.get('referrer_host')),
|
|
2386
|
+
},
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
function buildCheckoutConfirmHref(parsed) {
|
|
2391
|
+
const confirmUrl = new URL('/checkout/pro', 'https://thumbgate.invalid');
|
|
2392
|
+
confirmUrl.searchParams.set('confirm', '1');
|
|
2393
|
+
for (const [key, value] of parsed.searchParams.entries()) {
|
|
2394
|
+
if (key === 'confirm') continue;
|
|
2395
|
+
confirmUrl.searchParams.append(key, value);
|
|
1557
2396
|
}
|
|
1558
2397
|
return `${confirmUrl.pathname}${confirmUrl.search}`;
|
|
1559
2398
|
}
|
|
@@ -1567,17 +2406,6 @@ function normalizeCheckoutCustomerEmail(value) {
|
|
|
1567
2406
|
return email;
|
|
1568
2407
|
}
|
|
1569
2408
|
|
|
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
|
-
|
|
1581
2409
|
function normalizeTrackedLinkSlug(value) {
|
|
1582
2410
|
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
1583
2411
|
}
|
|
@@ -1588,6 +2416,27 @@ function normalizePublicPageSlug(value) {
|
|
|
1588
2416
|
.replace(/[^a-z0-9-]/g, '');
|
|
1589
2417
|
}
|
|
1590
2418
|
|
|
2419
|
+
function buildPublicHtmlFileMap(directory) {
|
|
2420
|
+
const entries = new Map();
|
|
2421
|
+
try {
|
|
2422
|
+
for (const fileName of fs.readdirSync(directory)) {
|
|
2423
|
+
if (!/^[a-z0-9-]+\.html$/i.test(fileName)) continue;
|
|
2424
|
+
const slug = normalizePublicPageSlug(fileName);
|
|
2425
|
+
if (!slug) continue;
|
|
2426
|
+
entries.set(slug, path.join(directory, fileName));
|
|
2427
|
+
}
|
|
2428
|
+
} catch (error) {
|
|
2429
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
2430
|
+
}
|
|
2431
|
+
return entries;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
function resolvePublicHtmlFile(publicPageMap, rawSlug) {
|
|
2435
|
+
const slug = normalizePublicPageSlug(rawSlug);
|
|
2436
|
+
if (!slug) return null;
|
|
2437
|
+
return publicPageMap.get(slug) || null;
|
|
2438
|
+
}
|
|
2439
|
+
|
|
1591
2440
|
function getTrackedLinkTarget(slug) {
|
|
1592
2441
|
const normalizedSlug = normalizeTrackedLinkSlug(slug);
|
|
1593
2442
|
return TRACKED_LINK_TARGETS[normalizedSlug]
|
|
@@ -1623,8 +2472,10 @@ function appendTrackedLinkQueryParams(destinationUrl, parsed, target) {
|
|
|
1623
2472
|
}
|
|
1624
2473
|
|
|
1625
2474
|
function buildTrackedLinkDestination(target, hostedConfig, parsed) {
|
|
1626
|
-
const
|
|
1627
|
-
|
|
2475
|
+
const configuredHref = target.configUrlKey ? hostedConfig[target.configUrlKey] : null;
|
|
2476
|
+
const href = target.href || configuredHref || target.fallbackHref;
|
|
2477
|
+
const destinationUrl = href
|
|
2478
|
+
? new URL(href)
|
|
1628
2479
|
: new URL(target.path || '/', hostedConfig.appOrigin);
|
|
1629
2480
|
appendTrackedLinkQueryParams(destinationUrl, parsed, target);
|
|
1630
2481
|
return destinationUrl;
|
|
@@ -1751,6 +2602,7 @@ function sendJson(res, statusCode, payload, extraHeaders = {}, options = {}) {
|
|
|
1751
2602
|
const body = JSON.stringify(payload);
|
|
1752
2603
|
res.writeHead(statusCode, {
|
|
1753
2604
|
'Content-Type': 'application/json; charset=utf-8',
|
|
2605
|
+
'X-Content-Type-Options': 'nosniff',
|
|
1754
2606
|
'Content-Length': Buffer.byteLength(body),
|
|
1755
2607
|
...extraHeaders,
|
|
1756
2608
|
});
|
|
@@ -1825,14 +2677,28 @@ function appendBestEffortTelemetry(feedbackDir, payload, headers, context) {
|
|
|
1825
2677
|
evidence: [err?.message ? err.message : 'unknown_error'],
|
|
1826
2678
|
},
|
|
1827
2679
|
});
|
|
1828
|
-
} catch
|
|
2680
|
+
} catch {}
|
|
1829
2681
|
return false;
|
|
1830
2682
|
}
|
|
1831
2683
|
}
|
|
1832
2684
|
|
|
2685
|
+
function inferActionIntegration(body = {}, headers = {}) {
|
|
2686
|
+
const explicit = pickFirstText(body.integration, body.source, body.actionIntegration, body.provider);
|
|
2687
|
+
const userAgent = String(headers['user-agent'] || '').toLowerCase();
|
|
2688
|
+
if (/chatgpt|gpt[_-]?actions?|openai/.test(String(explicit || '').toLowerCase()) || /chatgpt|openai/.test(userAgent)) {
|
|
2689
|
+
return 'chatgpt_gpt';
|
|
2690
|
+
}
|
|
2691
|
+
return explicit || 'api';
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
function chatgptActionEventType(integration, suffix) {
|
|
2695
|
+
return integration === 'chatgpt_gpt' ? `chatgpt_action_${suffix}` : `api_action_${suffix}`;
|
|
2696
|
+
}
|
|
2697
|
+
|
|
1833
2698
|
function getPublicOrigin(req) {
|
|
1834
|
-
const
|
|
1835
|
-
const
|
|
2699
|
+
const rawProto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
|
|
2700
|
+
const proto = rawProto === 'https' ? 'https' : 'http';
|
|
2701
|
+
const host = getSafePublicRequestHost(req) || 'localhost';
|
|
1836
2702
|
return `${proto}://${host}`;
|
|
1837
2703
|
}
|
|
1838
2704
|
|
|
@@ -1851,6 +2717,47 @@ function getRequestHostHeader(req) {
|
|
|
1851
2717
|
return forwardedHost || req.headers.host || '';
|
|
1852
2718
|
}
|
|
1853
2719
|
|
|
2720
|
+
function normalizePublicRequestHost(value) {
|
|
2721
|
+
const rawHost = String(value || '').split(',')[0].trim().toLowerCase();
|
|
2722
|
+
if (!rawHost || rawHost.length > 253) return '';
|
|
2723
|
+
|
|
2724
|
+
const hostWithoutPort = rawHost.startsWith('[')
|
|
2725
|
+
? rawHost.slice(1).split(']')[0]
|
|
2726
|
+
: rawHost.split(':')[0];
|
|
2727
|
+
const port = rawHost.startsWith('[')
|
|
2728
|
+
? rawHost.split(']:')[1] || ''
|
|
2729
|
+
: rawHost.split(':')[1] || '';
|
|
2730
|
+
|
|
2731
|
+
if (!isAllowedPublicHostName(hostWithoutPort)) {
|
|
2732
|
+
return '';
|
|
2733
|
+
}
|
|
2734
|
+
if (port && !/^\d{1,5}$/.test(port)) return '';
|
|
2735
|
+
if (port && Number(port) > 65535) return '';
|
|
2736
|
+
return port ? `${hostWithoutPort}:${port}` : hostWithoutPort;
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
function isAllowedPublicHostName(hostname) {
|
|
2740
|
+
return hostname === 'localhost'
|
|
2741
|
+
|| hostname === '::1'
|
|
2742
|
+
|| isIpv4Host(hostname)
|
|
2743
|
+
|| isDnsHostName(hostname);
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
function isIpv4Host(hostname) {
|
|
2747
|
+
const parts = hostname.split('.');
|
|
2748
|
+
return parts.length === 4 && parts.every((part) => /^\d{1,3}$/.test(part));
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
function isDnsHostName(hostname) {
|
|
2752
|
+
return hostname
|
|
2753
|
+
.split('.')
|
|
2754
|
+
.every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
function getSafePublicRequestHost(req) {
|
|
2758
|
+
return normalizePublicRequestHost(getRequestHostHeader(req));
|
|
2759
|
+
}
|
|
2760
|
+
|
|
1854
2761
|
function isLoopbackHost(hostValue) {
|
|
1855
2762
|
const rawHost = String(hostValue || '').split(',')[0].trim();
|
|
1856
2763
|
if (!rawHost) {
|
|
@@ -1896,7 +2803,7 @@ function stripTrailingSlashes(value) {
|
|
|
1896
2803
|
return input.slice(0, end);
|
|
1897
2804
|
}
|
|
1898
2805
|
|
|
1899
|
-
function normalizePublicMarketingHtml(html, runtimeConfig) {
|
|
2806
|
+
function normalizePublicMarketingHtml(html, runtimeConfig, requestHost) {
|
|
1900
2807
|
const appOrigin = runtimeConfig?.appOrigin
|
|
1901
2808
|
? stripTrailingSlashes(runtimeConfig.appOrigin)
|
|
1902
2809
|
: '';
|
|
@@ -1905,10 +2812,14 @@ function normalizePublicMarketingHtml(html, runtimeConfig) {
|
|
|
1905
2812
|
let output = String(html);
|
|
1906
2813
|
output = output.replaceAll(DEFAULT_PUBLIC_APP_ORIGIN, appOrigin);
|
|
1907
2814
|
try {
|
|
1908
|
-
const host = new URL(appOrigin).host;
|
|
2815
|
+
const host = normalizePublicRequestHost(requestHost) || new URL(appOrigin).host;
|
|
2816
|
+
const plausibleDomain = resolvePlausibleDataDomain({ host });
|
|
1909
2817
|
output = output.replaceAll(
|
|
1910
2818
|
'data-domain="thumbgate-production.up.railway.app"',
|
|
1911
|
-
`data-domain="${escapeHtmlAttribute(
|
|
2819
|
+
`data-domain="${escapeHtmlAttribute(plausibleDomain)}"`
|
|
2820
|
+
).replaceAll(
|
|
2821
|
+
'data-domain="thumbgate.ai"',
|
|
2822
|
+
`data-domain="${escapeHtmlAttribute(plausibleDomain)}"`
|
|
1912
2823
|
);
|
|
1913
2824
|
} catch {
|
|
1914
2825
|
// appOrigin is normalized by hosted-config; leave static analytics domains
|
|
@@ -1952,12 +2863,12 @@ function loadPublicMarketingTemplateHtml(templatePath, runtimeConfig, pageContex
|
|
|
1952
2863
|
'__SERVER_ACQUISITION_ID__': pageContext.serverAcquisitionId || '',
|
|
1953
2864
|
'__SERVER_TELEMETRY_CAPTURED__': pageContext.serverTelemetryCaptured ? 'true' : 'false',
|
|
1954
2865
|
'__VERIFICATION_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md',
|
|
1955
|
-
'__COMPATIBILITY_REPORT_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/
|
|
1956
|
-
'__AUTOMATION_REPORT_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/
|
|
2866
|
+
'__COMPATIBILITY_REPORT_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md',
|
|
2867
|
+
'__AUTOMATION_REPORT_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md',
|
|
1957
2868
|
'__GTM_PLAN_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/GO_TO_MARKET_REVENUE_WEDGE_2026-03.md',
|
|
1958
2869
|
'__GITHUB_URL__': 'https://github.com/IgorGanapolsky/ThumbGate',
|
|
1959
2870
|
'__POSTHOG_API_KEY__': runtimeConfig.posthogApiKey || '',
|
|
1960
|
-
}), runtimeConfig);
|
|
2871
|
+
}), runtimeConfig, pageContext.requestHost);
|
|
1961
2872
|
}
|
|
1962
2873
|
|
|
1963
2874
|
function loadLandingPageHtml(runtimeConfig, pageContext = {}) {
|
|
@@ -1968,6 +2879,30 @@ function loadProPageHtml(runtimeConfig, pageContext = {}) {
|
|
|
1968
2879
|
return loadPublicMarketingTemplateHtml(PRO_PAGE_PATH, runtimeConfig, pageContext);
|
|
1969
2880
|
}
|
|
1970
2881
|
|
|
2882
|
+
function loadDiagnosticPageHtml(runtimeConfig, pageContext = {}) {
|
|
2883
|
+
return loadPublicMarketingTemplateHtml(DIAGNOSTIC_PAGE_PATH, runtimeConfig, pageContext);
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
function loadInstallPageHtml(runtimeConfig, pageContext = {}) {
|
|
2887
|
+
return loadPublicMarketingTemplateHtml(INSTALL_PAGE_PATH, runtimeConfig, pageContext);
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
function loadPricingPageHtml(runtimeConfig, pageContext = {}) {
|
|
2891
|
+
return loadPublicMarketingTemplateHtml(PRICING_PAGE_PATH, runtimeConfig, pageContext);
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
function loadAboutPageHtml(runtimeConfig, pageContext = {}) {
|
|
2895
|
+
return loadPublicMarketingTemplateHtml(ABOUT_PAGE_PATH, runtimeConfig, pageContext);
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
function loadGuidePageHtml(runtimeConfig, pageContext = {}) {
|
|
2899
|
+
return loadPublicMarketingTemplateHtml(GUIDE_PAGE_PATH, runtimeConfig, pageContext);
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
function loadLearnPageHtml(runtimeConfig, pageContext = {}) {
|
|
2903
|
+
return loadPublicMarketingTemplateHtml(LEARN_PAGE_PATH, runtimeConfig, pageContext);
|
|
2904
|
+
}
|
|
2905
|
+
|
|
1971
2906
|
function readOptionalPublicTemplate(filePath) {
|
|
1972
2907
|
try {
|
|
1973
2908
|
return fs.readFileSync(filePath, 'utf-8');
|
|
@@ -1977,6 +2912,10 @@ function readOptionalPublicTemplate(filePath) {
|
|
|
1977
2912
|
}
|
|
1978
2913
|
}
|
|
1979
2914
|
|
|
2915
|
+
function escapeJsonForInlineScript(value) {
|
|
2916
|
+
return JSON.stringify(value).replaceAll('<', String.raw`\u003c`);
|
|
2917
|
+
}
|
|
2918
|
+
|
|
1980
2919
|
function resolveLocalPageBootstrap(req, expectedApiKey) {
|
|
1981
2920
|
const forwardedHost = req.headers['x-forwarded-host'];
|
|
1982
2921
|
const hostHeader = Array.isArray(forwardedHost)
|
|
@@ -1985,7 +2924,13 @@ function resolveLocalPageBootstrap(req, expectedApiKey) {
|
|
|
1985
2924
|
const localProBootstrap = process.env.THUMBGATE_PRO_MODE === '1' && Boolean(expectedApiKey) && isLoopbackHost(hostHeader);
|
|
1986
2925
|
const devOverride = expectedApiKey === null && isLoopbackHost(hostHeader);
|
|
1987
2926
|
const bootstrapActive = localProBootstrap || devOverride;
|
|
1988
|
-
|
|
2927
|
+
let bootstrapKey = '';
|
|
2928
|
+
if (localProBootstrap) {
|
|
2929
|
+
bootstrapKey = expectedApiKey;
|
|
2930
|
+
} else if (devOverride) {
|
|
2931
|
+
bootstrapKey = process.env.THUMBGATE_API_KEY || 'dev-override';
|
|
2932
|
+
}
|
|
2933
|
+
const serializedBootstrapKey = escapeJsonForInlineScript(bootstrapKey);
|
|
1989
2934
|
|
|
1990
2935
|
return {
|
|
1991
2936
|
bootstrapActive,
|
|
@@ -2026,6 +2971,7 @@ window.THUMBGATE_DASHBOARD_BOOTSTRAP = { enabled: ${bootstrapActive ? 'true' : '
|
|
|
2026
2971
|
<p>This lightweight npm dashboard is bundled without marketing assets, so installs stay small while core feedback, lessons, and API routes remain available.</p>
|
|
2027
2972
|
<div class="grid">
|
|
2028
2973
|
<a class="card" href="/v1/dashboard"><strong>Dashboard JSON</strong><span>Inspect feedback totals, lesson counts, and Reliability Gateway health.</span></a>
|
|
2974
|
+
<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>
|
|
2029
2975
|
<a class="card" href="/lessons"><strong>Lessons</strong><span>Review remembered thumbs-up/down lessons and enforcement context.</span></a>
|
|
2030
2976
|
<a class="card" href="/health"><strong>Health</strong><span>Verify the installed package version and runtime status.</span></a>
|
|
2031
2977
|
</div>
|
|
@@ -2105,6 +3051,53 @@ function normalizeLessonSignal(signal) {
|
|
|
2105
3051
|
return 'down';
|
|
2106
3052
|
}
|
|
2107
3053
|
|
|
3054
|
+
function splitLessonTitlePrefix(titleText) {
|
|
3055
|
+
const prefixMatch = /^(MISTAKE|SUCCESS|LEARNING|PREFERENCE):\s*(.*)/i.exec(titleText);
|
|
3056
|
+
if (!prefixMatch) return { prefix: '', rest: titleText };
|
|
3057
|
+
return {
|
|
3058
|
+
prefix: `${prefixMatch[1].toUpperCase()}: `,
|
|
3059
|
+
rest: prefixMatch[2],
|
|
3060
|
+
};
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
function maybeParseJsonObject(value) {
|
|
3064
|
+
const trimmed = String(value || '').trim();
|
|
3065
|
+
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null;
|
|
3066
|
+
try {
|
|
3067
|
+
return JSON.parse(trimmed);
|
|
3068
|
+
} catch (error) {
|
|
3069
|
+
debugApiFallback('lesson JSON parse skipped', error);
|
|
3070
|
+
return null;
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
function formatLessonJsonTitle(prefix, parsed) {
|
|
3075
|
+
const dirName = parsed.cwd ? parsed.cwd.split('/').pop() : '';
|
|
3076
|
+
const suffix = dirName ? ` inside ${dirName}` : '';
|
|
3077
|
+
if (parsed.prompt) return `${prefix}Prompt "${parsed.prompt}"${suffix}`;
|
|
3078
|
+
const hookVal = parsed.hook_event_name || parsed.hookEventName;
|
|
3079
|
+
if (hookVal) return `${prefix}Hook event ${hookVal}${suffix}`;
|
|
3080
|
+
if (parsed.signal) return `${prefix}${parsed.signal === 'up' ? 'Thumbs Up' : 'Thumbs Down'}${suffix}`;
|
|
3081
|
+
return '';
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
function cleanLessonTitle(titleText) {
|
|
3085
|
+
if (!titleText) return 'Untitled Lesson';
|
|
3086
|
+
const { prefix, rest } = splitLessonTitlePrefix(titleText);
|
|
3087
|
+
const parsed = maybeParseJsonObject(rest);
|
|
3088
|
+
if (parsed) {
|
|
3089
|
+
const jsonTitle = formatLessonJsonTitle(prefix, parsed);
|
|
3090
|
+
if (jsonTitle) return jsonTitle;
|
|
3091
|
+
}
|
|
3092
|
+
return titleText;
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
function formatLessonTextValue(value) {
|
|
3096
|
+
if (!value) return '';
|
|
3097
|
+
const parsed = maybeParseJsonObject(value);
|
|
3098
|
+
return parsed ? JSON.stringify(parsed, null, 2) : value;
|
|
3099
|
+
}
|
|
3100
|
+
|
|
2108
3101
|
function renderLessonDetailHtml(record, lessonId) {
|
|
2109
3102
|
if (!record) {
|
|
2110
3103
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Lesson Not Found</title>
|
|
@@ -2123,10 +3116,15 @@ a{color:#22d3ee;text-decoration:none}</style></head><body>
|
|
|
2123
3116
|
const signal = normalizeLessonSignal(merged.signal);
|
|
2124
3117
|
const emoji = signal === 'up' ? '👍' : '👎';
|
|
2125
3118
|
const signalColor = signal === 'up' ? '#4ade80' : '#f87171';
|
|
2126
|
-
const
|
|
2127
|
-
const
|
|
2128
|
-
const
|
|
2129
|
-
const
|
|
3119
|
+
const rawTitle = merged.title || merged.context || 'Untitled Lesson';
|
|
3120
|
+
const rawContext = merged.context || '';
|
|
3121
|
+
const rawWhatWentWrong = merged.whatWentWrong || '';
|
|
3122
|
+
const rawWhatWorked = merged.whatWorked || '';
|
|
3123
|
+
|
|
3124
|
+
const title = cleanLessonTitle(rawTitle);
|
|
3125
|
+
const context = formatLessonTextValue(rawContext);
|
|
3126
|
+
const whatWentWrong = formatLessonTextValue(rawWhatWentWrong);
|
|
3127
|
+
const whatWorked = formatLessonTextValue(rawWhatWorked);
|
|
2130
3128
|
const whatToChange = merged.whatToChange || '';
|
|
2131
3129
|
const tags = Array.isArray(merged.tags) ? merged.tags.join(', ') : (merged.tags || '');
|
|
2132
3130
|
const timestamp = merged.timestamp ? new Date(merged.timestamp).toLocaleString() : '';
|
|
@@ -2344,7 +3342,7 @@ nav .container { display: flex; justify-content: space-between; align-items: cen
|
|
|
2344
3342
|
</head>
|
|
2345
3343
|
<body>
|
|
2346
3344
|
<nav><div class="container">
|
|
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>
|
|
3345
|
+
<a href="/dashboard" class="nav-logo"><img src="/assets/brand/thumbgate-mark-inline-v3.svg" alt="ThumbGate" class="logo-mark" width="28" height="28"><span class="logo-text">ThumbGate</span></a>
|
|
2348
3346
|
<div class="nav-links">
|
|
2349
3347
|
<a href="/dashboard">Dashboard</a>
|
|
2350
3348
|
<a href="/lessons">Lessons</a>
|
|
@@ -2468,6 +3466,16 @@ function renderRobotsTxt(runtimeConfig) {
|
|
|
2468
3466
|
'Disallow: /v1/billing/',
|
|
2469
3467
|
'',
|
|
2470
3468
|
'# AI crawler access — allow all major LLM crawlers',
|
|
3469
|
+
'User-agent: OAI-SearchBot',
|
|
3470
|
+
'Allow: /',
|
|
3471
|
+
'Disallow: /checkout/',
|
|
3472
|
+
'Disallow: /v1/billing/',
|
|
3473
|
+
'',
|
|
3474
|
+
'User-agent: ChatGPT-User',
|
|
3475
|
+
'Allow: /',
|
|
3476
|
+
'Disallow: /checkout/',
|
|
3477
|
+
'Disallow: /v1/billing/',
|
|
3478
|
+
'',
|
|
2471
3479
|
'User-agent: GPTBot',
|
|
2472
3480
|
'Allow: /',
|
|
2473
3481
|
'Disallow: /checkout/',
|
|
@@ -2476,9 +3484,18 @@ function renderRobotsTxt(runtimeConfig) {
|
|
|
2476
3484
|
'User-agent: ClaudeBot',
|
|
2477
3485
|
'Allow: /',
|
|
2478
3486
|
'',
|
|
3487
|
+
'User-agent: Claude-SearchBot',
|
|
3488
|
+
'Allow: /',
|
|
3489
|
+
'',
|
|
3490
|
+
'User-agent: Claude-User',
|
|
3491
|
+
'Allow: /',
|
|
3492
|
+
'',
|
|
2479
3493
|
'User-agent: PerplexityBot',
|
|
2480
3494
|
'Allow: /',
|
|
2481
3495
|
'',
|
|
3496
|
+
'User-agent: Perplexity-User',
|
|
3497
|
+
'Allow: /',
|
|
3498
|
+
'',
|
|
2482
3499
|
'User-agent: Googlebot',
|
|
2483
3500
|
'Allow: /',
|
|
2484
3501
|
'',
|
|
@@ -2493,6 +3510,7 @@ function renderRobotsTxt(runtimeConfig) {
|
|
|
2493
3510
|
'',
|
|
2494
3511
|
'# LLM context document — clean declarative content for AI retrieval',
|
|
2495
3512
|
`# ${runtimeConfig.appOrigin}/llm-context.md`,
|
|
3513
|
+
`# ${runtimeConfig.appOrigin}/llms.txt`,
|
|
2496
3514
|
'',
|
|
2497
3515
|
`Sitemap: ${runtimeConfig.appOrigin}/sitemap.xml`,
|
|
2498
3516
|
].join('\n');
|
|
@@ -2502,10 +3520,57 @@ function renderSitemapXml(runtimeConfig) {
|
|
|
2502
3520
|
const entries = [
|
|
2503
3521
|
{ path: '/', changefreq: 'weekly', priority: '1.0' },
|
|
2504
3522
|
{ path: '/pro', changefreq: 'weekly', priority: '0.9' },
|
|
3523
|
+
{ path: '/diagnostic', changefreq: 'weekly', priority: '0.9' },
|
|
3524
|
+
{ path: '/workflow-hardening-sprint', changefreq: 'weekly', priority: '0.9' },
|
|
3525
|
+
{ path: '/install', changefreq: 'weekly', priority: '0.9' },
|
|
3526
|
+
{ path: '/agent-manager', changefreq: 'weekly', priority: '0.9' },
|
|
2505
3527
|
{ path: '/llm-context.md', changefreq: 'weekly', priority: '0.8' },
|
|
3528
|
+
{ path: '/chatgpt-app', changefreq: 'weekly', priority: '0.85' },
|
|
2506
3529
|
{ path: '/codex-plugin', changefreq: 'weekly', priority: '0.75' },
|
|
3530
|
+
{ path: '/codex-enterprise', changefreq: 'weekly', priority: '0.85' },
|
|
3531
|
+
{ path: '/agents-cost-savings', changefreq: 'weekly', priority: '0.85' },
|
|
3532
|
+
{ path: '/ai-malpractice-prevention', changefreq: 'weekly', priority: '0.9' },
|
|
3533
|
+
{ path: '/learn/background-agent-control-layer', changefreq: 'weekly', priority: '0.85' },
|
|
3534
|
+
{ path: '/learn/ac-dc-runtime-enforcement', changefreq: 'weekly', priority: '0.85' },
|
|
3535
|
+
{ path: '/learn/feedback-loop-vs-decision-layer', changefreq: 'weekly', priority: '0.9' },
|
|
3536
|
+
{ path: '/learn/agentic-enterprise-context-brain', changefreq: 'weekly', priority: '0.85' },
|
|
3537
|
+
{ path: '/learn/deterministic-agent-workflows', changefreq: 'weekly', priority: '0.85' },
|
|
3538
|
+
{ path: '/learn/codex-role-plugins-need-governance', changefreq: 'weekly', priority: '0.85' },
|
|
3539
|
+
{ path: '/learn/agentic-os-team-governance', changefreq: 'weekly', priority: '0.85' },
|
|
3540
|
+
{ path: '/learn/cost-aware-agent-gate-routing', changefreq: 'weekly', priority: '0.85' },
|
|
3541
|
+
{ path: '/learn/pretix-stripe-connect-marketplaces', changefreq: 'weekly', priority: '0.9' },
|
|
3542
|
+
{ path: '/compare/claude-code-hooks', changefreq: 'weekly', priority: '0.85' },
|
|
3543
|
+
{ path: '/compare/bumblebee', changefreq: 'weekly', priority: '0.85' },
|
|
3544
|
+
{ path: '/compare/anthropic-containment', changefreq: 'weekly', priority: '0.85' },
|
|
3545
|
+
{ path: '/compare/oak-and-sparrow-gatekeeper', changefreq: 'weekly', priority: '0.85' },
|
|
3546
|
+
{ path: '/compare/arcjet', changefreq: 'weekly', priority: '0.85' },
|
|
3547
|
+
{ path: '/compare/anthropic-claude-for-legal', changefreq: 'weekly', priority: '0.9' },
|
|
2507
3548
|
...THUMBGATE_SEO_SITEMAP_ENTRIES,
|
|
2508
3549
|
];
|
|
3550
|
+
// Auto-include every hand-written SEO page so /sitemap.xml can never drift out
|
|
3551
|
+
// of sync with public/compare/*.html or public/guides/*.html. Crawlers and AI
|
|
3552
|
+
// answer engines only surface pages they can discover, so a buyer-intent page
|
|
3553
|
+
// missing from the sitemap is invisible on its query. De-duped against entries
|
|
3554
|
+
// already declared above (e.g. seo-gsd specs), which keep explicit priorities.
|
|
3555
|
+
const declaredPaths = new Set(entries.map((entry) => entry.path));
|
|
3556
|
+
try {
|
|
3557
|
+
const seoDirectories = [
|
|
3558
|
+
{ dir: 'compare', route: '/compare', priority: '0.85' },
|
|
3559
|
+
{ dir: 'guides', route: '/guides', priority: '0.85' },
|
|
3560
|
+
];
|
|
3561
|
+
for (const catalog of seoDirectories) {
|
|
3562
|
+
const files = fs.readdirSync(path.join(PUBLIC_DIR, catalog.dir)).sort((a, b) => a.localeCompare(b));
|
|
3563
|
+
for (const file of files) {
|
|
3564
|
+
if (!file.endsWith('.html')) continue;
|
|
3565
|
+
const publicPath = `${catalog.route}/${file.replace(/\.html$/, '')}`;
|
|
3566
|
+
if (declaredPaths.has(publicPath)) continue;
|
|
3567
|
+
declaredPaths.add(publicPath);
|
|
3568
|
+
entries.push({ path: publicPath, changefreq: 'weekly', priority: catalog.priority });
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
} catch {
|
|
3572
|
+
// SEO directories absent in a stripped bundle — fall back to static entries.
|
|
3573
|
+
}
|
|
2509
3574
|
return [
|
|
2510
3575
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
2511
3576
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
@@ -2631,11 +3696,13 @@ function servePublicMarketingPage({
|
|
|
2631
3696
|
}, req.headers, 'seo_landing_view');
|
|
2632
3697
|
}
|
|
2633
3698
|
|
|
3699
|
+
const requestHost = getSafePublicRequestHost(req);
|
|
2634
3700
|
const html = renderHtml(hostedConfig, {
|
|
2635
3701
|
serverVisitorId: journeyState.visitorId,
|
|
2636
3702
|
serverSessionId: journeyState.sessionId,
|
|
2637
3703
|
serverAcquisitionId: journeyState.acquisitionId,
|
|
2638
3704
|
serverTelemetryCaptured: landingTelemetryCaptured,
|
|
3705
|
+
requestHost,
|
|
2639
3706
|
});
|
|
2640
3707
|
|
|
2641
3708
|
sendHtml(
|
|
@@ -2775,11 +3842,12 @@ function renderCheckoutSuccessPage(runtimeConfig) {
|
|
|
2775
3842
|
</style>
|
|
2776
3843
|
<link rel="icon" type="image/png" href="/thumbgate-icon.png">
|
|
2777
3844
|
<link rel="apple-touch-icon" href="/assets/brand/thumbgate-mark.svg">
|
|
2778
|
-
<script defer data-domain="thumbgate-production.up.railway.app" src="https://plausible.io/js/script.js"></script>
|
|
3845
|
+
<script defer data-domain="thumbgate-production.up.railway.app" src="https://plausible.io/js/script.tagged-events.js"></script>
|
|
3846
|
+
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments); };</script>
|
|
2779
3847
|
</head>
|
|
2780
3848
|
<body>
|
|
2781
3849
|
<main>
|
|
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>
|
|
3850
|
+
<a href="/" class="brand-header"><img src="/assets/brand/thumbgate-mark-inline-v3.svg" alt="ThumbGate" class="logo-mark" width="32" height="32"><span class="logo-text">ThumbGate</span></a>
|
|
2783
3851
|
<span class="eyebrow">ThumbGate Pro</span>
|
|
2784
3852
|
<h1>Your local Pro dashboard is ready.</h1>
|
|
2785
3853
|
<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>
|
|
@@ -2916,7 +3984,7 @@ function renderCheckoutSuccessPage(runtimeConfig) {
|
|
|
2916
3984
|
if (window.sessionStorage) {
|
|
2917
3985
|
window.sessionStorage.setItem(marker, '1');
|
|
2918
3986
|
}
|
|
2919
|
-
} catch
|
|
3987
|
+
} catch {
|
|
2920
3988
|
sendTelemetry(eventType, extra);
|
|
2921
3989
|
}
|
|
2922
3990
|
}
|
|
@@ -2953,6 +4021,7 @@ function renderCheckoutSuccessPage(runtimeConfig) {
|
|
|
2953
4021
|
}
|
|
2954
4022
|
|
|
2955
4023
|
sendTelemetryOnce('checkout_paid_confirmed');
|
|
4024
|
+
try { window.plausible && window.plausible('Checkout Pro Success Page Confirmed', { props: { sessionId: sessionId || '', traceId: traceId || '', source: 'success_page' } }); } catch (_) {}
|
|
2956
4025
|
statusEl.textContent = 'ThumbGate Pro activated.';
|
|
2957
4026
|
const resolvedTraceId = body.traceId || traceId || '';
|
|
2958
4027
|
const emailStatus = body.trialEmail || {};
|
|
@@ -3352,6 +4421,39 @@ function renderWorkflowSprintIntakeResultPage(runtimeConfig, { title, detail, le
|
|
|
3352
4421
|
</html>`;
|
|
3353
4422
|
}
|
|
3354
4423
|
|
|
4424
|
+
function renderBrokerAuditIntakeResultPage(runtimeConfig, { title, detail, leadId = null }) {
|
|
4425
|
+
const safeTitle = escapeHtmlAttribute(title || 'Broker audit request received');
|
|
4426
|
+
const safeDetail = escapeHtmlAttribute(detail || 'Your broker lead-flow audit request is in the queue.');
|
|
4427
|
+
const safeLeadId = leadId ? `<p><strong>Lead ID:</strong> ${escapeHtmlAttribute(leadId)}</p>` : '';
|
|
4428
|
+
return `<!DOCTYPE html>
|
|
4429
|
+
<html lang="en">
|
|
4430
|
+
<head>
|
|
4431
|
+
<meta charset="UTF-8" />
|
|
4432
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
4433
|
+
<title>${safeTitle}</title>
|
|
4434
|
+
<meta name="robots" content="noindex,nofollow" />
|
|
4435
|
+
<style>
|
|
4436
|
+
body { margin: 0; font-family: system-ui, -apple-system, sans-serif; background: #0b1220; color: #e5edf8; line-height: 1.6; }
|
|
4437
|
+
main { max-width: 680px; margin: 0 auto; padding: 72px 20px; }
|
|
4438
|
+
.card { border: 1px solid #26354f; border-radius: 14px; padding: 24px; background: #111a2b; }
|
|
4439
|
+
h1 { margin: 0 0 10px; font-size: 30px; }
|
|
4440
|
+
p { color: #b8c5d8; }
|
|
4441
|
+
a { color: #7dd3fc; font-weight: 700; text-decoration: none; }
|
|
4442
|
+
</style>
|
|
4443
|
+
</head>
|
|
4444
|
+
<body>
|
|
4445
|
+
<main>
|
|
4446
|
+
<div class="card">
|
|
4447
|
+
<h1>${safeTitle}</h1>
|
|
4448
|
+
<p>${safeDetail}</p>
|
|
4449
|
+
${safeLeadId}
|
|
4450
|
+
<p><a href="${escapeHtmlAttribute(runtimeConfig.appOrigin)}/broker-audit">Back to broker audit page</a></p>
|
|
4451
|
+
</div>
|
|
4452
|
+
</main>
|
|
4453
|
+
</body>
|
|
4454
|
+
</html>`;
|
|
4455
|
+
}
|
|
4456
|
+
|
|
3355
4457
|
function readBodyBuffer(req, maxBytes = 1024 * 1024) {
|
|
3356
4458
|
return new Promise((resolve, reject) => {
|
|
3357
4459
|
let total = 0;
|
|
@@ -3396,6 +4498,72 @@ async function parseFormBody(req, maxBytes = 1024 * 1024) {
|
|
|
3396
4498
|
return Object.fromEntries(params.entries());
|
|
3397
4499
|
}
|
|
3398
4500
|
|
|
4501
|
+
function normalizeLeadEmail(value) {
|
|
4502
|
+
const email = normalizeNullableText(value);
|
|
4503
|
+
if (!email || !/^[^\s@]{1,64}@[^\s@]{1,255}\.[^\s@]{1,63}$/.test(email)) {
|
|
4504
|
+
throw createHttpError(400, 'A valid email is required');
|
|
4505
|
+
}
|
|
4506
|
+
return email.toLowerCase();
|
|
4507
|
+
}
|
|
4508
|
+
|
|
4509
|
+
function normalizeHttpUrl(value, fieldName) {
|
|
4510
|
+
const raw = normalizeNullableText(value);
|
|
4511
|
+
if (!raw) throw createHttpError(400, `${fieldName} is required`);
|
|
4512
|
+
try {
|
|
4513
|
+
const parsedUrl = new URL(raw);
|
|
4514
|
+
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
4515
|
+
throw new Error('unsupported protocol');
|
|
4516
|
+
}
|
|
4517
|
+
return parsedUrl.toString();
|
|
4518
|
+
} catch {
|
|
4519
|
+
throw createHttpError(400, `${fieldName} must be an http(s) URL`);
|
|
4520
|
+
}
|
|
4521
|
+
}
|
|
4522
|
+
|
|
4523
|
+
function appendBrokerAuditLead(feedbackDir, payload) {
|
|
4524
|
+
const lead = {
|
|
4525
|
+
leadId: createTraceId('broker_audit_lead'),
|
|
4526
|
+
status: 'new',
|
|
4527
|
+
offer: 'broker_lead_flow_audit_free',
|
|
4528
|
+
requestedAt: new Date().toISOString(),
|
|
4529
|
+
contact: {
|
|
4530
|
+
name: normalizeNullableText(payload.name),
|
|
4531
|
+
email: normalizeLeadEmail(payload.email),
|
|
4532
|
+
brokerage: normalizeNullableText(payload.brokerage),
|
|
4533
|
+
website: normalizeHttpUrl(payload.website, 'website'),
|
|
4534
|
+
},
|
|
4535
|
+
qualification: {
|
|
4536
|
+
suspectedLeak: normalizeNullableText(payload.suspected_leak || payload.suspectedLeak),
|
|
4537
|
+
},
|
|
4538
|
+
attribution: {
|
|
4539
|
+
traceId: payload.traceId,
|
|
4540
|
+
acquisitionId: payload.acquisitionId,
|
|
4541
|
+
visitorId: payload.visitorId,
|
|
4542
|
+
sessionId: payload.sessionId,
|
|
4543
|
+
source: payload.source,
|
|
4544
|
+
utmSource: payload.utmSource,
|
|
4545
|
+
utmMedium: payload.utmMedium,
|
|
4546
|
+
utmCampaign: payload.utmCampaign,
|
|
4547
|
+
utmContent: payload.utmContent,
|
|
4548
|
+
utmTerm: payload.utmTerm,
|
|
4549
|
+
ctaId: payload.ctaId,
|
|
4550
|
+
ctaPlacement: payload.ctaPlacement,
|
|
4551
|
+
page: payload.page,
|
|
4552
|
+
landingPath: payload.landingPath,
|
|
4553
|
+
referrer: payload.referrer,
|
|
4554
|
+
referrerHost: payload.referrerHost,
|
|
4555
|
+
},
|
|
4556
|
+
};
|
|
4557
|
+
|
|
4558
|
+
if (!lead.contact.name) throw createHttpError(400, 'name is required');
|
|
4559
|
+
if (!lead.contact.brokerage) throw createHttpError(400, 'brokerage is required');
|
|
4560
|
+
|
|
4561
|
+
const leadsPath = path.join(feedbackDir, 'broker-audit-leads.jsonl');
|
|
4562
|
+
fs.mkdirSync(path.dirname(leadsPath), { recursive: true });
|
|
4563
|
+
fs.appendFileSync(leadsPath, `${JSON.stringify(lead)}\n`, 'utf8');
|
|
4564
|
+
return lead;
|
|
4565
|
+
}
|
|
4566
|
+
|
|
3399
4567
|
function parseOptionalObject(input, name) {
|
|
3400
4568
|
if (input == null) return {};
|
|
3401
4569
|
if (typeof input === 'object' && !Array.isArray(input)) return input;
|
|
@@ -3470,6 +4638,49 @@ function extractApiKey(req) {
|
|
|
3470
4638
|
return '';
|
|
3471
4639
|
}
|
|
3472
4640
|
|
|
4641
|
+
/**
|
|
4642
|
+
* Map a ThumbGate key presented at the OAuth consent screen to a role.
|
|
4643
|
+
*
|
|
4644
|
+
* - When any key is configured (production), the presented key MUST match a
|
|
4645
|
+
* configured admin/operator/reviewer key; otherwise returns null (reject) so
|
|
4646
|
+
* the OAuth flow actually authenticates the holder.
|
|
4647
|
+
* - When NO keys are configured (insecure/dev mode, e.g. local tests), any
|
|
4648
|
+
* non-empty key is accepted as role 'dev' to preserve local development.
|
|
4649
|
+
*
|
|
4650
|
+
* 'reviewer' (THUMBGATE_REVIEWER_KEY) is a read-only, independently-revocable
|
|
4651
|
+
* credential safe to share with a directory reviewer — tool execution enforces
|
|
4652
|
+
* read-only for it (see the tools/call handler).
|
|
4653
|
+
*/
|
|
4654
|
+
// Constant-time comparison of two API/OAuth bearer keys. We compare the raw
|
|
4655
|
+
// bytes with crypto.timingSafeEqual after an explicit equal-length guard
|
|
4656
|
+
// (timingSafeEqual throws on a length mismatch). No digest is taken: these are
|
|
4657
|
+
// high-entropy random credentials compared transiently at request time, not
|
|
4658
|
+
// user passwords stored at rest, so neither a KDF (bcrypt/scrypt/argon2) nor a
|
|
4659
|
+
// length-normalizing hash is appropriate — and hashing the credential here is
|
|
4660
|
+
// exactly what static analysis (correctly, for *stored* passwords) flags. The
|
|
4661
|
+
// only side-channel is whether the two keys share a length, which reveals
|
|
4662
|
+
// nothing useful about a random secret. Once lengths match, the byte compare
|
|
4663
|
+
// is constant-time and leaks no information about where they differ.
|
|
4664
|
+
function safeKeyEqual(a, b) {
|
|
4665
|
+
const x = Buffer.from(String(a || ''), 'utf8');
|
|
4666
|
+
const y = Buffer.from(String(b || ''), 'utf8');
|
|
4667
|
+
if (x.length === 0 || y.length === 0 || x.length !== y.length) return false;
|
|
4668
|
+
return require('crypto').timingSafeEqual(x, y);
|
|
4669
|
+
}
|
|
4670
|
+
|
|
4671
|
+
function resolveKeyRole(key) {
|
|
4672
|
+
const k = String(key || '').trim();
|
|
4673
|
+
const adminKey = String(process.env.THUMBGATE_API_KEY || '').trim();
|
|
4674
|
+
const operatorKey = String(process.env.THUMBGATE_OPERATOR_KEY || '').trim();
|
|
4675
|
+
const reviewerKey = String(process.env.THUMBGATE_REVIEWER_KEY || '').trim();
|
|
4676
|
+
const configured = [adminKey, operatorKey, reviewerKey].filter(Boolean);
|
|
4677
|
+
if (configured.length === 0) return k ? 'dev' : null;
|
|
4678
|
+
if (safeKeyEqual(k, adminKey)) return 'admin';
|
|
4679
|
+
if (safeKeyEqual(k, operatorKey)) return 'operator';
|
|
4680
|
+
if (safeKeyEqual(k, reviewerKey)) return 'reviewer';
|
|
4681
|
+
return null;
|
|
4682
|
+
}
|
|
4683
|
+
|
|
3473
4684
|
/**
|
|
3474
4685
|
* Admin-only guard for static THUMBGATE_API_KEY.
|
|
3475
4686
|
* Billing keys are intentionally excluded from admin actions.
|
|
@@ -3570,6 +4781,42 @@ function normalizeJobIdFromPath(pathname, suffix = '') {
|
|
|
3570
4781
|
return match ? decodeURIComponent(match[1]) : null;
|
|
3571
4782
|
}
|
|
3572
4783
|
|
|
4784
|
+
function escapeHtml(value) {
|
|
4785
|
+
return String(value)
|
|
4786
|
+
.replaceAll('&', '&')
|
|
4787
|
+
.replaceAll('<', '<')
|
|
4788
|
+
.replaceAll('>', '>')
|
|
4789
|
+
.replaceAll('"', '"')
|
|
4790
|
+
.replaceAll("'", ''');
|
|
4791
|
+
}
|
|
4792
|
+
|
|
4793
|
+
function escapeHtmlUnsafeJsonString(value) {
|
|
4794
|
+
return String(value)
|
|
4795
|
+
.replaceAll('<', String.raw`\u003c`)
|
|
4796
|
+
.replaceAll('>', String.raw`\u003e`)
|
|
4797
|
+
.replaceAll('&', String.raw`\u0026`)
|
|
4798
|
+
.replaceAll('\u2028', String.raw`\u2028`)
|
|
4799
|
+
.replaceAll('\u2029', String.raw`\u2029`);
|
|
4800
|
+
}
|
|
4801
|
+
|
|
4802
|
+
function sanitizeHtmlUnsafeJsonValue(value) {
|
|
4803
|
+
if (typeof value === 'string') {
|
|
4804
|
+
return escapeHtmlUnsafeJsonString(value);
|
|
4805
|
+
}
|
|
4806
|
+
if (Array.isArray(value)) {
|
|
4807
|
+
return value.map((entry) => sanitizeHtmlUnsafeJsonValue(entry));
|
|
4808
|
+
}
|
|
4809
|
+
if (!value || typeof value !== 'object') {
|
|
4810
|
+
return value;
|
|
4811
|
+
}
|
|
4812
|
+
return Object.fromEntries(
|
|
4813
|
+
Object.entries(value).map(([key, entry]) => [
|
|
4814
|
+
escapeHtmlUnsafeJsonString(key),
|
|
4815
|
+
sanitizeHtmlUnsafeJsonValue(entry),
|
|
4816
|
+
])
|
|
4817
|
+
);
|
|
4818
|
+
}
|
|
4819
|
+
|
|
3573
4820
|
function normalizeDocumentIdFromPath(pathname) {
|
|
3574
4821
|
const match = pathname.match(/^\/v1\/documents\/([^/]+)$/);
|
|
3575
4822
|
return match ? decodeURIComponent(match[1]) : null;
|
|
@@ -3679,12 +4926,75 @@ function createApiServer() {
|
|
|
3679
4926
|
tools: getPublicMcpTools(),
|
|
3680
4927
|
},
|
|
3681
4928
|
});
|
|
4929
|
+
} else if (msg.method === 'tools/call') {
|
|
4930
|
+
// Authenticated tool execution. Accept either an OAuth 2.1 access
|
|
4931
|
+
// token (audience-bound to this MCP server, RFC 8707) or a raw
|
|
4932
|
+
// ThumbGate API key, both via the Bearer header.
|
|
4933
|
+
const bearer = extractBearerToken(req);
|
|
4934
|
+
const resourceUrl = buildPublicUrl(hostedConfig, '/mcp');
|
|
4935
|
+
const oauthSession = mcpOauth.resolveAccessToken(oauthStore, bearer);
|
|
4936
|
+
// OAuth path: token must resolve AND be audience-bound to this server
|
|
4937
|
+
// (RFC 8707). Raw-key path: only an exact match to a configured
|
|
4938
|
+
// operator/admin key — never "any non-empty bearer".
|
|
4939
|
+
const adminKey = String(process.env.THUMBGATE_API_KEY || '').trim();
|
|
4940
|
+
const operatorKey = String(process.env.THUMBGATE_OPERATOR_KEY || '').trim();
|
|
4941
|
+
const reviewerKey = String(process.env.THUMBGATE_REVIEWER_KEY || '').trim();
|
|
4942
|
+
const rawKeyValid = Boolean(bearer) && (safeKeyEqual(bearer, adminKey) || safeKeyEqual(bearer, operatorKey) || safeKeyEqual(bearer, reviewerKey));
|
|
4943
|
+
const authed = oauthSession
|
|
4944
|
+
? mcpOauth.tokenAudienceValid(oauthSession, resourceUrl)
|
|
4945
|
+
: rawKeyValid;
|
|
4946
|
+
if (!authed) {
|
|
4947
|
+
res.writeHead(401, {
|
|
4948
|
+
'Content-Type': 'application/json',
|
|
4949
|
+
// RFC 9728: point unauthenticated clients at the resource metadata.
|
|
4950
|
+
'WWW-Authenticate': `Bearer resource_metadata="${buildPublicUrl(hostedConfig, '/.well-known/oauth-protected-resource')}"`,
|
|
4951
|
+
});
|
|
4952
|
+
res.end(JSON.stringify({
|
|
4953
|
+
jsonrpc: '2.0', id: msg.id,
|
|
4954
|
+
error: { code: -32001, message: 'Authentication required. Use OAuth 2.1 (see /.well-known/oauth-protected-resource) or a ThumbGate API key.' },
|
|
4955
|
+
}));
|
|
4956
|
+
return;
|
|
4957
|
+
}
|
|
4958
|
+
// The reviewer credential (THUMBGATE_REVIEWER_KEY) is read-only: it may
|
|
4959
|
+
// only invoke tools annotated readOnlyHint:true. This makes a credential
|
|
4960
|
+
// safe to share (e.g. with a directory reviewer) without granting the
|
|
4961
|
+
// ability to mutate shared server state.
|
|
4962
|
+
const effectiveKey = oauthSession ? String(oauthSession.boundKey || '') : bearer;
|
|
4963
|
+
const isReviewer = Boolean(reviewerKey) && safeKeyEqual(effectiveKey, reviewerKey);
|
|
4964
|
+
if (isReviewer) {
|
|
4965
|
+
const name = msg.params && msg.params.name;
|
|
4966
|
+
const tool = MCP_TOOLS.find((t) => t.name === name);
|
|
4967
|
+
const readOnly = Boolean(tool && tool.annotations && tool.annotations.readOnlyHint === true);
|
|
4968
|
+
if (!readOnly) {
|
|
4969
|
+
sendJson(res, 200, {
|
|
4970
|
+
jsonrpc: '2.0', id: msg.id,
|
|
4971
|
+
error: { code: -32002, message: `Tool "${name}" requires write access; the reviewer credential is read-only.` },
|
|
4972
|
+
});
|
|
4973
|
+
return;
|
|
4974
|
+
}
|
|
4975
|
+
}
|
|
4976
|
+
(async () => {
|
|
4977
|
+
try {
|
|
4978
|
+
const { callTool } = require('../../adapters/mcp/server-stdio');
|
|
4979
|
+
const name = msg.params && msg.params.name;
|
|
4980
|
+
const args = (msg.params && msg.params.arguments) || {};
|
|
4981
|
+
const result = await callTool(name, args);
|
|
4982
|
+
sendJson(res, 200, {
|
|
4983
|
+
jsonrpc: '2.0', id: msg.id,
|
|
4984
|
+
result: { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result) }] },
|
|
4985
|
+
});
|
|
4986
|
+
} catch (err) {
|
|
4987
|
+
sendJson(res, 200, {
|
|
4988
|
+
jsonrpc: '2.0', id: msg.id,
|
|
4989
|
+
result: { isError: true, content: [{ type: 'text', text: String(err && err.message || err) }] },
|
|
4990
|
+
});
|
|
4991
|
+
}
|
|
4992
|
+
})();
|
|
3682
4993
|
} else {
|
|
3683
|
-
// All other tool calls require auth — return method not found for unauthenticated
|
|
3684
4994
|
sendJson(res, 200, {
|
|
3685
4995
|
jsonrpc: '2.0',
|
|
3686
4996
|
id: msg.id,
|
|
3687
|
-
error: { code: -32601, message:
|
|
4997
|
+
error: { code: -32601, message: `Method not found: ${msg.method}` },
|
|
3688
4998
|
});
|
|
3689
4999
|
}
|
|
3690
5000
|
} catch (_e) {
|
|
@@ -3772,7 +5082,10 @@ function createApiServer() {
|
|
|
3772
5082
|
});
|
|
3773
5083
|
sendJson(res, 200, result);
|
|
3774
5084
|
} catch (e) {
|
|
3775
|
-
sendJson(res, 500, {
|
|
5085
|
+
sendJson(res, 500, {
|
|
5086
|
+
error: 'feedback submission failed',
|
|
5087
|
+
message: e?.message || 'Unable to submit dashboard feedback.',
|
|
5088
|
+
});
|
|
3776
5089
|
}
|
|
3777
5090
|
});
|
|
3778
5091
|
return;
|
|
@@ -3908,7 +5221,7 @@ function createApiServer() {
|
|
|
3908
5221
|
if (req.method === 'POST' && pathname === '/api/event') {
|
|
3909
5222
|
// Filter bots from analytics to keep Plausible data clean
|
|
3910
5223
|
let _botDetector;
|
|
3911
|
-
try { _botDetector = require('../../scripts/bot-
|
|
5224
|
+
try { _botDetector = require('../../scripts/bot-detection'); } catch (_e) { _botDetector = null; }
|
|
3912
5225
|
if (_botDetector && _botDetector.shouldExcludeFromAnalytics(req)) {
|
|
3913
5226
|
sendJson(res, 202, { status: 'filtered', reason: 'bot' });
|
|
3914
5227
|
return;
|
|
@@ -3961,7 +5274,7 @@ function createApiServer() {
|
|
|
3961
5274
|
return;
|
|
3962
5275
|
}
|
|
3963
5276
|
|
|
3964
|
-
if (isGetLikeRequest && pathname === '/.well-known/llms.txt') {
|
|
5277
|
+
if (isGetLikeRequest && (pathname === '/.well-known/llms.txt' || pathname === '/llms.txt')) {
|
|
3965
5278
|
const llmsTxtPath = path.join(__dirname, '..', '..', '.well-known', 'llms.txt');
|
|
3966
5279
|
try {
|
|
3967
5280
|
const content = fs.readFileSync(llmsTxtPath, 'utf8');
|
|
@@ -3972,11 +5285,22 @@ function createApiServer() {
|
|
|
3972
5285
|
return;
|
|
3973
5286
|
}
|
|
3974
5287
|
|
|
3975
|
-
if (isGetLikeRequest && pathname === '/
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
headOnly: isHeadRequest
|
|
5288
|
+
if (isGetLikeRequest && pathname === '/.well-known/agentic-verify.txt') {
|
|
5289
|
+
const agenticVerifyPath = path.join(__dirname, '..', '..', '.well-known', 'agentic-verify.txt');
|
|
5290
|
+
try {
|
|
5291
|
+
const content = fs.readFileSync(agenticVerifyPath, 'utf8');
|
|
5292
|
+
sendText(res, 200, content, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'public, max-age=3600' }, { headOnly: isHeadRequest });
|
|
5293
|
+
} catch {
|
|
5294
|
+
sendJson(res, 404, { error: 'agentic verification file not found' });
|
|
5295
|
+
}
|
|
5296
|
+
return;
|
|
5297
|
+
}
|
|
5298
|
+
|
|
5299
|
+
if (isGetLikeRequest && pathname === '/sitemap.xml') {
|
|
5300
|
+
sendText(res, 200, renderSitemapXml(hostedConfig), {
|
|
5301
|
+
'Content-Type': 'application/xml; charset=utf-8',
|
|
5302
|
+
}, {
|
|
5303
|
+
headOnly: isHeadRequest,
|
|
3980
5304
|
});
|
|
3981
5305
|
return;
|
|
3982
5306
|
}
|
|
@@ -4264,12 +5588,66 @@ async function addContext(){
|
|
|
4264
5588
|
return;
|
|
4265
5589
|
}
|
|
4266
5590
|
|
|
5591
|
+
if (isGetLikeRequest && (pathname === '/about' || pathname === '/about.html')) {
|
|
5592
|
+
try {
|
|
5593
|
+
servePublicMarketingPage({
|
|
5594
|
+
req,
|
|
5595
|
+
res,
|
|
5596
|
+
parsed,
|
|
5597
|
+
hostedConfig,
|
|
5598
|
+
isHeadRequest,
|
|
5599
|
+
renderHtml: loadAboutPageHtml,
|
|
5600
|
+
extraTelemetry: {
|
|
5601
|
+
pageType: 'about',
|
|
5602
|
+
},
|
|
5603
|
+
});
|
|
5604
|
+
} catch (err) {
|
|
5605
|
+
sendText(res, 500, err.message || 'About page unavailable');
|
|
5606
|
+
}
|
|
5607
|
+
return;
|
|
5608
|
+
}
|
|
5609
|
+
|
|
4267
5610
|
if (isGetLikeRequest && (pathname === '/guide' || pathname === '/guide.html')) {
|
|
4268
5611
|
try {
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
5612
|
+
servePublicMarketingPage({
|
|
5613
|
+
req,
|
|
5614
|
+
res,
|
|
5615
|
+
parsed,
|
|
5616
|
+
hostedConfig,
|
|
5617
|
+
isHeadRequest,
|
|
5618
|
+
renderHtml: loadGuidePageHtml,
|
|
5619
|
+
extraTelemetry: {
|
|
5620
|
+
pageType: 'guide',
|
|
5621
|
+
},
|
|
5622
|
+
});
|
|
5623
|
+
} catch (err) {
|
|
5624
|
+
sendText(res, 500, err.message || 'Guide page unavailable');
|
|
5625
|
+
}
|
|
5626
|
+
return;
|
|
5627
|
+
}
|
|
5628
|
+
|
|
5629
|
+
if (isGetLikeRequest && (
|
|
5630
|
+
pathname === '/install'
|
|
5631
|
+
|| pathname === '/install.html'
|
|
5632
|
+
|| pathname === '/marketplace'
|
|
5633
|
+
|| pathname === '/marketplace.html'
|
|
5634
|
+
|| pathname === '/marketplaces'
|
|
5635
|
+
|| pathname === '/marketplaces.html'
|
|
5636
|
+
)) {
|
|
5637
|
+
try {
|
|
5638
|
+
servePublicMarketingPage({
|
|
5639
|
+
req,
|
|
5640
|
+
res,
|
|
5641
|
+
parsed,
|
|
5642
|
+
hostedConfig,
|
|
5643
|
+
isHeadRequest,
|
|
5644
|
+
renderHtml: loadInstallPageHtml,
|
|
5645
|
+
extraTelemetry: {
|
|
5646
|
+
pageType: 'install',
|
|
5647
|
+
},
|
|
5648
|
+
});
|
|
5649
|
+
} catch (err) {
|
|
5650
|
+
sendText(res, 500, err.message || 'Install page unavailable');
|
|
4273
5651
|
}
|
|
4274
5652
|
return;
|
|
4275
5653
|
}
|
|
@@ -4284,6 +5662,28 @@ async function addContext(){
|
|
|
4284
5662
|
return;
|
|
4285
5663
|
}
|
|
4286
5664
|
|
|
5665
|
+
if (isGetLikeRequest && (
|
|
5666
|
+
pathname === '/chatgpt-app'
|
|
5667
|
+
|| pathname === '/chatgpt-app.html'
|
|
5668
|
+
|| pathname === '/chatgpt-plugin'
|
|
5669
|
+
|| pathname === '/chatgpt-plugin.html'
|
|
5670
|
+
)) {
|
|
5671
|
+
try {
|
|
5672
|
+
servePublicMarketingPage({
|
|
5673
|
+
req,
|
|
5674
|
+
res,
|
|
5675
|
+
parsed,
|
|
5676
|
+
hostedConfig,
|
|
5677
|
+
isHeadRequest,
|
|
5678
|
+
renderHtml: () => fs.readFileSync(CHATGPT_APP_PAGE_PATH, 'utf-8'),
|
|
5679
|
+
extraTelemetry: { pageType: 'chatgpt_app' },
|
|
5680
|
+
});
|
|
5681
|
+
} catch {
|
|
5682
|
+
sendJson(res, 404, { error: 'ChatGPT app page not found' });
|
|
5683
|
+
}
|
|
5684
|
+
return;
|
|
5685
|
+
}
|
|
5686
|
+
|
|
4287
5687
|
if (isGetLikeRequest && (pathname === '/compare' || pathname === '/compare.html')) {
|
|
4288
5688
|
try {
|
|
4289
5689
|
const html = fs.readFileSync(COMPARE_PAGE_PATH, 'utf-8');
|
|
@@ -4307,10 +5707,19 @@ async function addContext(){
|
|
|
4307
5707
|
|
|
4308
5708
|
if (isGetLikeRequest && (pathname === '/learn' || pathname === '/learn.html')) {
|
|
4309
5709
|
try {
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
5710
|
+
servePublicMarketingPage({
|
|
5711
|
+
req,
|
|
5712
|
+
res,
|
|
5713
|
+
parsed,
|
|
5714
|
+
hostedConfig,
|
|
5715
|
+
isHeadRequest,
|
|
5716
|
+
renderHtml: loadLearnPageHtml,
|
|
5717
|
+
extraTelemetry: {
|
|
5718
|
+
pageType: 'learn',
|
|
5719
|
+
},
|
|
5720
|
+
});
|
|
5721
|
+
} catch (err) {
|
|
5722
|
+
sendText(res, 500, err.message || 'Learn page unavailable');
|
|
4314
5723
|
}
|
|
4315
5724
|
return;
|
|
4316
5725
|
}
|
|
@@ -4333,13 +5742,16 @@ async function addContext(){
|
|
|
4333
5742
|
return;
|
|
4334
5743
|
}
|
|
4335
5744
|
|
|
4336
|
-
// Natural marketing URLs for the Workflow Hardening Sprint.
|
|
4337
|
-
//
|
|
4338
|
-
//
|
|
4339
|
-
//
|
|
4340
|
-
// Redirect them to the canonical intake anchor instead.
|
|
5745
|
+
// Natural marketing URLs for the Workflow Hardening Diagnostic/Sprint.
|
|
5746
|
+
// High-intent outreach refers to "the diagnostic", "the sprint", or
|
|
5747
|
+
// "workflow hardening"; serve a focused buyer page instead of sending
|
|
5748
|
+
// qualified traffic to the generic homepage anchor.
|
|
4341
5749
|
if (isGetLikeRequest && (
|
|
4342
|
-
pathname === '/
|
|
5750
|
+
pathname === '/diagnostic'
|
|
5751
|
+
|| pathname === '/diagnostic.html'
|
|
5752
|
+
|| pathname === '/workflow-diagnostic'
|
|
5753
|
+
|| pathname === '/workflow-diagnostic.html'
|
|
5754
|
+
|| pathname === '/sprint'
|
|
4343
5755
|
|| pathname === '/sprint.html'
|
|
4344
5756
|
|| pathname === '/workflow-hardening'
|
|
4345
5757
|
|| pathname === '/workflow-hardening.html'
|
|
@@ -4348,11 +5760,21 @@ async function addContext(){
|
|
|
4348
5760
|
|| pathname === '/workflow-sprint'
|
|
4349
5761
|
|| pathname === '/workflow-sprint.html'
|
|
4350
5762
|
)) {
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
5763
|
+
try {
|
|
5764
|
+
servePublicMarketingPage({
|
|
5765
|
+
req,
|
|
5766
|
+
res,
|
|
5767
|
+
parsed,
|
|
5768
|
+
hostedConfig,
|
|
5769
|
+
isHeadRequest,
|
|
5770
|
+
renderHtml: loadDiagnosticPageHtml,
|
|
5771
|
+
extraTelemetry: {
|
|
5772
|
+
pageType: 'diagnostic',
|
|
5773
|
+
},
|
|
5774
|
+
});
|
|
5775
|
+
} catch (err) {
|
|
5776
|
+
sendText(res, 500, err.message || 'Diagnostic page unavailable');
|
|
5777
|
+
}
|
|
4356
5778
|
return;
|
|
4357
5779
|
}
|
|
4358
5780
|
|
|
@@ -4398,6 +5820,94 @@ async function addContext(){
|
|
|
4398
5820
|
return;
|
|
4399
5821
|
}
|
|
4400
5822
|
|
|
5823
|
+
if (isGetLikeRequest && (pathname === '/agent-manager' || pathname === '/agent-manager.html')) {
|
|
5824
|
+
// ICP landing page for the role Anthropic named (Agent Manager —
|
|
5825
|
+
// hybrid PM/engineer DRI who owns CLAUDE.md hierarchy, plugin
|
|
5826
|
+
// marketplace, permissions policy, and which skills ship). Routed
|
|
5827
|
+
// through servePublicMarketingPage so arrivals via X/Bluesky/LinkedIn
|
|
5828
|
+
// threads about the role capture UTM attribution and
|
|
5829
|
+
// landing_page_view telemetry for downstream pilot-pipeline analysis.
|
|
5830
|
+
try {
|
|
5831
|
+
servePublicMarketingPage({
|
|
5832
|
+
req,
|
|
5833
|
+
res,
|
|
5834
|
+
parsed,
|
|
5835
|
+
hostedConfig,
|
|
5836
|
+
isHeadRequest,
|
|
5837
|
+
renderHtml: () => fs.readFileSync(path.join(PUBLIC_DIR, 'agent-manager.html'), 'utf-8'),
|
|
5838
|
+
extraTelemetry: { pageType: 'agent_manager' },
|
|
5839
|
+
});
|
|
5840
|
+
} catch {
|
|
5841
|
+
sendJson(res, 404, { error: 'Agent Manager page not found' });
|
|
5842
|
+
}
|
|
5843
|
+
return;
|
|
5844
|
+
}
|
|
5845
|
+
|
|
5846
|
+
if (isGetLikeRequest && (pathname === '/codex-enterprise' || pathname === '/codex-enterprise.html')) {
|
|
5847
|
+
// Landing page riding the 2026-05-20 OpenAI×Dell Codex Enterprise
|
|
5848
|
+
// partnership announcement. Dell-distributed Codex expands the TAM
|
|
5849
|
+
// for ThumbGate's governance layer — capture every agent decision,
|
|
5850
|
+
// promote repeat failures to PreToolUse gates, ship the audit trail
|
|
5851
|
+
// procurement requires. Routed through servePublicMarketingPage so
|
|
5852
|
+
// arrivals via the partnership news cycle capture UTM attribution
|
|
5853
|
+
// and landing_page_view telemetry with pageType: 'codex_enterprise'.
|
|
5854
|
+
try {
|
|
5855
|
+
servePublicMarketingPage({
|
|
5856
|
+
req,
|
|
5857
|
+
res,
|
|
5858
|
+
parsed,
|
|
5859
|
+
hostedConfig,
|
|
5860
|
+
isHeadRequest,
|
|
5861
|
+
renderHtml: () => fs.readFileSync(path.join(PUBLIC_DIR, 'codex-enterprise.html'), 'utf-8'),
|
|
5862
|
+
extraTelemetry: { pageType: 'codex_enterprise' },
|
|
5863
|
+
});
|
|
5864
|
+
} catch {
|
|
5865
|
+
sendJson(res, 404, { error: 'Codex Enterprise page not found' });
|
|
5866
|
+
}
|
|
5867
|
+
return;
|
|
5868
|
+
}
|
|
5869
|
+
|
|
5870
|
+
if (isGetLikeRequest && (pathname === '/agents-cost-savings' || pathname === '/agents-cost-savings.html')) {
|
|
5871
|
+
// FinOps-for-AI positioning page. Pairs with the `thumbgate cost` CLI
|
|
5872
|
+
// shipped in #2281: the CLI prints the dollar amount, this page is
|
|
5873
|
+
// the public-facing explanation of why "prevention" (ThumbGate's
|
|
5874
|
+
// PreToolUse gates) is a distinct category from "reporting" (Finout,
|
|
5875
|
+
// Helicone, Vantage, AgentOps). Reply-to-pitch surface for buyers
|
|
5876
|
+
// who get a FinOps-for-AI marketing email and need a frame.
|
|
5877
|
+
try {
|
|
5878
|
+
servePublicMarketingPage({
|
|
5879
|
+
req,
|
|
5880
|
+
res,
|
|
5881
|
+
parsed,
|
|
5882
|
+
hostedConfig,
|
|
5883
|
+
isHeadRequest,
|
|
5884
|
+
renderHtml: () => fs.readFileSync(path.join(PUBLIC_DIR, 'agents-cost-savings.html'), 'utf-8'),
|
|
5885
|
+
extraTelemetry: { pageType: 'agents_cost_savings' },
|
|
5886
|
+
});
|
|
5887
|
+
} catch {
|
|
5888
|
+
sendJson(res, 404, { error: 'Agents Cost Savings page not found' });
|
|
5889
|
+
}
|
|
5890
|
+
return;
|
|
5891
|
+
}
|
|
5892
|
+
|
|
5893
|
+
if (isGetLikeRequest && (pathname === '/ai-malpractice-prevention' || pathname === '/ai-malpractice-prevention.html')) {
|
|
5894
|
+
// Legal-vertical landing page (2026-05-21).
|
|
5895
|
+
try {
|
|
5896
|
+
servePublicMarketingPage({
|
|
5897
|
+
req,
|
|
5898
|
+
res,
|
|
5899
|
+
parsed,
|
|
5900
|
+
hostedConfig,
|
|
5901
|
+
isHeadRequest,
|
|
5902
|
+
renderHtml: () => fs.readFileSync(path.join(PUBLIC_DIR, 'ai-malpractice-prevention.html'), 'utf-8'),
|
|
5903
|
+
extraTelemetry: { pageType: 'ai_malpractice_prevention' },
|
|
5904
|
+
});
|
|
5905
|
+
} catch {
|
|
5906
|
+
sendJson(res, 404, { error: 'AI Malpractice Prevention page not found' });
|
|
5907
|
+
}
|
|
5908
|
+
return;
|
|
5909
|
+
}
|
|
5910
|
+
|
|
4401
5911
|
if (isGetLikeRequest && pathname === '/learn/learn.css') {
|
|
4402
5912
|
try {
|
|
4403
5913
|
const cssPath = path.join(LEARN_DIR, 'learn.css');
|
|
@@ -4413,13 +5923,13 @@ async function addContext(){
|
|
|
4413
5923
|
|
|
4414
5924
|
if (isGetLikeRequest && pathname.startsWith('/learn/')) {
|
|
4415
5925
|
try {
|
|
4416
|
-
const
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
sendJson(res, 403, { error: 'Forbidden' });
|
|
5926
|
+
const articlePath = resolvePublicHtmlFile(LEARN_PAGE_PATHS_BY_SLUG, pathname.replace('/learn/', ''));
|
|
5927
|
+
if (!articlePath) {
|
|
5928
|
+
sendJson(res, 404, { error: 'Article not found' });
|
|
4420
5929
|
return;
|
|
4421
5930
|
}
|
|
4422
|
-
const
|
|
5931
|
+
const requestHost = getSafePublicRequestHost(req);
|
|
5932
|
+
const html = normalizePublicMarketingHtml(fs.readFileSync(articlePath, 'utf-8'), hostedConfig, requestHost);
|
|
4423
5933
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
4424
5934
|
} catch {
|
|
4425
5935
|
sendJson(res, 404, { error: 'Article not found' });
|
|
@@ -4429,10 +5939,10 @@ async function addContext(){
|
|
|
4429
5939
|
|
|
4430
5940
|
if (isGetLikeRequest && pathname.startsWith('/guides/')) {
|
|
4431
5941
|
try {
|
|
4432
|
-
const
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
const html = fs.readFileSync(guidePath, 'utf-8');
|
|
5942
|
+
const guidePath = resolvePublicHtmlFile(GUIDE_PAGE_PATHS_BY_SLUG, pathname.replace('/guides/', ''));
|
|
5943
|
+
if (!guidePath) { sendJson(res, 404, { error: 'Guide not found' }); return; }
|
|
5944
|
+
const requestHost = getSafePublicRequestHost(req);
|
|
5945
|
+
const html = normalizePublicMarketingHtml(fs.readFileSync(guidePath, 'utf-8'), hostedConfig, requestHost);
|
|
4436
5946
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
4437
5947
|
} catch { sendJson(res, 404, { error: 'Guide not found' }); }
|
|
4438
5948
|
return;
|
|
@@ -4440,10 +5950,10 @@ async function addContext(){
|
|
|
4440
5950
|
|
|
4441
5951
|
if (isGetLikeRequest && pathname.startsWith('/compare/') && pathname !== '/compare') {
|
|
4442
5952
|
try {
|
|
4443
|
-
const
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
const html = fs.readFileSync(comparePath, 'utf-8');
|
|
5953
|
+
const comparePath = resolvePublicHtmlFile(COMPARE_PAGE_PATHS_BY_SLUG, pathname.replace('/compare/', ''));
|
|
5954
|
+
if (!comparePath) { sendJson(res, 404, { error: 'Comparison not found' }); return; }
|
|
5955
|
+
const requestHost = getSafePublicRequestHost(req);
|
|
5956
|
+
const html = normalizePublicMarketingHtml(fs.readFileSync(comparePath, 'utf-8'), hostedConfig, requestHost);
|
|
4447
5957
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
4448
5958
|
} catch { sendJson(res, 404, { error: 'Comparison not found' }); }
|
|
4449
5959
|
return;
|
|
@@ -4451,10 +5961,10 @@ async function addContext(){
|
|
|
4451
5961
|
|
|
4452
5962
|
if (isGetLikeRequest && pathname.startsWith('/use-cases/')) {
|
|
4453
5963
|
try {
|
|
4454
|
-
const
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
const html = fs.readFileSync(useCasePath, 'utf-8');
|
|
5964
|
+
const useCasePath = resolvePublicHtmlFile(USE_CASE_PAGE_PATHS_BY_SLUG, pathname.replace('/use-cases/', ''));
|
|
5965
|
+
if (!useCasePath) { sendJson(res, 404, { error: 'Use case not found' }); return; }
|
|
5966
|
+
const requestHost = getSafePublicRequestHost(req);
|
|
5967
|
+
const html = normalizePublicMarketingHtml(fs.readFileSync(useCasePath, 'utf-8'), hostedConfig, requestHost);
|
|
4458
5968
|
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
4459
5969
|
} catch { sendJson(res, 404, { error: 'Use case not found' }); }
|
|
4460
5970
|
return;
|
|
@@ -4471,6 +5981,18 @@ async function addContext(){
|
|
|
4471
5981
|
return;
|
|
4472
5982
|
}
|
|
4473
5983
|
|
|
5984
|
+
if (isGetLikeRequest && pathname.startsWith('/media/')) {
|
|
5985
|
+
const rel = pathname.slice('/media/'.length);
|
|
5986
|
+
const mediaDir = path.join(PUBLIC_DIR, 'media');
|
|
5987
|
+
const resolved = path.resolve(mediaDir, rel);
|
|
5988
|
+
if (!resolved.startsWith(mediaDir + path.sep) && resolved !== mediaDir) {
|
|
5989
|
+
sendJson(res, 403, { error: 'Forbidden' });
|
|
5990
|
+
return;
|
|
5991
|
+
}
|
|
5992
|
+
serveStaticFile(res, resolved, { headOnly: isHeadRequest });
|
|
5993
|
+
return;
|
|
5994
|
+
}
|
|
5995
|
+
|
|
4474
5996
|
if (isGetLikeRequest && (
|
|
4475
5997
|
pathname === '/favicon.ico'
|
|
4476
5998
|
|| pathname === '/thumbgate-logo.png'
|
|
@@ -4489,7 +6011,7 @@ async function addContext(){
|
|
|
4489
6011
|
version: pkg.version,
|
|
4490
6012
|
status: 'ok',
|
|
4491
6013
|
docs: 'https://github.com/IgorGanapolsky/ThumbGate',
|
|
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'],
|
|
6014
|
+
endpoints: ['/health', '/dashboard', '/guide', '/chatgpt-app', '/codex-plugin', '/compare', '/learn', '/pricing', '/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/ai-inventory', '/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'],
|
|
4493
6015
|
}, {}, {
|
|
4494
6016
|
headOnly: isHeadRequest,
|
|
4495
6017
|
});
|
|
@@ -4514,9 +6036,14 @@ async function addContext(){
|
|
|
4514
6036
|
return;
|
|
4515
6037
|
}
|
|
4516
6038
|
|
|
4517
|
-
|
|
6039
|
+
// HOTFIX 2026-06-04 — accept ANY method (GET/HEAD/POST) on /checkout/pro
|
|
6040
|
+
// to prevent the API-key guard from 401'ing real prospective customers
|
|
6041
|
+
// whose forms or fetch() calls land via POST. Audit: 69 emails submitted
|
|
6042
|
+
// → 0 paid because POST hit the auth gate. Query params still drive the
|
|
6043
|
+
// Stripe session creation; POST bodies are ignored harmlessly.
|
|
6044
|
+
if ((isGetLikeRequest || req.method === 'POST') && pathname === '/checkout/pro') {
|
|
4518
6045
|
if (isHeadRequest) {
|
|
4519
|
-
|
|
6046
|
+
sendHtml(res, 200, '', {}, {
|
|
4520
6047
|
headOnly: true,
|
|
4521
6048
|
});
|
|
4522
6049
|
return;
|
|
@@ -4545,10 +6072,76 @@ async function addContext(){
|
|
|
4545
6072
|
// (form submission JS-less bots don't do), (b) a `customer_email`
|
|
4546
6073
|
// query param treats the request as confirmed even from a bot UA,
|
|
4547
6074
|
// because no real crawler appends customer_email to discovered URLs.
|
|
6075
|
+
const rawCheckoutEmail = normalizeNullableText(bootstrapBody.customerEmail);
|
|
6076
|
+
const normalizedCheckoutEmail = normalizeCheckoutCustomerEmail(rawCheckoutEmail);
|
|
4548
6077
|
const hasCustomerEmailHint = !!parsed?.searchParams?.has('customer_email');
|
|
4549
|
-
const
|
|
4550
|
-
const
|
|
4551
|
-
|
|
6078
|
+
const hasValidCustomerEmailHint = !!normalizedCheckoutEmail;
|
|
6079
|
+
const botShouldBypass = !botClassification.isBot || hasValidCustomerEmailHint;
|
|
6080
|
+
// 2026-06-29 conversion audit: requiring email before Stripe gave us a
|
|
6081
|
+
// recoverable abandoned-session theory, but the hosted funnel showed
|
|
6082
|
+
// traffic with 0 checkout starts. Let confirmed human clicks reach
|
|
6083
|
+
// Stripe; Stripe can collect email on the secure checkout page. Bots
|
|
6084
|
+
// still need a valid email hint to bypass deflection.
|
|
6085
|
+
const isConfirmedCheckout = (
|
|
6086
|
+
(req.method === 'POST' && hasValidCustomerEmailHint)
|
|
6087
|
+
|| hasConfirmFlag
|
|
6088
|
+
) && botShouldBypass;
|
|
6089
|
+
// 2026-06-05 revenue bypass: env-gated direct-to-Stripe redirect.
|
|
6090
|
+
// Live 30d billing showed 254 interstitial views → 1 Stripe click-through
|
|
6091
|
+
// → 0 paid. When THUMBGATE_CHECKOUT_INTERSTITIAL_BYPASS=1 is set we
|
|
6092
|
+
// route raw /checkout/pro GETs (no confirm=1, no POST) straight to the
|
|
6093
|
+
// pro Stripe Payment Link, preserving UTM + attribution metadata via
|
|
6094
|
+
// buildCheckoutFallbackUrl. Default-off; bot-deflection still applies
|
|
6095
|
+
// (bot + no email hint still falls through to the existing interstitial).
|
|
6096
|
+
const interstitialBypassEnabled = process.env.THUMBGATE_CHECKOUT_INTERSTITIAL_BYPASS !== '0';
|
|
6097
|
+
const interstitialSampleRate = normalizeCheckoutInterstitialSampleRate(
|
|
6098
|
+
process.env.THUMBGATE_CHECKOUT_INTERSTITIAL_SAMPLE_RATE
|
|
6099
|
+
);
|
|
6100
|
+
const interstitialSampled = shouldSampleCheckoutInterstitial({
|
|
6101
|
+
sampleRate: interstitialSampleRate,
|
|
6102
|
+
traceId,
|
|
6103
|
+
analyticsMetadata,
|
|
6104
|
+
});
|
|
6105
|
+
if (
|
|
6106
|
+
!isConfirmedCheckout
|
|
6107
|
+
&& interstitialBypassEnabled
|
|
6108
|
+
&& req.method !== 'POST'
|
|
6109
|
+
&& botShouldBypass
|
|
6110
|
+
&& !interstitialSampled
|
|
6111
|
+
) {
|
|
6112
|
+
// Always target the pro Stripe Payment Link directly. The
|
|
6113
|
+
// hostedConfig.checkoutFallbackUrl (e.g. https://thumbgate.ai/go/pro)
|
|
6114
|
+
// is a router that 302s back to /checkout/pro, which would create a
|
|
6115
|
+
// redirect loop when bypass is on. Env override via
|
|
6116
|
+
// THUMBGATE_CHECKOUT_PRO_STRIPE_URL is supported for future
|
|
6117
|
+
// price-link rotation without a redeploy.
|
|
6118
|
+
const bypassTarget = process.env.THUMBGATE_CHECKOUT_PRO_STRIPE_URL
|
|
6119
|
+
|| FIRST_FAILURE_RULE_CHECKOUT_URL;
|
|
6120
|
+
appendBestEffortTelemetry(FEEDBACK_DIR, {
|
|
6121
|
+
eventType: 'checkout_interstitial_bypass_redirect',
|
|
6122
|
+
clientType: 'web',
|
|
6123
|
+
traceId,
|
|
6124
|
+
acquisitionId: analyticsMetadata.acquisitionId,
|
|
6125
|
+
visitorId: analyticsMetadata.visitorId,
|
|
6126
|
+
sessionId: analyticsMetadata.sessionId,
|
|
6127
|
+
utmSource: analyticsMetadata.utmSource,
|
|
6128
|
+
utmMedium: analyticsMetadata.utmMedium,
|
|
6129
|
+
utmCampaign: analyticsMetadata.utmCampaign,
|
|
6130
|
+
utmContent: analyticsMetadata.utmContent,
|
|
6131
|
+
utmTerm: analyticsMetadata.utmTerm,
|
|
6132
|
+
referrer: analyticsMetadata.referrer,
|
|
6133
|
+
referrerHost: analyticsMetadata.referrerHost,
|
|
6134
|
+
page: '/checkout/pro',
|
|
6135
|
+
planId: analyticsMetadata.planId,
|
|
6136
|
+
interstitialSampleRate,
|
|
6137
|
+
}, req.headers, 'checkout_interstitial_bypass_redirect');
|
|
6138
|
+
res.writeHead(302, {
|
|
6139
|
+
...responseHeaders,
|
|
6140
|
+
Location: buildCheckoutFallbackUrl(bypassTarget, analyticsMetadata),
|
|
6141
|
+
});
|
|
6142
|
+
res.end();
|
|
6143
|
+
return;
|
|
6144
|
+
}
|
|
4552
6145
|
// Plausible funnel event #1 of 3: page view. Fired before interstitial
|
|
4553
6146
|
// deflection so we get the full top-of-funnel count, with isBot as a
|
|
4554
6147
|
// prop so the dashboard can filter human vs. crawler traffic. Fire-and-forget.
|
|
@@ -4586,6 +6179,7 @@ async function addContext(){
|
|
|
4586
6179
|
const eventType = botClassification.isBot
|
|
4587
6180
|
? 'checkout_bot_deflected'
|
|
4588
6181
|
: 'checkout_interstitial_view';
|
|
6182
|
+
const missingConfirmedEmail = hasConfirmFlag && !hasValidCustomerEmailHint;
|
|
4589
6183
|
appendBestEffortTelemetry(FEEDBACK_DIR, {
|
|
4590
6184
|
eventType,
|
|
4591
6185
|
clientType: 'web',
|
|
@@ -4604,27 +6198,24 @@ async function addContext(){
|
|
|
4604
6198
|
ctaId: analyticsMetadata.ctaId,
|
|
4605
6199
|
ctaPlacement: analyticsMetadata.ctaPlacement,
|
|
4606
6200
|
planId: analyticsMetadata.planId,
|
|
6201
|
+
billingCycle: analyticsMetadata.billingCycle,
|
|
6202
|
+
landingPath: analyticsMetadata.landingPath,
|
|
4607
6203
|
isBot: botClassification.isBot ? 'true' : 'false',
|
|
4608
|
-
|
|
6204
|
+
interstitialSampled: interstitialSampled ? 'true' : 'false',
|
|
6205
|
+
interstitialSampleRate,
|
|
6206
|
+
reason: missingConfirmedEmail
|
|
6207
|
+
? (hasCustomerEmailHint ? 'invalid_customer_email' : 'missing_customer_email')
|
|
6208
|
+
: botClassification.reason,
|
|
6209
|
+
confirmEmailRequired: missingConfirmedEmail ? 'true' : 'false',
|
|
4609
6210
|
}, req.headers, eventType);
|
|
4610
|
-
const
|
|
4611
|
-
|
|
4612
|
-
|
|
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,
|
|
6211
|
+
const prefilledEmail = parsed?.searchParams?.get('customer_email') || '';
|
|
6212
|
+
const html = renderCheckoutIntentPage(prefilledEmail, parsed, {
|
|
6213
|
+
includeHiddenAttribution: !botClassification.isBot,
|
|
4621
6214
|
});
|
|
4622
6215
|
sendHtml(res, 200, html, responseHeaders);
|
|
4623
6216
|
return;
|
|
4624
6217
|
}
|
|
4625
6218
|
|
|
4626
|
-
const rawCheckoutEmail = normalizeNullableText(bootstrapBody.customerEmail);
|
|
4627
|
-
const normalizedCheckoutEmail = normalizeCheckoutCustomerEmail(rawCheckoutEmail);
|
|
4628
6219
|
if (!normalizedCheckoutEmail) {
|
|
4629
6220
|
appendBestEffortTelemetry(FEEDBACK_DIR, {
|
|
4630
6221
|
eventType: 'checkout_email_deferred_to_stripe',
|
|
@@ -4881,10 +6472,13 @@ async function addContext(){
|
|
|
4881
6472
|
// Public-facing broker lead-flow audit landing page. Wedge for the
|
|
4882
6473
|
// real-estate broker outreach. Static HTML served from src/api/static.
|
|
4883
6474
|
try {
|
|
4884
|
-
const
|
|
6475
|
+
const host = getSafePublicRequestHost(req);
|
|
6476
|
+
const html = normalizePublicMarketingHtml(fillTemplate(fs.readFileSync(
|
|
4885
6477
|
path.resolve(__dirname, '../../assets/static/broker-audit.html'),
|
|
4886
6478
|
'utf8'
|
|
4887
|
-
)
|
|
6479
|
+
), {
|
|
6480
|
+
'__POSTHOG_API_KEY__': hostedConfig.posthogApiKey || '',
|
|
6481
|
+
}), hostedConfig, host);
|
|
4888
6482
|
if (isHeadRequest) {
|
|
4889
6483
|
sendHtml(res, 200, html, {}, { headOnly: true });
|
|
4890
6484
|
return;
|
|
@@ -4897,6 +6491,25 @@ async function addContext(){
|
|
|
4897
6491
|
return;
|
|
4898
6492
|
}
|
|
4899
6493
|
|
|
6494
|
+
if (isGetLikeRequest && pathname === '/leash-beta') {
|
|
6495
|
+
// Hermes Mobile founding beta landing (mac-yolo-safeguards/hermes-mobile/docs/beta-page).
|
|
6496
|
+
try {
|
|
6497
|
+
const html = fs.readFileSync(
|
|
6498
|
+
path.resolve(__dirname, '../../assets/static/leash-beta.html'),
|
|
6499
|
+
'utf8'
|
|
6500
|
+
);
|
|
6501
|
+
if (isHeadRequest) {
|
|
6502
|
+
sendHtml(res, 200, html, {}, { headOnly: true });
|
|
6503
|
+
return;
|
|
6504
|
+
}
|
|
6505
|
+
sendHtml(res, 200, html);
|
|
6506
|
+
} catch (err) {
|
|
6507
|
+
console.error('leash-beta page read failed:', err?.message);
|
|
6508
|
+
sendJson(res, 500, { error: 'leash-beta page unavailable' });
|
|
6509
|
+
}
|
|
6510
|
+
return;
|
|
6511
|
+
}
|
|
6512
|
+
|
|
4900
6513
|
if (isGetLikeRequest && pathname === '/.well-known/mcp.json') {
|
|
4901
6514
|
sendJson(res, 200, getMcpDiscoveryManifest(hostedConfig), {}, {
|
|
4902
6515
|
headOnly: isHeadRequest,
|
|
@@ -4904,6 +6517,117 @@ async function addContext(){
|
|
|
4904
6517
|
return;
|
|
4905
6518
|
}
|
|
4906
6519
|
|
|
6520
|
+
// OAuth 2.1 discovery metadata for the remote MCP connector (RFC 9728 / RFC 8414).
|
|
6521
|
+
// Lets Claude discover how to authenticate before calling authenticated tools.
|
|
6522
|
+
if (isGetLikeRequest && pathname === '/.well-known/oauth-protected-resource') {
|
|
6523
|
+
sendJson(res, 200, mcpOauth.buildProtectedResourceMetadata(buildPublicUrl(hostedConfig, '')), {}, {
|
|
6524
|
+
headOnly: isHeadRequest,
|
|
6525
|
+
});
|
|
6526
|
+
return;
|
|
6527
|
+
}
|
|
6528
|
+
if (isGetLikeRequest && (pathname === '/.well-known/oauth-authorization-server' || pathname === '/.well-known/openid-configuration')) {
|
|
6529
|
+
sendJson(res, 200, mcpOauth.buildAuthServerMetadata(buildPublicUrl(hostedConfig, '')), {}, {
|
|
6530
|
+
headOnly: isHeadRequest,
|
|
6531
|
+
});
|
|
6532
|
+
return;
|
|
6533
|
+
}
|
|
6534
|
+
|
|
6535
|
+
// --- OAuth 2.1 (PKCE) endpoints for the remote MCP connector ---
|
|
6536
|
+
// RFC 7591 Dynamic Client Registration.
|
|
6537
|
+
if (req.method === 'POST' && pathname === '/oauth/register') {
|
|
6538
|
+
let body = '';
|
|
6539
|
+
req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
|
|
6540
|
+
req.on('end', () => {
|
|
6541
|
+
let parsed = {};
|
|
6542
|
+
try { parsed = body ? JSON.parse(body) : {}; } catch { /* ignore */ }
|
|
6543
|
+
const reg = mcpOauth.registerClient(oauthStore, parsed);
|
|
6544
|
+
if (reg.error) { sendJson(res, 400, reg); return; }
|
|
6545
|
+
sendJson(res, 201, reg);
|
|
6546
|
+
});
|
|
6547
|
+
return;
|
|
6548
|
+
}
|
|
6549
|
+
// Authorization endpoint: GET renders consent, POST issues the code.
|
|
6550
|
+
if (pathname === '/oauth/authorize') {
|
|
6551
|
+
if (isGetLikeRequest) {
|
|
6552
|
+
const authRequestToken = createPendingOauthAuthorizeRequest(
|
|
6553
|
+
getOauthAuthorizeParamsFromQuery(parsed.searchParams)
|
|
6554
|
+
);
|
|
6555
|
+
const html = `<!doctype html><html><head><meta charset="utf-8"><title>Authorize ThumbGate</title>
|
|
6556
|
+
<style>body{font:15px system-ui;margin:0;background:#0b0b0c;color:#eee;display:flex;min-height:100vh;align-items:center;justify-content:center}
|
|
6557
|
+
.card{background:#161618;border:1px solid #2a2a2e;border-radius:12px;padding:28px;max-width:420px}
|
|
6558
|
+
input[type=password]{width:100%;padding:10px;margin:8px 0 16px;border-radius:8px;border:1px solid #2a2a2e;background:#0b0b0c;color:#eee}
|
|
6559
|
+
button{width:100%;padding:11px;border-radius:8px;border:0;background:#10b981;color:#04120c;font-weight:600;cursor:pointer}
|
|
6560
|
+
a{color:#8b9}</style></head><body><form class="card" method="post" action="/oauth/authorize">
|
|
6561
|
+
<h2>Authorize Claude → ThumbGate</h2>
|
|
6562
|
+
<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>
|
|
6563
|
+
<input type="hidden" name="auth_request_token" value="${escapeHtmlAttribute(authRequestToken)}">
|
|
6564
|
+
<input type="password" name="api_key" placeholder="ThumbGate API key" autocomplete="off" required>
|
|
6565
|
+
<button type="submit" name="approve" value="yes">Approve</button>
|
|
6566
|
+
</form></body></html>`;
|
|
6567
|
+
sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
|
|
6568
|
+
return;
|
|
6569
|
+
}
|
|
6570
|
+
if (req.method === 'POST') {
|
|
6571
|
+
let body = '';
|
|
6572
|
+
req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
|
|
6573
|
+
req.on('end', () => {
|
|
6574
|
+
const form = new URLSearchParams(body);
|
|
6575
|
+
const authorizationParams = getOauthAuthorizeParamsFromForm(form, hostedConfig);
|
|
6576
|
+
const redirectUri = authorizationParams.redirectUri;
|
|
6577
|
+
const state = authorizationParams.state;
|
|
6578
|
+
// Validate the presented ThumbGate key before issuing a code. When keys
|
|
6579
|
+
// are configured (production) the key MUST match a configured admin /
|
|
6580
|
+
// operator / reviewer key — otherwise OAuth would authenticate nobody.
|
|
6581
|
+
// In insecure/dev mode (no keys configured) any non-empty key is accepted.
|
|
6582
|
+
if (!resolveKeyRole(form.get('api_key') || '')) {
|
|
6583
|
+
sendJson(res, 400, { error: 'access_denied', error_description: 'invalid ThumbGate API key' });
|
|
6584
|
+
return;
|
|
6585
|
+
}
|
|
6586
|
+
const issued = mcpOauth.createAuthorizationCode(oauthStore, {
|
|
6587
|
+
clientId: authorizationParams.clientId,
|
|
6588
|
+
redirectUri,
|
|
6589
|
+
codeChallenge: authorizationParams.codeChallenge,
|
|
6590
|
+
codeChallengeMethod: authorizationParams.codeChallengeMethod,
|
|
6591
|
+
scope: authorizationParams.scope,
|
|
6592
|
+
resource: authorizationParams.resource || buildPublicUrl(hostedConfig, '/mcp'),
|
|
6593
|
+
boundKey: form.get('api_key') || '',
|
|
6594
|
+
state,
|
|
6595
|
+
});
|
|
6596
|
+
if (issued.error) {
|
|
6597
|
+
sendJson(res, 400, { error: issued.error, error_description: issued.error_description });
|
|
6598
|
+
return;
|
|
6599
|
+
}
|
|
6600
|
+
const sep = redirectUri.includes('?') ? '&' : '?';
|
|
6601
|
+
const loc = `${redirectUri}${sep}code=${encodeURIComponent(issued.code)}${state ? `&state=${encodeURIComponent(state)}` : ''}`;
|
|
6602
|
+
res.writeHead(302, { Location: loc });
|
|
6603
|
+
res.end();
|
|
6604
|
+
});
|
|
6605
|
+
return;
|
|
6606
|
+
}
|
|
6607
|
+
}
|
|
6608
|
+
// Token endpoint (authorization_code + PKCE).
|
|
6609
|
+
if (req.method === 'POST' && pathname === '/oauth/token') {
|
|
6610
|
+
let body = '';
|
|
6611
|
+
req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
|
|
6612
|
+
req.on('end', () => {
|
|
6613
|
+
const form = new URLSearchParams(body);
|
|
6614
|
+
if (form.get('grant_type') !== 'authorization_code') {
|
|
6615
|
+
sendJson(res, 400, { error: 'unsupported_grant_type' });
|
|
6616
|
+
return;
|
|
6617
|
+
}
|
|
6618
|
+
const tok = mcpOauth.exchangeCode(oauthStore, {
|
|
6619
|
+
code: form.get('code') || '',
|
|
6620
|
+
codeVerifier: form.get('code_verifier') || '',
|
|
6621
|
+
clientId: form.get('client_id') || '',
|
|
6622
|
+
redirectUri: form.get('redirect_uri') || '',
|
|
6623
|
+
resource: form.get('resource') || undefined,
|
|
6624
|
+
});
|
|
6625
|
+
if (tok.error) { sendJson(res, 400, tok); return; }
|
|
6626
|
+
sendJson(res, 200, tok, { 'Cache-Control': 'no-store' });
|
|
6627
|
+
});
|
|
6628
|
+
return;
|
|
6629
|
+
}
|
|
6630
|
+
|
|
4907
6631
|
if (isGetLikeRequest && pathname === '/.well-known/mcp/tools.json') {
|
|
4908
6632
|
sendJson(res, 200, {
|
|
4909
6633
|
name: 'thumbgate',
|
|
@@ -5016,40 +6740,57 @@ async function addContext(){
|
|
|
5016
6740
|
// was down, when feedback-dir was unwritable, when env was misconfigured.
|
|
5017
6741
|
// The fix is shallow but meaningful: probe each critical subsystem and
|
|
5018
6742
|
// surface failures with HTTP 503 + a per-check breakdown.
|
|
6743
|
+
// Tiered failure classification — not all degradations should kill the
|
|
6744
|
+
// container. A *service-failing* check (feedback dir unwritable,
|
|
6745
|
+
// appOrigin missing) returns 503 → Railway's healthcheck fails → SIGTERM
|
|
6746
|
+
// → restart loop → outage (this exact failure mode took prod down
|
|
6747
|
+
// 2026-05-21 18:21Z → 19:30Z when BUILD_METADATA.buildSha came up empty
|
|
6748
|
+
// after a Railway env-var cleanup). A *telemetry-degraded* check (missing
|
|
6749
|
+
// buildSha) returns 200 with `degraded: true` so observability stays
|
|
6750
|
+
// visible but Railway doesn't kill an otherwise-healthy container over
|
|
6751
|
+
// a SHA gap.
|
|
5019
6752
|
const checks = {};
|
|
5020
|
-
let
|
|
6753
|
+
let failing = false; // any service-failing check → 503
|
|
6754
|
+
let degraded = false; // any telemetry-degraded check → 200 + flag
|
|
5021
6755
|
|
|
5022
6756
|
// Check 1: feedback dir exists and is writable.
|
|
6757
|
+
// SERVICE-FAILING — if we can't write feedback, the API can't function.
|
|
5023
6758
|
try {
|
|
5024
6759
|
const { FEEDBACK_DIR } = getFeedbackPaths();
|
|
5025
6760
|
fs.accessSync(FEEDBACK_DIR, fs.constants.W_OK);
|
|
5026
6761
|
checks.feedbackDir = { ok: true };
|
|
5027
6762
|
} catch (err) {
|
|
5028
|
-
checks.feedbackDir = { ok: false, error: err?.code || 'inaccessible' };
|
|
5029
|
-
|
|
6763
|
+
checks.feedbackDir = { ok: false, error: err?.code || 'inaccessible', severity: 'failing' };
|
|
6764
|
+
failing = true;
|
|
5030
6765
|
}
|
|
5031
6766
|
|
|
5032
6767
|
// Check 2: hosted config resolves the canonical app origin.
|
|
5033
|
-
//
|
|
6768
|
+
// SERVICE-FAILING — if appOrigin is missing/empty, redirects + checkout
|
|
6769
|
+
// flow break silently.
|
|
5034
6770
|
if (hostedConfig?.appOrigin) {
|
|
5035
6771
|
checks.hostedConfig = { ok: true };
|
|
5036
6772
|
} else {
|
|
5037
|
-
checks.hostedConfig = { ok: false, error: 'missing_appOrigin' };
|
|
5038
|
-
|
|
6773
|
+
checks.hostedConfig = { ok: false, error: 'missing_appOrigin', severity: 'failing' };
|
|
6774
|
+
failing = true;
|
|
5039
6775
|
}
|
|
5040
6776
|
|
|
5041
|
-
// Check 3: build metadata loaded.
|
|
5042
|
-
//
|
|
6777
|
+
// Check 3: build metadata loaded.
|
|
6778
|
+
// TELEMETRY-DEGRADED — observability gap, not a runtime outage. The
|
|
6779
|
+
// container still serves requests fine; we just can't tag responses with
|
|
6780
|
+
// the deployed SHA. Surfaces the gap to monitors via `degraded: true`
|
|
6781
|
+
// without triggering Railway's SIGTERM-on-503 loop.
|
|
5043
6782
|
if (BUILD_METADATA?.buildSha) {
|
|
5044
6783
|
checks.buildMetadata = { ok: true };
|
|
5045
6784
|
} else {
|
|
5046
|
-
checks.buildMetadata = { ok: false, error: 'missing_buildSha' };
|
|
5047
|
-
|
|
6785
|
+
checks.buildMetadata = { ok: false, error: 'missing_buildSha', severity: 'degraded' };
|
|
6786
|
+
degraded = true;
|
|
5048
6787
|
}
|
|
5049
6788
|
|
|
5050
|
-
const statusCode =
|
|
6789
|
+
const statusCode = failing ? 503 : 200;
|
|
6790
|
+
const status = failing ? 'failing' : (degraded ? 'degraded' : 'ok');
|
|
5051
6791
|
sendJson(res, statusCode, {
|
|
5052
|
-
status
|
|
6792
|
+
status,
|
|
6793
|
+
degraded: degraded || failing,
|
|
5053
6794
|
version: pkg.version,
|
|
5054
6795
|
buildSha: BUILD_METADATA.buildSha,
|
|
5055
6796
|
uptime: process.uptime(),
|
|
@@ -5119,7 +6860,7 @@ async function addContext(){
|
|
|
5119
6860
|
evidence: [err?.message ? err.message : 'unknown_error'],
|
|
5120
6861
|
},
|
|
5121
6862
|
});
|
|
5122
|
-
} catch
|
|
6863
|
+
} catch {
|
|
5123
6864
|
// Telemetry is best-effort and must never fail the caller.
|
|
5124
6865
|
}
|
|
5125
6866
|
}
|
|
@@ -5144,18 +6885,28 @@ async function addContext(){
|
|
|
5144
6885
|
}
|
|
5145
6886
|
|
|
5146
6887
|
if (req.method === 'GET' && pathname === '/v1/metrics/real') {
|
|
5147
|
-
const bd = require('../../scripts/bot-
|
|
6888
|
+
const bd = require('../../scripts/bot-detection');
|
|
5148
6889
|
const { FEEDBACK_DIR: metricsDir } = getFeedbackPaths();
|
|
5149
6890
|
const telemetryPath = path.join(metricsDir, 'telemetry-pings.jsonl');
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
6891
|
+
|
|
6892
|
+
// Cache parsed entries for 60s to avoid re-parsing 72K+ JSON lines per request
|
|
6893
|
+
const cacheKey = '_metricsRealCache';
|
|
6894
|
+
const cacheTTL = 60_000;
|
|
6895
|
+
let entries;
|
|
6896
|
+
if (global[cacheKey] && (Date.now() - global[cacheKey].ts) < cacheTTL) {
|
|
6897
|
+
entries = global[cacheKey].entries;
|
|
6898
|
+
} else {
|
|
6899
|
+
entries = [];
|
|
6900
|
+
try {
|
|
6901
|
+
if (fs.existsSync(telemetryPath)) {
|
|
6902
|
+
entries = fs.readFileSync(telemetryPath, 'utf8')
|
|
6903
|
+
.split('\n').filter(Boolean)
|
|
6904
|
+
.map(l => { try { return JSON.parse(l); } catch(_e) { return null; } })
|
|
6905
|
+
.filter(Boolean);
|
|
6906
|
+
}
|
|
6907
|
+
} catch { entries = []; }
|
|
6908
|
+
global[cacheKey] = { entries, ts: Date.now() };
|
|
6909
|
+
}
|
|
5159
6910
|
|
|
5160
6911
|
const now = Date.now();
|
|
5161
6912
|
const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000;
|
|
@@ -5184,6 +6935,52 @@ async function addContext(){
|
|
|
5184
6935
|
byVisitorType[e.visitorType] = (byVisitorType[e.visitorType] || 0) + 1;
|
|
5185
6936
|
});
|
|
5186
6937
|
|
|
6938
|
+
// ---------------------------------------------------------------
|
|
6939
|
+
// Active user analytics: group by installId to distinguish real
|
|
6940
|
+
// users from bots/mirrors. "Active" = performed a meaningful CLI
|
|
6941
|
+
// action (capture, recall, search, gate-check, stats) — not just init.
|
|
6942
|
+
// ---------------------------------------------------------------
|
|
6943
|
+
// Only events that prove a human used the product beyond init.
|
|
6944
|
+
// cli_pro_view excluded: browsing the upgrade page is not "using" the product.
|
|
6945
|
+
// cli_gate_check excluded: runs as subprocess, never fires trackEvent().
|
|
6946
|
+
// cli_search excluded: event name doesn't exist in production telemetry.
|
|
6947
|
+
const ACTIVE_EVENTS = new Set([
|
|
6948
|
+
'cli_capture', 'cli_recall', 'cli_stats',
|
|
6949
|
+
'activation_first_rule_promoted',
|
|
6950
|
+
]);
|
|
6951
|
+
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
|
|
6952
|
+
const last30 = classified.filter(e => {
|
|
6953
|
+
const ts = e.timestamp || e.receivedAt;
|
|
6954
|
+
return ts && new Date(ts).getTime() > thirtyDaysAgo;
|
|
6955
|
+
});
|
|
6956
|
+
|
|
6957
|
+
// Per-install activity (all-time and recent windows)
|
|
6958
|
+
const activeInstalls7d = new Set();
|
|
6959
|
+
const activeInstalls30d = new Set();
|
|
6960
|
+
const allTimeActiveInstalls = new Set();
|
|
6961
|
+
const uniqueSessions7d = new Set();
|
|
6962
|
+
const uniqueSessions30d = new Set();
|
|
6963
|
+
|
|
6964
|
+
for (const e of classified) {
|
|
6965
|
+
if (!e.installId) continue;
|
|
6966
|
+
const et = e.eventType || '';
|
|
6967
|
+
if (!ACTIVE_EVENTS.has(et)) continue;
|
|
6968
|
+
if (e.visitorType === 'bot' || e.visitorType === 'ci') continue;
|
|
6969
|
+
allTimeActiveInstalls.add(e.installId);
|
|
6970
|
+
}
|
|
6971
|
+
for (const e of recent) {
|
|
6972
|
+
if (!e.installId) continue;
|
|
6973
|
+
if (e.visitorType === 'bot' || e.visitorType === 'ci') continue;
|
|
6974
|
+
if (ACTIVE_EVENTS.has(e.eventType || '')) activeInstalls7d.add(e.installId);
|
|
6975
|
+
if (e.sessionId) uniqueSessions7d.add(e.sessionId);
|
|
6976
|
+
}
|
|
6977
|
+
for (const e of last30) {
|
|
6978
|
+
if (!e.installId) continue;
|
|
6979
|
+
if (e.visitorType === 'bot' || e.visitorType === 'ci') continue;
|
|
6980
|
+
if (ACTIVE_EVENTS.has(e.eventType || '')) activeInstalls30d.add(e.installId);
|
|
6981
|
+
if (e.sessionId) uniqueSessions30d.add(e.sessionId);
|
|
6982
|
+
}
|
|
6983
|
+
|
|
5187
6984
|
sendJson(res, 200, {
|
|
5188
6985
|
allTime: {
|
|
5189
6986
|
total: classified.length,
|
|
@@ -5192,6 +6989,12 @@ async function addContext(){
|
|
|
5192
6989
|
owner: classified.filter(e => e.visitorType === 'owner').length,
|
|
5193
6990
|
ci: classified.filter(e => e.visitorType === 'ci').length,
|
|
5194
6991
|
uniqueInstalls: uniqueInstallIds.size,
|
|
6992
|
+
activeInstalls: allTimeActiveInstalls.size,
|
|
6993
|
+
},
|
|
6994
|
+
last30Days: {
|
|
6995
|
+
uniqueInstalls: new Set(last30.filter(e => e.installId).map(e => e.installId)).size,
|
|
6996
|
+
activeInstalls: activeInstalls30d.size,
|
|
6997
|
+
uniqueSessions: uniqueSessions30d.size,
|
|
5195
6998
|
},
|
|
5196
6999
|
last7Days: {
|
|
5197
7000
|
total: recent.length,
|
|
@@ -5200,6 +7003,8 @@ async function addContext(){
|
|
|
5200
7003
|
owner: recent.filter(e => e.visitorType === 'owner').length,
|
|
5201
7004
|
ci: recent.filter(e => e.visitorType === 'ci').length,
|
|
5202
7005
|
uniqueInstalls: recentInstallIds.size,
|
|
7006
|
+
activeInstalls: activeInstalls7d.size,
|
|
7007
|
+
uniqueSessions: uniqueSessions7d.size,
|
|
5203
7008
|
},
|
|
5204
7009
|
byEventType,
|
|
5205
7010
|
byVisitorType,
|
|
@@ -5264,6 +7069,7 @@ async function addContext(){
|
|
|
5264
7069
|
source,
|
|
5265
7070
|
telemetry: { rows: [], truncated: false, totalAfterSince: 0 },
|
|
5266
7071
|
funnel: { rows: [], truncated: false, totalAfterSince: 0 },
|
|
7072
|
+
journeySummary: null,
|
|
5267
7073
|
};
|
|
5268
7074
|
|
|
5269
7075
|
function readJsonlSince(p) {
|
|
@@ -5293,25 +7099,173 @@ async function addContext(){
|
|
|
5293
7099
|
result.telemetry.truncated = all.length > limit;
|
|
5294
7100
|
}
|
|
5295
7101
|
|
|
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;
|
|
7102
|
+
if (wantFunnel) {
|
|
7103
|
+
// Use the canonical funnel ledger path from scripts/billing.js so
|
|
7104
|
+
// any test override (THUMBGATE_FUNNEL_LEDGER_PATH / _TEST_FUNNEL_LEDGER_PATH)
|
|
7105
|
+
// resolves correctly. Falls back to the feedback dir's funnel-events.jsonl.
|
|
7106
|
+
let funnelPath;
|
|
7107
|
+
try {
|
|
7108
|
+
const billing = require('../../scripts/billing');
|
|
7109
|
+
funnelPath = (billing._FUNNEL_LEDGER_PATH && billing._FUNNEL_LEDGER_PATH())
|
|
7110
|
+
|| path.join(exportDir, 'funnel-events.jsonl');
|
|
7111
|
+
} catch {
|
|
7112
|
+
funnelPath = path.join(exportDir, 'funnel-events.jsonl');
|
|
7113
|
+
}
|
|
7114
|
+
const all = readJsonlSince(funnelPath);
|
|
7115
|
+
result.funnel.totalAfterSince = all.length;
|
|
7116
|
+
result.funnel.rows = all.slice(-limit);
|
|
7117
|
+
result.funnel.truncated = all.length > limit;
|
|
7118
|
+
}
|
|
7119
|
+
|
|
7120
|
+
try {
|
|
7121
|
+
const { buildVisitorJourneySummary } = require('../../scripts/visitor-journey');
|
|
7122
|
+
result.journeySummary = buildVisitorJourneySummary({
|
|
7123
|
+
telemetryRows: wantTelemetry ? result.telemetry.rows : [],
|
|
7124
|
+
funnelRows: wantFunnel ? result.funnel.rows : [],
|
|
7125
|
+
limit: Math.min(limit, 500),
|
|
7126
|
+
});
|
|
7127
|
+
} catch (err) {
|
|
7128
|
+
result.journeySummary = {
|
|
7129
|
+
error: err && err.message ? err.message : 'journey_summary_unavailable',
|
|
7130
|
+
};
|
|
7131
|
+
}
|
|
7132
|
+
|
|
7133
|
+
sendJson(res, 200, result);
|
|
7134
|
+
return;
|
|
7135
|
+
}
|
|
7136
|
+
|
|
7137
|
+
if (req.method === 'OPTIONS' && pathname === '/v1/marketing/install-email') {
|
|
7138
|
+
// CORS preflight for the npm postinstall email-capture endpoint.
|
|
7139
|
+
// The endpoint is called from `npx thumbgate subscribe <email>` so
|
|
7140
|
+
// the CLI gets the right CORS headers if it ever hits the proxy
|
|
7141
|
+
// path; in practice CLI hits the server directly so this is mostly
|
|
7142
|
+
// defense-in-depth.
|
|
7143
|
+
sendPublicBillingPreflight(res);
|
|
7144
|
+
return;
|
|
7145
|
+
}
|
|
7146
|
+
|
|
7147
|
+
if (req.method === 'POST' && pathname === '/v1/marketing/install-email') {
|
|
7148
|
+
// Email-capture wedge for npm installers. The `subscribe` CLI
|
|
7149
|
+
// subcommand POSTs { email, source, installId, cliVersion } here;
|
|
7150
|
+
// we validate the email, persist it to a dedicated capture ledger
|
|
7151
|
+
// (telemetry sanitizer would strip the email as PII), emit a
|
|
7152
|
+
// privacy-clean telemetry ping for funnel attribution, and fire
|
|
7153
|
+
// a Resend newsletter welcome (if RESEND_API_KEY is configured).
|
|
7154
|
+
// No auth required — this is a public opt-in surface.
|
|
7155
|
+
const { FEEDBACK_DIR } = getFeedbackPaths();
|
|
7156
|
+
let body = '';
|
|
7157
|
+
let total = 0;
|
|
7158
|
+
let oversize = false;
|
|
7159
|
+
const MAX_BODY = 2048;
|
|
7160
|
+
req.on('data', (chunk) => {
|
|
7161
|
+
total += chunk.length;
|
|
7162
|
+
if (total > MAX_BODY) {
|
|
7163
|
+
oversize = true;
|
|
7164
|
+
return; // stop appending; let 'end' fire naturally to send 413
|
|
7165
|
+
}
|
|
7166
|
+
body += chunk;
|
|
7167
|
+
});
|
|
7168
|
+
req.on('end', async () => {
|
|
7169
|
+
if (oversize) {
|
|
7170
|
+
sendJson(res, 413, { error: 'payload_too_large' });
|
|
7171
|
+
return;
|
|
7172
|
+
}
|
|
7173
|
+
let parsed;
|
|
7174
|
+
try {
|
|
7175
|
+
parsed = body ? JSON.parse(body) : {};
|
|
7176
|
+
} catch {
|
|
7177
|
+
sendJson(res, 400, { error: 'invalid_json' });
|
|
7178
|
+
return;
|
|
7179
|
+
}
|
|
7180
|
+
const rawEmail = typeof parsed.email === 'string' ? parsed.email.trim() : '';
|
|
7181
|
+
// RFC 5321-bounded email shape — same regex shape as the CLI side
|
|
7182
|
+
// to keep the contract honest.
|
|
7183
|
+
const emailValid = /^[^\s@]{1,64}@[^\s@]{1,255}\.[^\s@]{1,64}$/.test(rawEmail);
|
|
7184
|
+
if (!emailValid) {
|
|
7185
|
+
sendJson(res, 400, { error: 'invalid_email' });
|
|
7186
|
+
return;
|
|
7187
|
+
}
|
|
7188
|
+
const source = typeof parsed.source === 'string' && parsed.source.length <= 64
|
|
7189
|
+
? parsed.source
|
|
7190
|
+
: 'cli_subscribe';
|
|
7191
|
+
const installId = typeof parsed.installId === 'string' && parsed.installId.length <= 128
|
|
7192
|
+
? parsed.installId
|
|
7193
|
+
: null;
|
|
7194
|
+
const cliVersion = typeof parsed.cliVersion === 'string' && parsed.cliVersion.length <= 32
|
|
7195
|
+
? parsed.cliVersion
|
|
7196
|
+
: null;
|
|
7197
|
+
|
|
7198
|
+
// Persist the capture to a dedicated ledger. The standard
|
|
7199
|
+
// telemetry sanitizer in scripts/telemetry-analytics.js
|
|
7200
|
+
// intentionally strips arbitrary fields (PII protection), so
|
|
7201
|
+
// we cannot rely on appendBestEffortTelemetry to preserve the
|
|
7202
|
+
// email. The capture ledger lives alongside other feedback
|
|
7203
|
+
// artifacts and is the source of truth for the marketing drip.
|
|
7204
|
+
try {
|
|
7205
|
+
const fsModule = require('node:fs');
|
|
7206
|
+
const pathModule = require('node:path');
|
|
7207
|
+
const captureDir = pathModule.resolve(FEEDBACK_DIR);
|
|
7208
|
+
fsModule.mkdirSync(captureDir, { recursive: true });
|
|
7209
|
+
const capturePath = pathModule.join(captureDir, 'marketing-install-emails.jsonl');
|
|
7210
|
+
fsModule.appendFileSync(capturePath, JSON.stringify({
|
|
7211
|
+
capturedAt: new Date().toISOString(),
|
|
7212
|
+
email: rawEmail,
|
|
7213
|
+
source,
|
|
7214
|
+
installId,
|
|
7215
|
+
cliVersion,
|
|
7216
|
+
remoteAddr: req.socket?.remoteAddress || null,
|
|
7217
|
+
userAgent: req.headers['user-agent'] || null,
|
|
7218
|
+
}) + '\n', 'utf-8');
|
|
7219
|
+
} catch (err) {
|
|
7220
|
+
// Capture failure is recoverable — we still want to fire the
|
|
7221
|
+
// welcome email and surface the failure to ops via diagnostic.
|
|
7222
|
+
try {
|
|
7223
|
+
const { appendDiagnosticRecord } = require('../../scripts/feedback-loop');
|
|
7224
|
+
appendDiagnosticRecord({
|
|
7225
|
+
source: 'install_email_capture',
|
|
7226
|
+
step: 'capture_persist',
|
|
7227
|
+
context: 'failed to persist install-email capture to ledger',
|
|
7228
|
+
metadata: { error: err?.message || 'unknown' },
|
|
7229
|
+
});
|
|
7230
|
+
} catch {}
|
|
7231
|
+
}
|
|
7232
|
+
|
|
7233
|
+
// Privacy-clean telemetry ping for funnel attribution (no email).
|
|
7234
|
+
appendBestEffortTelemetry(FEEDBACK_DIR, {
|
|
7235
|
+
eventType: 'marketing_install_email_captured',
|
|
7236
|
+
clientType: 'cli',
|
|
7237
|
+
source,
|
|
7238
|
+
installId,
|
|
7239
|
+
cliVersion,
|
|
7240
|
+
utmSource: source,
|
|
7241
|
+
utmMedium: 'npm_postinstall',
|
|
7242
|
+
utmCampaign: 'install_email_capture',
|
|
7243
|
+
}, req.headers, 'marketing_install_email_captured');
|
|
7244
|
+
|
|
7245
|
+
// Fire Resend welcome. If RESEND_API_KEY is unset the mailer
|
|
7246
|
+
// returns { sent: false, reason: 'no_api_key' } and the
|
|
7247
|
+
// capture still succeeds — the operator can drip later from
|
|
7248
|
+
// the captured ledger.
|
|
7249
|
+
let mailerResult = { sent: false, reason: 'not_attempted' };
|
|
5301
7250
|
try {
|
|
5302
|
-
const
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
7251
|
+
const { sendNewsletterWelcomeEmail } = require('../../scripts/mailer');
|
|
7252
|
+
mailerResult = await sendNewsletterWelcomeEmail({
|
|
7253
|
+
to: rawEmail,
|
|
7254
|
+
source,
|
|
7255
|
+
installId,
|
|
7256
|
+
cliVersion,
|
|
7257
|
+
});
|
|
7258
|
+
} catch (err) {
|
|
7259
|
+
mailerResult = { sent: false, reason: `mailer_error:${err.message || 'unknown'}` };
|
|
5307
7260
|
}
|
|
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
|
-
}
|
|
5313
7261
|
|
|
5314
|
-
|
|
7262
|
+
sendJson(res, 200, {
|
|
7263
|
+
ok: true,
|
|
7264
|
+
captured: true,
|
|
7265
|
+
mailerSent: !!mailerResult.sent,
|
|
7266
|
+
mailerReason: mailerResult.reason || null,
|
|
7267
|
+
});
|
|
7268
|
+
});
|
|
5315
7269
|
return;
|
|
5316
7270
|
}
|
|
5317
7271
|
|
|
@@ -5320,6 +7274,130 @@ async function addContext(){
|
|
|
5320
7274
|
return;
|
|
5321
7275
|
}
|
|
5322
7276
|
|
|
7277
|
+
if (req.method === 'OPTIONS' && pathname === '/v1/intake/broker-audit') {
|
|
7278
|
+
sendPublicBillingPreflight(res);
|
|
7279
|
+
return;
|
|
7280
|
+
}
|
|
7281
|
+
|
|
7282
|
+
if (req.method === 'POST' && pathname === '/v1/intake/broker-audit') {
|
|
7283
|
+
const { FEEDBACK_DIR } = getFeedbackPaths();
|
|
7284
|
+
const traceId = createTraceId('broker_audit');
|
|
7285
|
+
const journeyState = resolveJourneyState(req, parsed);
|
|
7286
|
+
const referrerAttribution = buildReferrerAttribution(req);
|
|
7287
|
+
const contentType = String(req.headers['content-type'] || '').toLowerCase();
|
|
7288
|
+
const isFormSubmission = contentType.includes('application/x-www-form-urlencoded');
|
|
7289
|
+
try {
|
|
7290
|
+
const body = isFormSubmission
|
|
7291
|
+
? await parseFormBody(req, 16 * 1024)
|
|
7292
|
+
: await parseJsonBody(req, 16 * 1024);
|
|
7293
|
+
const lead = appendBrokerAuditLead(FEEDBACK_DIR, {
|
|
7294
|
+
...body,
|
|
7295
|
+
traceId: body.traceId || traceId,
|
|
7296
|
+
acquisitionId: body.acquisitionId || journeyState.acquisitionId,
|
|
7297
|
+
visitorId: body.visitorId || journeyState.visitorId,
|
|
7298
|
+
sessionId: body.sessionId || journeyState.sessionId,
|
|
7299
|
+
page: body.page || referrerAttribution.page || '/broker-audit',
|
|
7300
|
+
landingPath: body.landingPath || referrerAttribution.landingPath || '/broker-audit',
|
|
7301
|
+
ctaId: body.ctaId || 'broker_audit_form',
|
|
7302
|
+
ctaPlacement: body.ctaPlacement || 'broker_audit',
|
|
7303
|
+
source: body.source || body.utmSource || referrerAttribution.source || 'website',
|
|
7304
|
+
utmSource: body.utmSource || body.source || referrerAttribution.utmSource || 'website',
|
|
7305
|
+
utmMedium: body.utmMedium || referrerAttribution.utmMedium || 'broker_audit',
|
|
7306
|
+
utmCampaign: body.utmCampaign || referrerAttribution.utmCampaign || 'broker_audit_free',
|
|
7307
|
+
utmContent: body.utmContent || referrerAttribution.utmContent || null,
|
|
7308
|
+
utmTerm: body.utmTerm || referrerAttribution.utmTerm || null,
|
|
7309
|
+
referrerHost: body.referrerHost || referrerAttribution.referrerHost || null,
|
|
7310
|
+
referrer: body.referrer || referrerAttribution.referrer || null,
|
|
7311
|
+
});
|
|
7312
|
+
|
|
7313
|
+
appendBestEffortTelemetry(FEEDBACK_DIR, {
|
|
7314
|
+
eventType: 'broker_audit_lead_submitted',
|
|
7315
|
+
clientType: 'web',
|
|
7316
|
+
traceId: lead.attribution.traceId,
|
|
7317
|
+
acquisitionId: lead.attribution.acquisitionId,
|
|
7318
|
+
visitorId: lead.attribution.visitorId,
|
|
7319
|
+
sessionId: lead.attribution.sessionId,
|
|
7320
|
+
source: lead.attribution.source,
|
|
7321
|
+
utmSource: lead.attribution.utmSource,
|
|
7322
|
+
utmMedium: lead.attribution.utmMedium,
|
|
7323
|
+
utmCampaign: lead.attribution.utmCampaign,
|
|
7324
|
+
utmContent: lead.attribution.utmContent,
|
|
7325
|
+
utmTerm: lead.attribution.utmTerm,
|
|
7326
|
+
ctaId: lead.attribution.ctaId,
|
|
7327
|
+
ctaPlacement: lead.attribution.ctaPlacement,
|
|
7328
|
+
page: lead.attribution.page,
|
|
7329
|
+
landingPath: lead.attribution.landingPath,
|
|
7330
|
+
referrerHost: lead.attribution.referrerHost,
|
|
7331
|
+
referrer: lead.attribution.referrer,
|
|
7332
|
+
}, req.headers, 'broker_audit_lead_submitted');
|
|
7333
|
+
|
|
7334
|
+
if (isFormSubmission && !wantsJson(req, parsed)) {
|
|
7335
|
+
sendHtml(
|
|
7336
|
+
res,
|
|
7337
|
+
201,
|
|
7338
|
+
renderBrokerAuditIntakeResultPage(hostedConfig, {
|
|
7339
|
+
title: 'Broker audit request received',
|
|
7340
|
+
detail: 'Your free broker lead-flow audit request is captured. The next step is the 1-page PDF with the specific lead leaks.',
|
|
7341
|
+
leadId: lead.leadId,
|
|
7342
|
+
}),
|
|
7343
|
+
journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}
|
|
7344
|
+
);
|
|
7345
|
+
return;
|
|
7346
|
+
}
|
|
7347
|
+
|
|
7348
|
+
sendJson(res, 201, {
|
|
7349
|
+
ok: true,
|
|
7350
|
+
leadId: lead.leadId,
|
|
7351
|
+
status: lead.status,
|
|
7352
|
+
offer: lead.offer,
|
|
7353
|
+
nextStep: 'deliver_1_page_pdf',
|
|
7354
|
+
}, {
|
|
7355
|
+
...getPublicBillingHeaders(lead.attribution.traceId),
|
|
7356
|
+
...(journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}),
|
|
7357
|
+
});
|
|
7358
|
+
} catch (err) {
|
|
7359
|
+
appendBestEffortTelemetry(FEEDBACK_DIR, {
|
|
7360
|
+
eventType: 'broker_audit_lead_failed',
|
|
7361
|
+
clientType: 'web',
|
|
7362
|
+
traceId,
|
|
7363
|
+
acquisitionId: journeyState.acquisitionId,
|
|
7364
|
+
visitorId: journeyState.visitorId,
|
|
7365
|
+
sessionId: journeyState.sessionId,
|
|
7366
|
+
source: referrerAttribution.source || 'website',
|
|
7367
|
+
utmSource: referrerAttribution.utmSource || 'website',
|
|
7368
|
+
utmMedium: referrerAttribution.utmMedium || 'broker_audit',
|
|
7369
|
+
utmCampaign: referrerAttribution.utmCampaign || 'broker_audit_free',
|
|
7370
|
+
ctaId: 'broker_audit_form',
|
|
7371
|
+
ctaPlacement: 'broker_audit',
|
|
7372
|
+
page: referrerAttribution.page || '/broker-audit',
|
|
7373
|
+
landingPath: referrerAttribution.landingPath || '/broker-audit',
|
|
7374
|
+
referrerHost: referrerAttribution.referrerHost,
|
|
7375
|
+
referrer: referrerAttribution.referrer,
|
|
7376
|
+
failureCode: err?.message ? err.message : 'broker_audit_lead_failed',
|
|
7377
|
+
httpStatus: err && err.statusCode ? err.statusCode : null,
|
|
7378
|
+
}, req.headers, 'broker_audit_lead_failed');
|
|
7379
|
+
if (isFormSubmission && !wantsJson(req, parsed)) {
|
|
7380
|
+
sendHtml(
|
|
7381
|
+
res,
|
|
7382
|
+
err.statusCode || 500,
|
|
7383
|
+
renderBrokerAuditIntakeResultPage(hostedConfig, {
|
|
7384
|
+
title: 'Broker audit request failed',
|
|
7385
|
+
detail: err.message || 'Unable to capture broker audit request.',
|
|
7386
|
+
}),
|
|
7387
|
+
journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}
|
|
7388
|
+
);
|
|
7389
|
+
return;
|
|
7390
|
+
}
|
|
7391
|
+
sendProblem(res, {
|
|
7392
|
+
type: !err.statusCode || err.statusCode >= 500 ? PROBLEM_TYPES.INTERNAL : PROBLEM_TYPES.BAD_REQUEST,
|
|
7393
|
+
title: !err.statusCode || err.statusCode >= 500 ? 'Internal Server Error' : 'Request Error',
|
|
7394
|
+
status: err.statusCode || 500,
|
|
7395
|
+
detail: err.message || 'Unable to capture broker audit request.',
|
|
7396
|
+
}, getPublicBillingHeaders(traceId));
|
|
7397
|
+
}
|
|
7398
|
+
return;
|
|
7399
|
+
}
|
|
7400
|
+
|
|
5323
7401
|
if (req.method === 'POST' && pathname === '/v1/intake/workflow-sprint') {
|
|
5324
7402
|
const { FEEDBACK_DIR } = getFeedbackPaths();
|
|
5325
7403
|
const traceId = createTraceId('sprint_intake');
|
|
@@ -5467,7 +7545,19 @@ async function addContext(){
|
|
|
5467
7545
|
|
|
5468
7546
|
// Public OpenAPI spec — no auth required (needed for ChatGPT GPT Store import)
|
|
5469
7547
|
if (isGetLikeRequest && (pathname === '/openapi.json' || pathname === '/openapi.yaml')) {
|
|
5470
|
-
const specPath =
|
|
7548
|
+
const specPath = [
|
|
7549
|
+
path.join(__dirname, '../../openapi/openapi.yaml'),
|
|
7550
|
+
path.join(__dirname, '../../adapters/chatgpt/openapi.yaml'),
|
|
7551
|
+
].find((candidate) => fs.existsSync(candidate));
|
|
7552
|
+
if (!specPath) {
|
|
7553
|
+
sendProblem(res, {
|
|
7554
|
+
type: PROBLEM_TYPES.NOT_FOUND,
|
|
7555
|
+
title: 'Not Found',
|
|
7556
|
+
status: 404,
|
|
7557
|
+
detail: 'OpenAPI spec not found.',
|
|
7558
|
+
});
|
|
7559
|
+
return;
|
|
7560
|
+
}
|
|
5471
7561
|
try {
|
|
5472
7562
|
const yaml = renderOpenApiYamlForRequest(fs.readFileSync(specPath, 'utf8'), req);
|
|
5473
7563
|
if (pathname === '/openapi.yaml') {
|
|
@@ -5543,38 +7633,14 @@ async function addContext(){
|
|
|
5543
7633
|
// surface that was missing: thumbgate.ai had no /case-studies, so visitors
|
|
5544
7634
|
// landed on CLI install commands without seeing whether anyone actually
|
|
5545
7635
|
// got value. First entry is the Aiventyx Teams listing integration: real
|
|
5546
|
-
// third-party CTR signal (5/8 clicks before the /go/teams fix,
|
|
5547
|
-
//
|
|
7636
|
+
// third-party CTR signal (5/8 clicks before the /go/teams fix, now routed
|
|
7637
|
+
// through team intake so scope happens before checkout).
|
|
5548
7638
|
if (isGetLikeRequest && pathname === '/case-studies') {
|
|
5549
7639
|
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>
|
|
5550
7640
|
<h1>Case Studies</h1>
|
|
5551
7641
|
<p class="lede">Real integrations. No fabricated logos, no aspirational numbers — every claim below is reproducible.</p>
|
|
5552
7642
|
|
|
5553
|
-
<
|
|
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>
|
|
7643
|
+
<p><em>New case studies for individual Pro operators coming soon.</em></p>
|
|
5578
7644
|
|
|
5579
7645
|
<footer>
|
|
5580
7646
|
<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>
|
|
@@ -5593,87 +7659,66 @@ async function addContext(){
|
|
|
5593
7659
|
// Sprint (proof-pack, sales-led) → Pro (self-serve recurring) → Team
|
|
5594
7660
|
// (after qualification). Every paid CTA across the site should funnel
|
|
5595
7661
|
// here OR directly into Stripe checkout — never a different price.
|
|
5596
|
-
if (isGetLikeRequest && pathname === '/pricing') {
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
<a class="cta cta-free" href="/go/install?utm_source=pricing">Install free →</a>
|
|
5615
|
-
</div>
|
|
7662
|
+
if (isGetLikeRequest && (pathname === '/pricing' || pathname === '/pricing.html')) {
|
|
7663
|
+
try {
|
|
7664
|
+
servePublicMarketingPage({
|
|
7665
|
+
req,
|
|
7666
|
+
res,
|
|
7667
|
+
parsed,
|
|
7668
|
+
hostedConfig,
|
|
7669
|
+
isHeadRequest,
|
|
7670
|
+
renderHtml: loadPricingPageHtml,
|
|
7671
|
+
extraTelemetry: {
|
|
7672
|
+
pageType: 'pricing',
|
|
7673
|
+
},
|
|
7674
|
+
});
|
|
7675
|
+
} catch (err) {
|
|
7676
|
+
sendText(res, 500, err.message || 'Pricing page unavailable');
|
|
7677
|
+
}
|
|
7678
|
+
return;
|
|
7679
|
+
}
|
|
5616
7680
|
|
|
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>
|
|
5631
7681
|
|
|
5632
|
-
|
|
5633
|
-
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
7682
|
+
// Remote MCP connector documentation — the `resource_documentation` target
|
|
7683
|
+
// advertised by /.well-known/oauth-protected-resource. The Claude Connectors
|
|
7684
|
+
// Directory requires this URL to resolve (200) for submission review.
|
|
7685
|
+
if (isGetLikeRequest && (pathname === '/docs/connectors' || pathname === '/docs/connectors/')) {
|
|
7686
|
+
const mcpUrl = buildPublicUrl(hostedConfig, '/mcp');
|
|
7687
|
+
const cardUrl = buildPublicUrl(hostedConfig, '/.well-known/mcp/server-card.json');
|
|
7688
|
+
const prmUrl = buildPublicUrl(hostedConfig, '/.well-known/oauth-protected-resource');
|
|
7689
|
+
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>
|
|
7690
|
+
<h1>ThumbGate — Remote MCP Connector</h1>
|
|
7691
|
+
<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>
|
|
7692
|
+
|
|
7693
|
+
<h2>Connect URL</h2>
|
|
7694
|
+
<pre>${esc(mcpUrl)}</pre>
|
|
7695
|
+
<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>
|
|
7696
|
+
|
|
7697
|
+
<h2>Authorization (OAuth 2.1 + PKCE)</h2>
|
|
7698
|
+
<ol>
|
|
7699
|
+
<li>Claude reads the <code>resource</code> and <code>authorization_servers</code> from the protected-resource metadata.</li>
|
|
7700
|
+
<li>It runs the standard OAuth 2.1 authorization-code flow with PKCE (<code>S256</code> only — <code>plain</code> is rejected).</li>
|
|
7701
|
+
<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>
|
|
7702
|
+
<li>The bearer token is then sent on the <code>Authorization</code> header for every <code>tools/call</code>.</li>
|
|
7703
|
+
</ol>
|
|
7704
|
+
|
|
7705
|
+
<h2>Available tools</h2>
|
|
7706
|
+
<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>
|
|
5637
7707
|
<ul>
|
|
5638
|
-
<li>
|
|
5639
|
-
<li>
|
|
5640
|
-
<li>
|
|
5641
|
-
<li>
|
|
7708
|
+
<li><strong>Feedback & lessons</strong> — <code>capture_feedback</code>, <code>feedback_summary</code>, <code>search_lessons</code>, <code>retrieve_lessons</code>, <code>prevention_rules</code>.</li>
|
|
7709
|
+
<li><strong>Context engineering</strong> — <code>construct_context_pack</code>, <code>evaluate_context_pack</code>, <code>unified_context</code>, <code>recall</code>.</li>
|
|
7710
|
+
<li><strong>Pre-action gates & governance</strong> — <code>satisfy_gate</code>, <code>track_action</code>, <code>approve_protected_action</code>, <code>verify_claim</code>, <code>enforcement_matrix</code>.</li>
|
|
7711
|
+
<li><strong>Diagnostics & planning</strong> — <code>diagnose_failure</code>, <code>suggest_fix</code>, <code>security_scan</code>, and the <code>plan_*</code> advisory tools.</li>
|
|
5642
7712
|
</ul>
|
|
5643
|
-
<a class="cta cta-secondary" href="/go/teams?utm_source=pricing">Start Team →</a>
|
|
5644
|
-
</div>
|
|
5645
7713
|
|
|
5646
|
-
</
|
|
7714
|
+
<h2>Reviewer credential (read-only)</h2>
|
|
7715
|
+
<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>
|
|
5647
7716
|
|
|
5648
|
-
<
|
|
5649
|
-
<
|
|
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>
|
|
7717
|
+
<h2>Source & contact</h2>
|
|
7718
|
+
<p>Open-source CLI and server: <a href="https://github.com/IgorGanapolsky/ThumbGate">github.com/IgorGanapolsky/ThumbGate</a>. Questions or reviewer-credential requests: <a href="mailto:igor.ganapolsky@gmail.com">igor.ganapolsky@gmail.com</a>.</p>
|
|
5669
7719
|
|
|
5670
|
-
<footer>
|
|
5671
|
-
|
|
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
|
-
});
|
|
7720
|
+
<footer><a href="/">ThumbGate</a> · <a href="/support">Support</a> · <a href="/privacy">Privacy</a> · <a href="/terms">Terms</a></footer>
|
|
7721
|
+
</body></html>`, {}, { headOnly: isHeadRequest });
|
|
5677
7722
|
return;
|
|
5678
7723
|
}
|
|
5679
7724
|
|
|
@@ -5916,6 +7961,14 @@ async function addContext(){
|
|
|
5916
7961
|
return;
|
|
5917
7962
|
}
|
|
5918
7963
|
|
|
7964
|
+
// Catch non-API GET requests that didn't match any public page route above.
|
|
7965
|
+
// Without this, /about, /docs, /demo etc. fall through to the API auth gate
|
|
7966
|
+
// and return a raw JSON 401 instead of a user-friendly 404.
|
|
7967
|
+
if (isGetLikeRequest && !pathname.startsWith('/v1/') && !pathname.startsWith('/api/')) {
|
|
7968
|
+
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>`);
|
|
7969
|
+
return;
|
|
7970
|
+
}
|
|
7971
|
+
|
|
5919
7972
|
// Operator key is allowed to bypass the general admin gate for its dedicated endpoint
|
|
5920
7973
|
const _reqToken = extractApiKey(req);
|
|
5921
7974
|
const isOperatorBillingRequest = Boolean(expectedOperatorKey)
|
|
@@ -5941,7 +7994,161 @@ async function addContext(){
|
|
|
5941
7994
|
|
|
5942
7995
|
try {
|
|
5943
7996
|
if (req.method === 'GET' && pathname === '/v1/feedback/stats') {
|
|
5944
|
-
|
|
7997
|
+
const stats = shouldAggregateFeedback()
|
|
7998
|
+
? computeAggregateFeedbackStats({ feedbackDir: requestFeedbackPaths.FEEDBACK_DIR })
|
|
7999
|
+
: analyzeFeedback(requestFeedbackPaths.FEEDBACK_LOG_PATH);
|
|
8000
|
+
try {
|
|
8001
|
+
const { getStatuslineMeta } = require('../../scripts/statusline-meta');
|
|
8002
|
+
const meta = getStatuslineMeta({ env: process.env });
|
|
8003
|
+
stats.tier = meta.tier;
|
|
8004
|
+
} catch (error) {
|
|
8005
|
+
debugApiFallback('statusline meta unavailable', error);
|
|
8006
|
+
stats.tier = 'Pro';
|
|
8007
|
+
}
|
|
8008
|
+
|
|
8009
|
+
const projectChatSettings = readProjectChatSettings(req, parsed);
|
|
8010
|
+
|
|
8011
|
+
stats.geminiConfigured = Boolean(
|
|
8012
|
+
projectChatSettings.geminiKey ||
|
|
8013
|
+
process.env.GEMINI_API_KEY ||
|
|
8014
|
+
process.env.THUMBGATE_GEMINI_API_KEY ||
|
|
8015
|
+
process.env.GOOGLE_API_KEY
|
|
8016
|
+
);
|
|
8017
|
+
stats.perplexityConfigured = Boolean(
|
|
8018
|
+
projectChatSettings.perplexityKey ||
|
|
8019
|
+
process.env.PERPLEXITY_API_KEY ||
|
|
8020
|
+
process.env.THUMBGATE_PERPLEXITY_API_KEY
|
|
8021
|
+
);
|
|
8022
|
+
stats.geminiValidatedAt = projectChatSettings.geminiValidatedAt;
|
|
8023
|
+
stats.geminiKeyStatus = 'none';
|
|
8024
|
+
if (projectChatSettings.geminiKey) {
|
|
8025
|
+
stats.geminiKeyStatus = 'present';
|
|
8026
|
+
}
|
|
8027
|
+
if (projectChatSettings.geminiValidatedAt) {
|
|
8028
|
+
stats.geminiKeyStatus = 'validated';
|
|
8029
|
+
}
|
|
8030
|
+
stats.hybridInferenceAvailable = !!(stats.geminiConfigured || stats.perplexityConfigured);
|
|
8031
|
+
stats.localLlmConfigured = Boolean(process.env.THUMBGATE_LOCAL_LLM_ENDPOINT);
|
|
8032
|
+
stats.localLlmEndpoint = process.env.THUMBGATE_LOCAL_LLM_ENDPOINT || null;
|
|
8033
|
+
stats.localLlmModel = process.env.THUMBGATE_LOCAL_LLM_MODEL || null;
|
|
8034
|
+
sendJson(res, 200, stats);
|
|
8035
|
+
return;
|
|
8036
|
+
}
|
|
8037
|
+
|
|
8038
|
+
// Chat with your data — LOCAL-FIRST. Powers the dashboard "Chat with your
|
|
8039
|
+
// data" panel. Factual/metric questions (gates, blocks, feedback, token
|
|
8040
|
+
// savings, team) are answered DETERMINISTICALLY from this install's own
|
|
8041
|
+
// dashboard data — no cloud, no LLM, no API key (the local-first thesis).
|
|
8042
|
+
// Only open-ended/qualitative questions fall through to lesson retrieval +
|
|
8043
|
+
// the user's configured LOCAL model (a BYO cloud key is optional, not required).
|
|
8044
|
+
if (req.method === 'POST' && pathname === '/v1/chat') {
|
|
8045
|
+
const body = await parseJsonBody(req);
|
|
8046
|
+
const question = body.question || body.q || body.message;
|
|
8047
|
+
const normalizedChatPrompt = normalizeEnterpriseChatPrompt(question);
|
|
8048
|
+
const chatFeedbackDir = requestFeedbackPaths.FEEDBACK_DIR;
|
|
8049
|
+
|
|
8050
|
+
// Local-first: factual/metric questions (gates, blocks, feedback, cost,
|
|
8051
|
+
// team) are answered deterministically from local data — no cloud/LLM/key.
|
|
8052
|
+
if (
|
|
8053
|
+
normalizedChatPrompt
|
|
8054
|
+
&& !containsUnsafeEnterpriseChatInput(normalizedChatPrompt)
|
|
8055
|
+
&& classifyEnterpriseChatTopic(normalizedChatPrompt) !== 'overview'
|
|
8056
|
+
&& await trySendLocalDashboardChat(res, parsed, chatFeedbackDir, normalizedChatPrompt)
|
|
8057
|
+
) {
|
|
8058
|
+
return;
|
|
8059
|
+
}
|
|
8060
|
+
|
|
8061
|
+
const { answerDataQuestion } = require('../../scripts/dashboard-chat');
|
|
8062
|
+
|
|
8063
|
+
const projectChatSettings = readProjectChatSettings(req, parsed);
|
|
8064
|
+
|
|
8065
|
+
const result = await answerDataQuestion(question, {
|
|
8066
|
+
feedbackDir: chatFeedbackDir,
|
|
8067
|
+
model: typeof body.model === 'string' ? body.model : undefined,
|
|
8068
|
+
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 || '',
|
|
8069
|
+
localEndpoint: projectChatSettings.localEndpoint || process.env.THUMBGATE_LOCAL_LLM_ENDPOINT || '',
|
|
8070
|
+
localModel: projectChatSettings.localModel || process.env.THUMBGATE_LOCAL_LLM_MODEL || '',
|
|
8071
|
+
});
|
|
8072
|
+
|
|
8073
|
+
// Local-first guarantee: if no model is configured, never hard-fail with
|
|
8074
|
+
// "no_api_key" — fall back to a deterministic local answer. No cloud required.
|
|
8075
|
+
if (
|
|
8076
|
+
!result.ok
|
|
8077
|
+
&& (result.error === 'no_api_key' || result.error === 'no_model')
|
|
8078
|
+
&& await trySendLocalDashboardChat(res, parsed, chatFeedbackDir, normalizedChatPrompt || question, '(Connect a local model via THUMBGATE_LOCAL_LLM_ENDPOINT for open-ended analysis over your lessons.)')
|
|
8079
|
+
) {
|
|
8080
|
+
return;
|
|
8081
|
+
}
|
|
8082
|
+
|
|
8083
|
+
sendJson(res, result.ok ? 200 : (result.error === 'no_api_key' ? 503 : 400), result);
|
|
8084
|
+
return;
|
|
8085
|
+
}
|
|
8086
|
+
|
|
8087
|
+
// Save Gemini API key from the dashboard UI
|
|
8088
|
+
if (req.method === 'POST' && pathname === '/v1/settings/gemini-key') {
|
|
8089
|
+
const body = await parseJsonBody(req);
|
|
8090
|
+
const key = String(body.key || '').trim();
|
|
8091
|
+
if (!key) {
|
|
8092
|
+
sendJson(res, 400, { ok: false, error: 'missing_key', message: 'No API key provided.' });
|
|
8093
|
+
return;
|
|
8094
|
+
}
|
|
8095
|
+
|
|
8096
|
+
// Validate the candidate key using the *exact* same code path as /v1/chat
|
|
8097
|
+
// (project-scoped .env read + RAG + Gemini call). This prevents saving a
|
|
8098
|
+
// key that will later produce the confusing "API key not valid" error in chat.
|
|
8099
|
+
let validation;
|
|
8100
|
+
try {
|
|
8101
|
+
const { answerDataQuestion } = require('../../scripts/dashboard-chat');
|
|
8102
|
+
validation = await answerDataQuestion('Reply with the single word: PONG', {
|
|
8103
|
+
feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
|
|
8104
|
+
apiKey: key,
|
|
8105
|
+
});
|
|
8106
|
+
} catch (e) {
|
|
8107
|
+
validation = { ok: false, error: 'validation_exception', message: String(e?.message || e) };
|
|
8108
|
+
}
|
|
8109
|
+
|
|
8110
|
+
if (!validation.ok) {
|
|
8111
|
+
const detail = validation.error === 'gemini_error'
|
|
8112
|
+
? (validation.message || 'Gemini rejected the key')
|
|
8113
|
+
: (validation.message || validation.error || 'unknown error');
|
|
8114
|
+
sendJson(res, 400, {
|
|
8115
|
+
ok: false,
|
|
8116
|
+
error: 'invalid_key',
|
|
8117
|
+
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.'
|
|
8118
|
+
});
|
|
8119
|
+
return;
|
|
8120
|
+
}
|
|
8121
|
+
|
|
8122
|
+
try {
|
|
8123
|
+
const projectDir = resolveRequestProjectDir(req, parsed);
|
|
8124
|
+
const envPath = path.join(projectDir, '.env');
|
|
8125
|
+
let content = '';
|
|
8126
|
+
if (fs.existsSync(envPath)) {
|
|
8127
|
+
content = fs.readFileSync(envPath, 'utf8');
|
|
8128
|
+
}
|
|
8129
|
+
const regex = /^GEMINI_API_KEY=.*$/m;
|
|
8130
|
+
if (regex.test(content)) {
|
|
8131
|
+
content = content.replace(regex, `GEMINI_API_KEY=${key}`);
|
|
8132
|
+
} else {
|
|
8133
|
+
content = content.trim() + `\nGEMINI_API_KEY=${key}\n`;
|
|
8134
|
+
}
|
|
8135
|
+
fs.writeFileSync(envPath, content, 'utf8');
|
|
8136
|
+
// Also set it in the current process so it takes effect immediately without restart
|
|
8137
|
+
process.env.GEMINI_API_KEY = key;
|
|
8138
|
+
// Persist validation success for reliable "configured" status in stats/hints
|
|
8139
|
+
try {
|
|
8140
|
+
const statusPath = path.join(projectDir, '.gemini-validated.json');
|
|
8141
|
+
fs.writeFileSync(statusPath, JSON.stringify({
|
|
8142
|
+
validatedAt: new Date().toISOString(),
|
|
8143
|
+
validatedBy: 'dashboard-save'
|
|
8144
|
+
}, null, 2));
|
|
8145
|
+
} catch (error) {
|
|
8146
|
+
debugApiFallback('Gemini validation marker unavailable', error);
|
|
8147
|
+
}
|
|
8148
|
+
sendJson(res, 200, { ok: true, message: 'Key saved and validated.' });
|
|
8149
|
+
} catch (e) {
|
|
8150
|
+
sendJson(res, 500, { ok: false, error: 'fs_error', message: 'Failed to write to .env file: ' + e.message });
|
|
8151
|
+
}
|
|
5945
8152
|
return;
|
|
5946
8153
|
}
|
|
5947
8154
|
|
|
@@ -6000,6 +8207,28 @@ async function addContext(){
|
|
|
6000
8207
|
return;
|
|
6001
8208
|
}
|
|
6002
8209
|
|
|
8210
|
+
if (req.method === 'GET' && (
|
|
8211
|
+
pathname === '/v1/enterprise/data-chat/status'
|
|
8212
|
+
|| pathname === '/v1/enterprise/dialogflow/status'
|
|
8213
|
+
)) {
|
|
8214
|
+
sendJson(res, 200, buildEnterpriseDataChatStatus());
|
|
8215
|
+
return;
|
|
8216
|
+
}
|
|
8217
|
+
|
|
8218
|
+
if (req.method === 'POST' && (
|
|
8219
|
+
pathname === '/v1/enterprise/data-chat/chat'
|
|
8220
|
+
|| pathname === '/v1/enterprise/dialogflow/chat'
|
|
8221
|
+
)) {
|
|
8222
|
+
const body = await parseJsonBody(req, 16 * 1024);
|
|
8223
|
+
const result = await answerEnterpriseDataChat({
|
|
8224
|
+
prompt: body.prompt || body.message || body.query,
|
|
8225
|
+
feedbackDir: requestFeedbackDir,
|
|
8226
|
+
parsed,
|
|
8227
|
+
});
|
|
8228
|
+
sendJson(res, 200, result);
|
|
8229
|
+
return;
|
|
8230
|
+
}
|
|
8231
|
+
|
|
6003
8232
|
if (req.method === 'GET' && pathname === '/v1/intents/catalog') {
|
|
6004
8233
|
const mcpProfile = parsed.searchParams.get('mcpProfile') || undefined;
|
|
6005
8234
|
const bundleId = parsed.searchParams.get('bundleId') || undefined;
|
|
@@ -6025,6 +8254,7 @@ async function addContext(){
|
|
|
6025
8254
|
bundleId: body.bundleId,
|
|
6026
8255
|
partnerProfile: body.partnerProfile,
|
|
6027
8256
|
delegationMode: body.delegationMode,
|
|
8257
|
+
enforcePlanQuality: body.enforcePlanQuality === true,
|
|
6028
8258
|
approved: body.approved === true,
|
|
6029
8259
|
repoPath: body.repoPath,
|
|
6030
8260
|
});
|
|
@@ -6283,6 +8513,7 @@ async function addContext(){
|
|
|
6283
8513
|
summary: body.summary,
|
|
6284
8514
|
allowedPaths: body.allowedPaths,
|
|
6285
8515
|
protectedPaths: body.protectedPaths,
|
|
8516
|
+
workflowContract: body.workflowContract,
|
|
6286
8517
|
repoPath: body.repoPath,
|
|
6287
8518
|
localOnly: body.localOnly === true,
|
|
6288
8519
|
clear: body.clear === true,
|
|
@@ -6360,6 +8591,11 @@ async function addContext(){
|
|
|
6360
8591
|
}
|
|
6361
8592
|
|
|
6362
8593
|
if (req.method === 'GET' && pathname === '/v1/lessons/search') {
|
|
8594
|
+
const lessonLimit = checkLimit('search_lessons');
|
|
8595
|
+
if (!lessonLimit.allowed) {
|
|
8596
|
+
sendJson(res, 429, { error: 'Free tier: lesson search requires Pro. Upgrade at /pricing', code: 'PRO_REQUIRED' });
|
|
8597
|
+
return;
|
|
8598
|
+
}
|
|
6363
8599
|
const query = parsed.searchParams.get('q') || parsed.searchParams.get('query') || '';
|
|
6364
8600
|
const limit = Number(parsed.searchParams.get('limit') || 10);
|
|
6365
8601
|
const category = parsed.searchParams.get('category') || '';
|
|
@@ -6379,17 +8615,24 @@ async function addContext(){
|
|
|
6379
8615
|
}
|
|
6380
8616
|
|
|
6381
8617
|
if (req.method === 'GET' && pathname === '/v1/search') {
|
|
8618
|
+
const searchLimit = checkLimit('search_thumbgate');
|
|
8619
|
+
if (!searchLimit.allowed) {
|
|
8620
|
+
sendJson(res, 429, { error: 'Free tier: search requires Pro. Upgrade at /pricing', code: 'PRO_REQUIRED' });
|
|
8621
|
+
return;
|
|
8622
|
+
}
|
|
6382
8623
|
const query = parsed.searchParams.get('q') || parsed.searchParams.get('query') || '';
|
|
6383
8624
|
const limit = Number(parsed.searchParams.get('limit') || 10);
|
|
6384
8625
|
const source = parsed.searchParams.get('source') || 'all';
|
|
6385
8626
|
const signal = parsed.searchParams.get('signal') || null;
|
|
6386
8627
|
let results;
|
|
6387
8628
|
try {
|
|
8629
|
+
const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
|
|
6388
8630
|
results = searchThumbgate({
|
|
6389
8631
|
query,
|
|
6390
8632
|
limit: Number.isFinite(limit) ? limit : 10,
|
|
6391
8633
|
source,
|
|
6392
8634
|
signal,
|
|
8635
|
+
feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
|
|
6393
8636
|
});
|
|
6394
8637
|
} catch (err) {
|
|
6395
8638
|
throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
|
|
@@ -6399,14 +8642,21 @@ async function addContext(){
|
|
|
6399
8642
|
}
|
|
6400
8643
|
|
|
6401
8644
|
if (req.method === 'POST' && pathname === '/v1/search') {
|
|
8645
|
+
const searchLimit = checkLimit('search_thumbgate');
|
|
8646
|
+
if (!searchLimit.allowed) {
|
|
8647
|
+
sendJson(res, 429, { error: 'Free tier: search requires Pro. Upgrade at /pricing', code: 'PRO_REQUIRED' });
|
|
8648
|
+
return;
|
|
8649
|
+
}
|
|
6402
8650
|
const body = await parseJsonBody(req);
|
|
6403
8651
|
let results;
|
|
6404
8652
|
try {
|
|
8653
|
+
const requestFeedbackPaths = getRequestFeedbackPaths(req, parsed);
|
|
6405
8654
|
results = searchThumbgate({
|
|
6406
8655
|
query: body.query || body.q || '',
|
|
6407
8656
|
limit: body.limit,
|
|
6408
8657
|
source: body.source,
|
|
6409
8658
|
signal: body.signal,
|
|
8659
|
+
feedbackDir: requestFeedbackPaths.FEEDBACK_DIR,
|
|
6410
8660
|
});
|
|
6411
8661
|
} catch (err) {
|
|
6412
8662
|
throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
|
|
@@ -6432,11 +8682,14 @@ async function addContext(){
|
|
|
6432
8682
|
{
|
|
6433
8683
|
const documentId = normalizeDocumentIdFromPath(pathname);
|
|
6434
8684
|
if (req.method === 'GET' && documentId) {
|
|
8685
|
+
if (!/^[a-zA-Z0-9-_]+$/.test(documentId)) {
|
|
8686
|
+
throw createHttpError(400, 'Invalid document ID format');
|
|
8687
|
+
}
|
|
6435
8688
|
const document = readImportedDocument(documentId, {
|
|
6436
8689
|
feedbackDir: requestFeedbackDir,
|
|
6437
8690
|
});
|
|
6438
8691
|
if (!document) {
|
|
6439
|
-
throw createHttpError(404, `Imported document not found: ${documentId}`);
|
|
8692
|
+
throw createHttpError(404, `Imported document not found: ${escapeHtml(documentId)}`);
|
|
6440
8693
|
}
|
|
6441
8694
|
sendJson(res, 200, { document });
|
|
6442
8695
|
return;
|
|
@@ -6463,7 +8716,7 @@ async function addContext(){
|
|
|
6463
8716
|
feedbackDir: getSafeDataDir(),
|
|
6464
8717
|
limit: 10,
|
|
6465
8718
|
});
|
|
6466
|
-
} catch
|
|
8719
|
+
} catch { /* best-effort — conversation window is optional */ }
|
|
6467
8720
|
}
|
|
6468
8721
|
const result = captureFeedback({
|
|
6469
8722
|
signal: body.signal,
|
|
@@ -6482,6 +8735,18 @@ async function addContext(){
|
|
|
6482
8735
|
tags: extractTags(body.tags),
|
|
6483
8736
|
skill: body.skill,
|
|
6484
8737
|
});
|
|
8738
|
+
const actionIntegration = inferActionIntegration(body, req.headers);
|
|
8739
|
+
appendBestEffortTelemetry(requestFeedbackDir, {
|
|
8740
|
+
eventType: chatgptActionEventType(actionIntegration, 'capture_feedback'),
|
|
8741
|
+
clientType: 'api',
|
|
8742
|
+
source: actionIntegration,
|
|
8743
|
+
integration: actionIntegration,
|
|
8744
|
+
actionOperation: 'captureFeedback',
|
|
8745
|
+
endpoint: '/v1/feedback/capture',
|
|
8746
|
+
actionStatus: result.accepted ? 'accepted' : 'clarification_required',
|
|
8747
|
+
accepted: Boolean(result.accepted),
|
|
8748
|
+
failureCode: result.accepted ? null : 'clarification_required',
|
|
8749
|
+
}, req.headers, 'api_action_capture_feedback');
|
|
6485
8750
|
if (result?.accepted) {
|
|
6486
8751
|
// Fan out to any connected dashboard clients so they re-render
|
|
6487
8752
|
// without polling. Non-sensitive summary only (no chat history,
|
|
@@ -6562,6 +8827,11 @@ async function addContext(){
|
|
|
6562
8827
|
}
|
|
6563
8828
|
|
|
6564
8829
|
if (req.method === 'POST' && pathname === '/v1/dpo/export') {
|
|
8830
|
+
const dpoLimit = checkLimit('export_dpo');
|
|
8831
|
+
if (!dpoLimit.allowed) {
|
|
8832
|
+
sendJson(res, 429, { error: 'Free tier: DPO export requires Pro. Upgrade at /pricing', code: 'PRO_REQUIRED' });
|
|
8833
|
+
return;
|
|
8834
|
+
}
|
|
6565
8835
|
const body = await parseJsonBody(req);
|
|
6566
8836
|
const paths = resolveDpoExportPaths(body, {
|
|
6567
8837
|
safeDataDir: requestSafeDataDir,
|
|
@@ -7139,6 +9409,40 @@ async function addContext(){
|
|
|
7139
9409
|
return;
|
|
7140
9410
|
}
|
|
7141
9411
|
|
|
9412
|
+
// GET /v1/dashboard/ai-inventory -- Enterprise AI inventory evidence
|
|
9413
|
+
if (req.method === 'GET' && pathname === '/v1/dashboard/ai-inventory') {
|
|
9414
|
+
try {
|
|
9415
|
+
const {
|
|
9416
|
+
scanAiComponents,
|
|
9417
|
+
buildCycloneDxMlBom,
|
|
9418
|
+
} = require('../../scripts/ai-component-inventory');
|
|
9419
|
+
const requestedRoot = parsed.searchParams.get('root');
|
|
9420
|
+
const serverRoot = process.cwd();
|
|
9421
|
+
const rootDir = requestedRoot ? path.resolve(requestedRoot) : serverRoot;
|
|
9422
|
+
const rootRel = path.relative(serverRoot, rootDir);
|
|
9423
|
+
if (rootRel.startsWith('..') || path.isAbsolute(rootRel)) {
|
|
9424
|
+
sendJson(res, 400, {
|
|
9425
|
+
error: 'ai_inventory_root_out_of_scope',
|
|
9426
|
+
message: 'Dashboard AI inventory root must stay within the server working directory. Use the CLI for explicit cross-project scans.',
|
|
9427
|
+
});
|
|
9428
|
+
return;
|
|
9429
|
+
}
|
|
9430
|
+
const inventory = scanAiComponents({
|
|
9431
|
+
rootDir,
|
|
9432
|
+
maxFiles: parsed.searchParams.get('maxFiles') ? Number(parsed.searchParams.get('maxFiles')) : undefined,
|
|
9433
|
+
includeSnippets: parsed.searchParams.get('snippets') !== '0',
|
|
9434
|
+
});
|
|
9435
|
+
const format = String(parsed.searchParams.get('format') || 'json').toLowerCase();
|
|
9436
|
+
sendJson(res, 200, format === 'cyclonedx' ? buildCycloneDxMlBom(inventory, { version: pkg.version }) : inventory);
|
|
9437
|
+
} catch (err) {
|
|
9438
|
+
sendJson(res, 500, {
|
|
9439
|
+
error: 'ai_inventory_failed',
|
|
9440
|
+
message: err?.message || 'Unable to scan AI component inventory.',
|
|
9441
|
+
});
|
|
9442
|
+
}
|
|
9443
|
+
return;
|
|
9444
|
+
}
|
|
9445
|
+
|
|
7142
9446
|
// GET /v1/dashboard/review-state -- incremental review baseline and deltas
|
|
7143
9447
|
if (req.method === 'GET' && pathname === '/v1/dashboard/review-state') {
|
|
7144
9448
|
const reviewState = readDashboardReviewState(requestFeedbackDir);
|
|
@@ -7155,7 +9459,12 @@ async function addContext(){
|
|
|
7155
9459
|
|
|
7156
9460
|
// POST /v1/dashboard/review-state -- mark current dashboard state as reviewed
|
|
7157
9461
|
if (req.method === 'POST' && pathname === '/v1/dashboard/review-state') {
|
|
9462
|
+
const body = await parseJsonBody(req);
|
|
7158
9463
|
const snapshot = buildReviewSnapshot(requestFeedbackDir);
|
|
9464
|
+
// Override snapshot timestamp with client-provided one if available
|
|
9465
|
+
if (body?.reviewedAt) {
|
|
9466
|
+
snapshot.reviewedAt = body.reviewedAt;
|
|
9467
|
+
}
|
|
7159
9468
|
writeDashboardReviewState(requestFeedbackDir, snapshot);
|
|
7160
9469
|
const data = generateDashboard(requestFeedbackDir, {
|
|
7161
9470
|
reviewBaseline: snapshot,
|
|
@@ -7287,6 +9596,18 @@ async function addContext(){
|
|
|
7287
9596
|
});
|
|
7288
9597
|
report.actionId = evaluation.actionId;
|
|
7289
9598
|
if (report.decisionControl) report.decisionControl.actionId = evaluation.actionId;
|
|
9599
|
+
const actionIntegration = inferActionIntegration(body, req.headers);
|
|
9600
|
+
appendBestEffortTelemetry(requestFeedbackDir, {
|
|
9601
|
+
eventType: chatgptActionEventType(actionIntegration, 'evaluate_decision'),
|
|
9602
|
+
clientType: 'api',
|
|
9603
|
+
source: actionIntegration,
|
|
9604
|
+
integration: actionIntegration,
|
|
9605
|
+
actionOperation: 'evaluateDecision',
|
|
9606
|
+
endpoint: '/v1/decisions/evaluate',
|
|
9607
|
+
actionStatus: 'accepted',
|
|
9608
|
+
accepted: true,
|
|
9609
|
+
decisionMode: report.decisionControl && report.decisionControl.executionMode,
|
|
9610
|
+
}, req.headers, 'api_action_evaluate_decision');
|
|
7290
9611
|
sendJson(res, 200, report);
|
|
7291
9612
|
return;
|
|
7292
9613
|
}
|
|
@@ -7391,6 +9712,7 @@ function startServer({ port, host } = {}) {
|
|
|
7391
9712
|
const listenPort = Number(port ?? process.env.PORT ?? 8787);
|
|
7392
9713
|
const listenHost = String(host ?? process.env.HOST ?? '0.0.0.0').trim() || '0.0.0.0';
|
|
7393
9714
|
const server = createApiServer();
|
|
9715
|
+
registerGracefulShutdown(server);
|
|
7394
9716
|
return new Promise((resolve) => {
|
|
7395
9717
|
server.listen(listenPort, listenHost, () => {
|
|
7396
9718
|
const address = server.address();
|
|
@@ -7406,6 +9728,39 @@ function startServer({ port, host } = {}) {
|
|
|
7406
9728
|
});
|
|
7407
9729
|
}
|
|
7408
9730
|
|
|
9731
|
+
// Railway / Cloud Run / Kubernetes deploy rotations send SIGTERM to swap
|
|
9732
|
+
// containers. Without a handler, Node exits immediately — in-flight requests
|
|
9733
|
+
// are killed and the orchestrator may mark the container as "crashed" (instead
|
|
9734
|
+
// of "gracefully stopped"), wasting its restart-policy budget on a healthy
|
|
9735
|
+
// shutdown. Drain HTTP, give a deadline, then force-exit if anything hangs.
|
|
9736
|
+
function registerGracefulShutdown(server, { gracePeriodMs = 25_000 } = {}) {
|
|
9737
|
+
if (server[GRACEFUL_SHUTDOWN_KEY]) return;
|
|
9738
|
+
server[GRACEFUL_SHUTDOWN_KEY] = true;
|
|
9739
|
+
let shuttingDown = false;
|
|
9740
|
+
const stop = (signal) => {
|
|
9741
|
+
if (shuttingDown) return;
|
|
9742
|
+
shuttingDown = true;
|
|
9743
|
+
console.log(`[shutdown] ${signal} received — draining connections (deadline ${gracePeriodMs}ms)`);
|
|
9744
|
+
const forceTimer = setTimeout(() => {
|
|
9745
|
+
console.error('[shutdown] grace period elapsed — forcing exit');
|
|
9746
|
+
process.exit(1);
|
|
9747
|
+
}, gracePeriodMs);
|
|
9748
|
+
if (typeof forceTimer.unref === 'function') forceTimer.unref();
|
|
9749
|
+
server.close((err) => {
|
|
9750
|
+
if (err) {
|
|
9751
|
+
console.error('[shutdown] server.close error:', err.message);
|
|
9752
|
+
process.exit(1);
|
|
9753
|
+
}
|
|
9754
|
+
console.log('[shutdown] drained cleanly');
|
|
9755
|
+
process.exit(0);
|
|
9756
|
+
});
|
|
9757
|
+
};
|
|
9758
|
+
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
9759
|
+
process.on('SIGINT', () => stop('SIGINT'));
|
|
9760
|
+
}
|
|
9761
|
+
|
|
9762
|
+
const GRACEFUL_SHUTDOWN_KEY = Symbol.for('thumbgate.gracefulShutdownRegistered');
|
|
9763
|
+
|
|
7409
9764
|
module.exports = {
|
|
7410
9765
|
createApiServer,
|
|
7411
9766
|
startServer,
|
|
@@ -7423,6 +9778,15 @@ module.exports = {
|
|
|
7423
9778
|
renderPackagedLessonsHtml,
|
|
7424
9779
|
readOptionalPublicTemplate,
|
|
7425
9780
|
resolveLocalPageBootstrap,
|
|
9781
|
+
getPublicMcpTools,
|
|
9782
|
+
getServerCardTools,
|
|
9783
|
+
buildEnterpriseDataChatStatus,
|
|
9784
|
+
buildEnterpriseDialogflowStatus,
|
|
9785
|
+
buildEnterpriseChatAnswer,
|
|
9786
|
+
answerEnterpriseDataChat,
|
|
9787
|
+
answerEnterpriseDialogflowChat,
|
|
9788
|
+
buildLossAnalyticsResponse,
|
|
9789
|
+
sanitizeHtmlUnsafeJsonValue,
|
|
7426
9790
|
},
|
|
7427
9791
|
};
|
|
7428
9792
|
|