thumbgate 0.9.10

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.
Files changed (364) hide show
  1. package/.claude-plugin/README.md +134 -0
  2. package/.claude-plugin/bundle/icon.png +0 -0
  3. package/.claude-plugin/bundle/icon.svg +18 -0
  4. package/.claude-plugin/bundle/server/index.js +24 -0
  5. package/.claude-plugin/marketplace.json +36 -0
  6. package/.claude-plugin/plugin.json +21 -0
  7. package/.well-known/mcp/server-card.json +231 -0
  8. package/LICENSE +21 -0
  9. package/README.md +375 -0
  10. package/adapters/README.md +9 -0
  11. package/adapters/amp/skills/thumbgate-feedback/SKILL.md +22 -0
  12. package/adapters/chatgpt/INSTALL.md +83 -0
  13. package/adapters/chatgpt/openapi.yaml +1281 -0
  14. package/adapters/claude/.mcp.json +14 -0
  15. package/adapters/codex/config.toml +9 -0
  16. package/adapters/gemini/function-declarations.json +224 -0
  17. package/adapters/mcp/server-stdio.js +788 -0
  18. package/adapters/opencode/opencode.json +15 -0
  19. package/bin/cli.js +1484 -0
  20. package/bin/memory.sh +64 -0
  21. package/bin/obsidian-sync.sh +20 -0
  22. package/bin/postinstall.js +37 -0
  23. package/config/build-metadata.json +4 -0
  24. package/config/e2e-critical-flows.json +45 -0
  25. package/config/gate-templates.json +77 -0
  26. package/config/gates/claim-verification.json +29 -0
  27. package/config/gates/computer-use.json +39 -0
  28. package/config/gates/default.json +117 -0
  29. package/config/github-about.json +25 -0
  30. package/config/mcp-allowlists.json +135 -0
  31. package/config/model-tiers.json +33 -0
  32. package/config/partner-routing.json +132 -0
  33. package/config/policy-bundles/constrained-v1.json +64 -0
  34. package/config/policy-bundles/default-v1.json +91 -0
  35. package/config/rubrics/default-v1.json +52 -0
  36. package/config/skill-packs/react-testing.json +23 -0
  37. package/config/skill-packs/stripe-integration/references/api-spec.json +1 -0
  38. package/config/skill-packs/stripe-integration/references/webhook-guide.md +3 -0
  39. package/config/skill-specs/pr-reviewer.json +9 -0
  40. package/config/skill-specs/release-status.json +9 -0
  41. package/config/skill-specs/ticket-triage.json +9 -0
  42. package/config/subagent-profiles.json +32 -0
  43. package/config/tessl-tiles.json +29 -0
  44. package/config/thumbgate-settings.managed.json +12 -0
  45. package/openapi/openapi.yaml +1281 -0
  46. package/package.json +283 -0
  47. package/plugins/amp-skill/INSTALL.md +52 -0
  48. package/plugins/amp-skill/SKILL.md +64 -0
  49. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +22 -0
  50. package/plugins/claude-codex-bridge/.mcp.json +12 -0
  51. package/plugins/claude-codex-bridge/INSTALL.md +43 -0
  52. package/plugins/claude-codex-bridge/README.md +46 -0
  53. package/plugins/claude-codex-bridge/scripts/codex-bridge.js +288 -0
  54. package/plugins/claude-codex-bridge/skills/adversarial-review/SKILL.md +24 -0
  55. package/plugins/claude-codex-bridge/skills/result/SKILL.md +22 -0
  56. package/plugins/claude-codex-bridge/skills/review/SKILL.md +28 -0
  57. package/plugins/claude-codex-bridge/skills/second-pass/SKILL.md +27 -0
  58. package/plugins/claude-codex-bridge/skills/setup/SKILL.md +21 -0
  59. package/plugins/claude-codex-bridge/skills/status/SKILL.md +19 -0
  60. package/plugins/claude-skill/INSTALL.md +55 -0
  61. package/plugins/claude-skill/SKILL.md +46 -0
  62. package/plugins/codex-profile/.codex-plugin/plugin.json +43 -0
  63. package/plugins/codex-profile/.mcp.json +12 -0
  64. package/plugins/codex-profile/AGENTS.md +20 -0
  65. package/plugins/codex-profile/INSTALL.md +66 -0
  66. package/plugins/codex-profile/README.md +37 -0
  67. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +23 -0
  68. package/plugins/cursor-marketplace/CHANGELOG.md +30 -0
  69. package/plugins/cursor-marketplace/LICENSE +21 -0
  70. package/plugins/cursor-marketplace/README.md +124 -0
  71. package/plugins/cursor-marketplace/agents/reliability-reviewer.md +31 -0
  72. package/plugins/cursor-marketplace/assets/logo-400x400.png +0 -0
  73. package/plugins/cursor-marketplace/commands/capture-feedback.md +33 -0
  74. package/plugins/cursor-marketplace/commands/check-gates.md +25 -0
  75. package/plugins/cursor-marketplace/commands/show-lessons.md +27 -0
  76. package/plugins/cursor-marketplace/hooks/hooks.json +10 -0
  77. package/plugins/cursor-marketplace/mcp.json +12 -0
  78. package/plugins/cursor-marketplace/rules/feedback-capture.mdc +34 -0
  79. package/plugins/cursor-marketplace/rules/pre-action-gates.mdc +30 -0
  80. package/plugins/cursor-marketplace/rules/session-continuity.mdc +28 -0
  81. package/plugins/cursor-marketplace/scripts/gate-check.sh +11 -0
  82. package/plugins/cursor-marketplace/skills/capture-feedback/SKILL.md +47 -0
  83. package/plugins/cursor-marketplace/skills/prevention-rules/SKILL.md +31 -0
  84. package/plugins/cursor-marketplace/skills/recall-context/SKILL.md +30 -0
  85. package/plugins/cursor-marketplace/skills/search-lessons/SKILL.md +33 -0
  86. package/plugins/gemini-extension/INSTALL.md +92 -0
  87. package/plugins/gemini-extension/gemini_prompt.txt +14 -0
  88. package/plugins/gemini-extension/tool_contract.json +45 -0
  89. package/plugins/opencode-profile/INSTALL.md +57 -0
  90. package/public/assets/instagram-card.png +0 -0
  91. package/public/assets/tiktok-agent-memory.mp4 +0 -0
  92. package/public/blog.html +400 -0
  93. package/public/dashboard.html +1093 -0
  94. package/public/guide.html +317 -0
  95. package/public/index.html +1014 -0
  96. package/public/learn/agent-harness-pattern.html +180 -0
  97. package/public/learn/ai-agent-persistent-memory.html +202 -0
  98. package/public/learn/learn.css +45 -0
  99. package/public/learn/mcp-pre-action-gates-explained.html +172 -0
  100. package/public/learn/stop-ai-agent-force-push.html +134 -0
  101. package/public/learn/vibe-coding-safety-net.html +142 -0
  102. package/public/learn.html +213 -0
  103. package/public/lessons.html +650 -0
  104. package/public/vercel.json +8 -0
  105. package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
  106. package/scripts/a2ui-engine.js +73 -0
  107. package/scripts/access-anomaly-detector.js +12 -0
  108. package/scripts/adk-consolidator.js +266 -0
  109. package/scripts/agent-readiness.js +220 -0
  110. package/scripts/agent-security-hardening.js +227 -0
  111. package/scripts/agentic-data-pipeline.js +847 -0
  112. package/scripts/analytics-report.js +328 -0
  113. package/scripts/analytics-window.js +158 -0
  114. package/scripts/async-job-runner.js +1001 -0
  115. package/scripts/audit-trail.js +398 -0
  116. package/scripts/auto-promote-gates.js +299 -0
  117. package/scripts/auto-wire-hooks.js +312 -0
  118. package/scripts/autonomous-sales-agent.js +39 -0
  119. package/scripts/autoresearch-runner.js +216 -0
  120. package/scripts/background-agent-governance.js +237 -0
  121. package/scripts/behavioral-extraction.js +97 -0
  122. package/scripts/belief-update.js +84 -0
  123. package/scripts/billing.js +2438 -0
  124. package/scripts/bot-detector.js +50 -0
  125. package/scripts/budget-guard.js +173 -0
  126. package/scripts/build-claude-mcpb.js +189 -0
  127. package/scripts/build-metadata.js +97 -0
  128. package/scripts/check-congruence.js +322 -0
  129. package/scripts/cli-feedback.js +135 -0
  130. package/scripts/cli-telemetry.js +87 -0
  131. package/scripts/cloudflare-dynamic-sandbox.js +315 -0
  132. package/scripts/code-reasoning.js +350 -0
  133. package/scripts/codegraph-context.js +466 -0
  134. package/scripts/commercial-offer.js +56 -0
  135. package/scripts/computer-use-firewall.js +250 -0
  136. package/scripts/context-engine.js +694 -0
  137. package/scripts/contextfs.js +1287 -0
  138. package/scripts/conversation-context.js +119 -0
  139. package/scripts/creator-campaigns.js +239 -0
  140. package/scripts/daemon-manager.js +108 -0
  141. package/scripts/daily-digest.js +11 -0
  142. package/scripts/dashboard-render-spec.js +395 -0
  143. package/scripts/dashboard.js +1058 -0
  144. package/scripts/data-governance.js +173 -0
  145. package/scripts/delegation-runtime.js +900 -0
  146. package/scripts/deploy-gcp.sh +44 -0
  147. package/scripts/deploy-policy.js +263 -0
  148. package/scripts/disagreement-mining.js +315 -0
  149. package/scripts/dispatch-brief.js +159 -0
  150. package/scripts/distribution-surfaces.js +44 -0
  151. package/scripts/dpo-optimizer.js +209 -0
  152. package/scripts/ephemeral-agent-store.js +219 -0
  153. package/scripts/eval-harness.js +56 -0
  154. package/scripts/evolution-state.js +241 -0
  155. package/scripts/experiment-tracker.js +267 -0
  156. package/scripts/export-databricks-bundle.js +242 -0
  157. package/scripts/export-dpo-pairs.js +345 -0
  158. package/scripts/export-kto-pairs.js +310 -0
  159. package/scripts/export-training.js +448 -0
  160. package/scripts/failure-diagnostics.js +558 -0
  161. package/scripts/feedback-attribution.js +313 -0
  162. package/scripts/feedback-fallback.js +111 -0
  163. package/scripts/feedback-history-distiller.js +391 -0
  164. package/scripts/feedback-inbox-read.js +162 -0
  165. package/scripts/feedback-loop.js +1887 -0
  166. package/scripts/feedback-paths.js +145 -0
  167. package/scripts/feedback-quality.js +139 -0
  168. package/scripts/feedback-root-consolidator.js +238 -0
  169. package/scripts/feedback-schema.js +426 -0
  170. package/scripts/feedback-session.js +286 -0
  171. package/scripts/feedback-to-memory.js +185 -0
  172. package/scripts/feedback-to-rules.js +163 -0
  173. package/scripts/filesystem-search.js +404 -0
  174. package/scripts/funnel-analytics.js +35 -0
  175. package/scripts/gate-satisfy.js +42 -0
  176. package/scripts/gate-stats.js +116 -0
  177. package/scripts/gate-templates.js +70 -0
  178. package/scripts/gates-engine.js +816 -0
  179. package/scripts/generate-paperbanana-diagrams.sh +99 -0
  180. package/scripts/generate-pretool-hook.sh +40 -0
  181. package/scripts/github-about.js +350 -0
  182. package/scripts/github-outreach.js +65 -0
  183. package/scripts/gtm-revenue-loop.js +520 -0
  184. package/scripts/hallucination-detector.js +226 -0
  185. package/scripts/hf-papers.js +317 -0
  186. package/scripts/history-distiller.js +200 -0
  187. package/scripts/hook-auto-capture.sh +95 -0
  188. package/scripts/hook-stop-pr-thread-check.sh +68 -0
  189. package/scripts/hook-stop-self-score.sh +51 -0
  190. package/scripts/hook-stop-verify-deploy.sh +31 -0
  191. package/scripts/hook-thumbgate-cache-updater.js +48 -0
  192. package/scripts/hook-verify-before-done.sh +20 -0
  193. package/scripts/hosted-config.js +170 -0
  194. package/scripts/hybrid-feedback-context.js +676 -0
  195. package/scripts/install-mcp.js +159 -0
  196. package/scripts/intent-router.js +392 -0
  197. package/scripts/internal-agent-bootstrap.js +490 -0
  198. package/scripts/jsonl-watcher.js +155 -0
  199. package/scripts/lesson-db.js +613 -0
  200. package/scripts/lesson-inference.js +315 -0
  201. package/scripts/lesson-retrieval.js +95 -0
  202. package/scripts/lesson-rotation.js +137 -0
  203. package/scripts/lesson-search.js +644 -0
  204. package/scripts/lesson-synthesis.js +196 -0
  205. package/scripts/license.js +50 -0
  206. package/scripts/local-model-profile.js +383 -0
  207. package/scripts/markdown-escape.js +12 -0
  208. package/scripts/marketing-experiment.js +671 -0
  209. package/scripts/mcp-config.js +149 -0
  210. package/scripts/mcp-policy.js +99 -0
  211. package/scripts/memalign-recall.js +111 -0
  212. package/scripts/memory-firewall.js +222 -0
  213. package/scripts/memory-migration.js +296 -0
  214. package/scripts/meta-policy.js +194 -0
  215. package/scripts/metered-billing.js +16 -0
  216. package/scripts/model-tier-router.js +301 -0
  217. package/scripts/money-watcher.js +71 -0
  218. package/scripts/multi-hop-recall.js +240 -0
  219. package/scripts/natural-language-harness.js +330 -0
  220. package/scripts/obsidian-export.js +712 -0
  221. package/scripts/operational-dashboard.js +103 -0
  222. package/scripts/operational-summary.js +93 -0
  223. package/scripts/optimize-context.js +17 -0
  224. package/scripts/org-dashboard.js +201 -0
  225. package/scripts/partner-orchestration.js +146 -0
  226. package/scripts/per-step-scoring.js +165 -0
  227. package/scripts/perplexity-marketing.js +466 -0
  228. package/scripts/pii-scanner.js +153 -0
  229. package/scripts/plan-gate.js +154 -0
  230. package/scripts/post-everywhere.js +308 -0
  231. package/scripts/post-to-x-retry.sh +22 -0
  232. package/scripts/post-to-x.js +369 -0
  233. package/scripts/pr-manager.js +236 -0
  234. package/scripts/predictive-insights.js +356 -0
  235. package/scripts/principle-extractor.js +162 -0
  236. package/scripts/pro-features.js +40 -0
  237. package/scripts/pro-local-dashboard.js +174 -0
  238. package/scripts/problem-detail.js +53 -0
  239. package/scripts/product-feedback.js +134 -0
  240. package/scripts/profile-router.js +245 -0
  241. package/scripts/prompt-dlp.js +221 -0
  242. package/scripts/prompt-guard.js +83 -0
  243. package/scripts/prove-adapters.js +863 -0
  244. package/scripts/prove-attribution.js +365 -0
  245. package/scripts/prove-automation.js +653 -0
  246. package/scripts/prove-autoresearch.js +304 -0
  247. package/scripts/prove-claim-verification.js +277 -0
  248. package/scripts/prove-cloudflare-sandbox.js +163 -0
  249. package/scripts/prove-data-pipeline.js +410 -0
  250. package/scripts/prove-data-quality.js +227 -0
  251. package/scripts/prove-evolution.js +352 -0
  252. package/scripts/prove-harnesses.js +287 -0
  253. package/scripts/prove-intelligence.js +259 -0
  254. package/scripts/prove-lancedb.js +371 -0
  255. package/scripts/prove-local-intelligence.js +342 -0
  256. package/scripts/prove-loop-closure.js +263 -0
  257. package/scripts/prove-predictive-insights.js +357 -0
  258. package/scripts/prove-runtime.js +350 -0
  259. package/scripts/prove-seo-gsd.js +234 -0
  260. package/scripts/prove-settings.js +279 -0
  261. package/scripts/prove-subway-upgrades.js +277 -0
  262. package/scripts/prove-tessl.js +229 -0
  263. package/scripts/prove-training-export.js +327 -0
  264. package/scripts/prove-workflow-contract.js +116 -0
  265. package/scripts/prove-xmemory.js +332 -0
  266. package/scripts/publish-decision.js +133 -0
  267. package/scripts/pulse.js +80 -0
  268. package/scripts/rate-limiter.js +125 -0
  269. package/scripts/reddit-dm-outreach.js +182 -0
  270. package/scripts/reddit-monitor-cron.sh +26 -0
  271. package/scripts/reflector-agent.js +221 -0
  272. package/scripts/reminder-engine.js +132 -0
  273. package/scripts/revenue-status.js +472 -0
  274. package/scripts/risk-scorer.js +458 -0
  275. package/scripts/rlaif-self-audit.js +129 -0
  276. package/scripts/rubric-engine.js +230 -0
  277. package/scripts/schedule-manager.js +251 -0
  278. package/scripts/secret-scanner.js +414 -0
  279. package/scripts/self-heal.js +147 -0
  280. package/scripts/self-healing-check.js +188 -0
  281. package/scripts/semantic-layer.js +98 -0
  282. package/scripts/seo-gsd.js +1153 -0
  283. package/scripts/settings-hierarchy.js +214 -0
  284. package/scripts/shieldcortex-memory-firewall-runner.mjs +53 -0
  285. package/scripts/skill-exporter.js +262 -0
  286. package/scripts/skill-generator.js +446 -0
  287. package/scripts/skill-materializer.js +134 -0
  288. package/scripts/skill-packs.js +136 -0
  289. package/scripts/skill-proposer.js +99 -0
  290. package/scripts/skill-quality-tracker.js +284 -0
  291. package/scripts/slo-alert-engine.js +14 -0
  292. package/scripts/slow-loop.js +72 -0
  293. package/scripts/social-analytics/db/schema.sql +32 -0
  294. package/scripts/social-analytics/digest.js +256 -0
  295. package/scripts/social-analytics/generate-instagram-card.js +97 -0
  296. package/scripts/social-analytics/instagram-thumbgate-post.js +73 -0
  297. package/scripts/social-analytics/mcp-server.js +289 -0
  298. package/scripts/social-analytics/normalizer.js +580 -0
  299. package/scripts/social-analytics/notify.js +162 -0
  300. package/scripts/social-analytics/poll-all.js +107 -0
  301. package/scripts/social-analytics/pollers/github.js +195 -0
  302. package/scripts/social-analytics/pollers/instagram.js +253 -0
  303. package/scripts/social-analytics/pollers/linkedin.js +330 -0
  304. package/scripts/social-analytics/pollers/plausible.js +247 -0
  305. package/scripts/social-analytics/pollers/reddit.js +306 -0
  306. package/scripts/social-analytics/pollers/threads.js +233 -0
  307. package/scripts/social-analytics/pollers/tiktok.js +203 -0
  308. package/scripts/social-analytics/pollers/x.js +227 -0
  309. package/scripts/social-analytics/pollers/youtube.js +304 -0
  310. package/scripts/social-analytics/pollers/zernio.js +180 -0
  311. package/scripts/social-analytics/publish-instagram-thumbgate.js +85 -0
  312. package/scripts/social-analytics/publishers/devto.js +122 -0
  313. package/scripts/social-analytics/publishers/instagram.js +317 -0
  314. package/scripts/social-analytics/publishers/linkedin.js +294 -0
  315. package/scripts/social-analytics/publishers/reddit.js +390 -0
  316. package/scripts/social-analytics/publishers/threads.js +275 -0
  317. package/scripts/social-analytics/publishers/tiktok.js +217 -0
  318. package/scripts/social-analytics/publishers/x.js +259 -0
  319. package/scripts/social-analytics/publishers/youtube.js +223 -0
  320. package/scripts/social-analytics/publishers/zernio.js +209 -0
  321. package/scripts/social-analytics/run-digest.js +34 -0
  322. package/scripts/social-analytics/store.js +257 -0
  323. package/scripts/social-analytics/utm.js +143 -0
  324. package/scripts/social-pipeline.js +2628 -0
  325. package/scripts/social-quality-gate.js +18 -0
  326. package/scripts/social-reply-monitor.js +445 -0
  327. package/scripts/status-dashboard.js +155 -0
  328. package/scripts/statusline-lesson.js +16 -0
  329. package/scripts/statusline-tower.js +8 -0
  330. package/scripts/statusline.sh +116 -0
  331. package/scripts/stripe-live-status.js +115 -0
  332. package/scripts/subagent-profiles.js +79 -0
  333. package/scripts/sync-gh-secrets-from-env.sh +70 -0
  334. package/scripts/sync-github-about.js +52 -0
  335. package/scripts/sync-version.js +451 -0
  336. package/scripts/synthetic-dpo.js +234 -0
  337. package/scripts/telemetry-analytics.js +821 -0
  338. package/scripts/tessl-export.js +371 -0
  339. package/scripts/test-coverage.js +120 -0
  340. package/scripts/thompson-sampling.js +417 -0
  341. package/scripts/thumbgate-search.js +189 -0
  342. package/scripts/tool-kpi-tracker.js +12 -0
  343. package/scripts/tool-registry.js +811 -0
  344. package/scripts/train_from_feedback.py +910 -0
  345. package/scripts/user-profile.js +78 -0
  346. package/scripts/validate-feedback.js +580 -0
  347. package/scripts/validate-workflow-contract.js +287 -0
  348. package/scripts/vector-store.js +198 -0
  349. package/scripts/verification-loop.js +291 -0
  350. package/scripts/verify-obsidian-setup.sh +269 -0
  351. package/scripts/verify-run.js +269 -0
  352. package/scripts/webhook-delivery.js +62 -0
  353. package/scripts/weekly-auto-post.js +124 -0
  354. package/scripts/workflow-runs.js +154 -0
  355. package/scripts/workflow-sprint-intake.js +475 -0
  356. package/scripts/workspace-evolver.js +374 -0
  357. package/scripts/x-autonomous-marketing.js +139 -0
  358. package/scripts/xmemory-lite.js +405 -0
  359. package/skills/agent-memory/SKILL.md +97 -0
  360. package/skills/solve-architecture-autonomy/SKILL.md +17 -0
  361. package/skills/solve-architecture-autonomy/tool.js +33 -0
  362. package/skills/thumbgate/SKILL.md +114 -0
  363. package/skills/thumbgate-feedback/SKILL.md +49 -0
  364. package/src/api/server.js +4208 -0
@@ -0,0 +1,4208 @@
1
+ #!/usr/bin/env node
2
+ const http = require('http');
3
+ const https = require('https');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const pkg = require('../../package.json');
7
+
8
+ const {
9
+ captureFeedback,
10
+ analyzeFeedback,
11
+ feedbackSummary,
12
+ writePreventionRules,
13
+ getFeedbackPaths,
14
+ appendDiagnosticRecord,
15
+ } = require('../../scripts/feedback-loop');
16
+ const {
17
+ readRecentConversationWindow,
18
+ } = require('../../scripts/feedback-history-distiller');
19
+ const {
20
+ readJSONL,
21
+ exportDpoFromMemories,
22
+ DEFAULT_LOCAL_MEMORY_LOG,
23
+ } = require('../../scripts/export-dpo-pairs');
24
+ const {
25
+ exportDatabricksBundle,
26
+ } = require('../../scripts/export-databricks-bundle');
27
+ const {
28
+ ensureContextFs,
29
+ normalizeNamespaces,
30
+ constructContextPack,
31
+ evaluateContextPack,
32
+ getProvenance,
33
+ } = require('../../scripts/contextfs');
34
+ const {
35
+ buildRubricEvaluation,
36
+ } = require('../../scripts/rubric-engine');
37
+ const {
38
+ listIntents,
39
+ planIntent,
40
+ } = require('../../scripts/intent-router');
41
+ const {
42
+ startHandoff,
43
+ completeHandoff,
44
+ } = require('../../scripts/delegation-runtime');
45
+ const {
46
+ bootstrapInternalAgent,
47
+ } = require('../../scripts/internal-agent-bootstrap');
48
+ const {
49
+ buildCloudflareSandboxPlan,
50
+ } = require('../../scripts/cloudflare-dynamic-sandbox');
51
+ const {
52
+ loadModel,
53
+ getReliability,
54
+ samplePosteriors,
55
+ } = require('../../scripts/thompson-sampling');
56
+ const {
57
+ createCheckoutSession,
58
+ getCheckoutSessionStatus,
59
+ provisionApiKey,
60
+ validateApiKey,
61
+ recordUsage,
62
+ rotateApiKey,
63
+ handleWebhook,
64
+ verifyWebhookSignature,
65
+ verifyGithubWebhookSignature,
66
+ handleGithubWebhook,
67
+ getFunnelAnalytics,
68
+ getBillingSummary,
69
+ getBillingSummaryLive,
70
+ } = require('../../scripts/billing');
71
+ const {
72
+ resolveHostedBillingConfig,
73
+ createTraceId,
74
+ buildHostedSuccessUrl,
75
+ buildHostedCancelUrl,
76
+ } = require('../../scripts/hosted-config');
77
+ const {
78
+ PRO_MONTHLY_PRICE_DOLLARS,
79
+ PRO_ANNUAL_PRICE_DOLLARS,
80
+ TEAM_MONTHLY_PRICE_DOLLARS,
81
+ normalizePlanId,
82
+ normalizeBillingCycle,
83
+ normalizeSeatCount,
84
+ } = require('../../scripts/commercial-offer');
85
+ const {
86
+ generateSkills,
87
+ } = require('../../scripts/skill-generator');
88
+ const {
89
+ satisfyCondition,
90
+ loadStats: loadGateStats,
91
+ setConstraint,
92
+ loadConstraints,
93
+ } = require('../../scripts/gates-engine');
94
+ const {
95
+ generateDashboard,
96
+ } = require('../../scripts/dashboard');
97
+ const {
98
+ buildDashboardRenderSpec,
99
+ } = require('../../scripts/dashboard-render-spec');
100
+ const {
101
+ getSettingsStatus,
102
+ } = require('../../scripts/settings-hierarchy');
103
+ const {
104
+ searchLessons,
105
+ } = require('../../scripts/lesson-search');
106
+ const {
107
+ updateRecordInJsonl,
108
+ deleteRecordFromJsonl,
109
+ readJSONLLocal,
110
+ } = require('../../scripts/lesson-synthesis');
111
+ const {
112
+ searchThumbgate,
113
+ } = require('../../scripts/thumbgate-search');
114
+ const {
115
+ appendTelemetryPing,
116
+ } = require('../../scripts/telemetry-analytics');
117
+ const {
118
+ buildProductIssueTitle,
119
+ submitProductIssue,
120
+ } = require('../../scripts/product-feedback');
121
+ const {
122
+ resolveBuildMetadata,
123
+ } = require('../../scripts/build-metadata');
124
+ const {
125
+ resolveAnalyticsWindow,
126
+ } = require('../../scripts/analytics-window');
127
+ const {
128
+ appendWorkflowSprintLead,
129
+ advanceWorkflowSprintLead,
130
+ } = require('../../scripts/workflow-sprint-intake');
131
+ const {
132
+ checkLimit,
133
+ UPGRADE_MESSAGE: RATE_LIMIT_MESSAGE,
134
+ } = require('../../scripts/rate-limiter');
135
+ const { sendProblem, PROBLEM_TYPES } = require('../../scripts/problem-detail');
136
+ const { TOOLS: MCP_TOOLS } = require('../../scripts/tool-registry');
137
+ const {
138
+ findSeoPageByPath,
139
+ renderSeoPageHtml,
140
+ THUMBGATE_SEO_SITEMAP_ENTRIES,
141
+ } = require('../../scripts/seo-gsd');
142
+
143
+ const LANDING_PAGE_PATH = path.resolve(__dirname, '../../public/index.html');
144
+ const DASHBOARD_PAGE_PATH = path.resolve(__dirname, '../../public/dashboard.html');
145
+ const LESSONS_PAGE_PATH = path.resolve(__dirname, '../../public/lessons.html');
146
+ const GUIDE_PAGE_PATH = path.resolve(__dirname, '../../public/guide.html');
147
+ const LEARN_PAGE_PATH = path.resolve(__dirname, '../../public/learn.html');
148
+ const LEARN_DIR = path.resolve(__dirname, '../../public/learn');
149
+ const VISITOR_COOKIE_NAME = 'thumbgate_visitor_id';
150
+ const SESSION_COOKIE_NAME = 'thumbgate_session_id';
151
+ const ACQUISITION_COOKIE_NAME = 'thumbgate_acquisition_id';
152
+ const VISITOR_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 90;
153
+ const BUILD_METADATA = resolveBuildMetadata();
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Stripe event tracking helpers
157
+ // ---------------------------------------------------------------------------
158
+ const STRIPE_EVENTS_PATH = path.resolve(__dirname, '../../.thumbgate/stripe-events.jsonl');
159
+
160
+ function ensureStripeEventsDir() {
161
+ const dir = path.dirname(STRIPE_EVENTS_PATH);
162
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
163
+ }
164
+
165
+ function appendStripeEvent(record) {
166
+ ensureStripeEventsDir();
167
+ fs.appendFileSync(STRIPE_EVENTS_PATH, JSON.stringify(record) + '\n', 'utf8');
168
+ }
169
+
170
+ function readStripeEvents() {
171
+ ensureStripeEventsDir();
172
+ if (!fs.existsSync(STRIPE_EVENTS_PATH)) return [];
173
+ const lines = fs.readFileSync(STRIPE_EVENTS_PATH, 'utf8').split('\n').filter(Boolean);
174
+ return lines.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
175
+ }
176
+
177
+ function computeConversionStats(events) {
178
+ const active = {};
179
+ const cancelled = new Set();
180
+ for (const ev of events) {
181
+ if (ev.event_type === 'customer.subscription.created' || ev.event_type === 'checkout.session.completed') {
182
+ if (ev.customer_email) active[ev.customer_email] = ev;
183
+ }
184
+ if (ev.event_type === 'customer.subscription.deleted') {
185
+ if (ev.customer_email) cancelled.add(ev.customer_email);
186
+ }
187
+ }
188
+ for (const email of cancelled) delete active[email];
189
+ const subscribers = Object.values(active);
190
+ const mrr = subscribers.reduce((sum, ev) => sum + (ev.amount_cents ? ev.amount_cents / 100 : 0), 0);
191
+ const recent = [...events].reverse().slice(0, 20);
192
+ return { total_subscribers: subscribers.length, mrr_dollars: Math.round(mrr * 100) / 100, recent_events: recent };
193
+ }
194
+ // ---------------------------------------------------------------------------
195
+
196
+ function getSafeDataDir() {
197
+ const { FEEDBACK_LOG_PATH } = getFeedbackPaths();
198
+ return path.resolve(path.dirname(FEEDBACK_LOG_PATH));
199
+ }
200
+
201
+ function findRecordById(id, feedbackDir) {
202
+ const memoryLogPath = path.join(feedbackDir, 'memory-log.jsonl');
203
+ const feedbackLogPath = path.join(feedbackDir, 'feedback-log.jsonl');
204
+ let memoryRecord = null;
205
+ let feedbackEvent = null;
206
+ const memoryRecords = readJSONLLocal(memoryLogPath, { maxLines: 0 });
207
+ for (const rec of memoryRecords) {
208
+ if (rec.id === id) { memoryRecord = rec; break; }
209
+ }
210
+ const feedbackRecords = readJSONLLocal(feedbackLogPath, { maxLines: 0 });
211
+ for (const rec of feedbackRecords) {
212
+ if (rec.id === id) { feedbackEvent = rec; break; }
213
+ }
214
+ if (!memoryRecord && !feedbackEvent) return null;
215
+ return { feedbackEvent, memoryRecord };
216
+ }
217
+
218
+ function getPublicMcpTools() {
219
+ return MCP_TOOLS.map((tool) => ({
220
+ name: tool.name,
221
+ description: tool.description,
222
+ inputSchema: tool.inputSchema,
223
+ }));
224
+ }
225
+
226
+ function getServerCardTools() {
227
+ return MCP_TOOLS.map((tool) => ({
228
+ name: tool.name,
229
+ description: tool.description,
230
+ inputSchema: tool.inputSchema,
231
+ }));
232
+ }
233
+
234
+ function createHttpError(statusCode, message) {
235
+ const err = new Error(message);
236
+ err.statusCode = statusCode;
237
+ return err;
238
+ }
239
+
240
+ function normalizeNullableText(value) {
241
+ if (value === undefined || value === null) return null;
242
+ const text = String(value).trim();
243
+ return text || null;
244
+ }
245
+
246
+ function pickFirstText(...values) {
247
+ for (const value of values) {
248
+ const normalized = normalizeNullableText(value);
249
+ if (normalized) return normalized;
250
+ }
251
+ return null;
252
+ }
253
+
254
+ function parseReferrerHost(referrer) {
255
+ const normalized = normalizeNullableText(referrer);
256
+ if (!normalized) return null;
257
+ try {
258
+ return new URL(normalized).host || null;
259
+ } catch {
260
+ return null;
261
+ }
262
+ }
263
+
264
+ function parseRedditCommunity(referrer) {
265
+ const normalized = normalizeNullableText(referrer);
266
+ if (!normalized) return null;
267
+ try {
268
+ const ref = new URL(normalized);
269
+ const parts = ref.pathname.split('/').filter(Boolean);
270
+ const index = parts.findIndex((part) => part.toLowerCase() === 'r');
271
+ return index !== -1 && parts[index + 1] ? parts[index + 1] : null;
272
+ } catch {
273
+ return null;
274
+ }
275
+ }
276
+
277
+ function inferSearchSurface(referrerHost) {
278
+ const normalizedHost = normalizeNullableText(referrerHost);
279
+ if (!normalizedHost) return null;
280
+ const host = normalizedHost.toLowerCase();
281
+ if (/(^|\.)reddit\.com$/.test(host) || host === 'redd.it') return 'reddit';
282
+ if (/(^|\.)google\.com$/.test(host)) return 'google_search';
283
+ if (/(^|\.)bing\.com$/.test(host)) return 'bing_search';
284
+ if (/(^|\.)duckduckgo\.com$/.test(host)) return 'duckduckgo_search';
285
+ if (/(^|\.)search\.yahoo\.com$/.test(host)) return 'yahoo_search';
286
+ if (/(^|\.)search\.brave\.com$/.test(host)) return 'brave_search';
287
+ if (/(^|\.)ecosia\.org$/.test(host)) return 'ecosia_search';
288
+ if (/(^|\.)perplexity\.ai$/.test(host)) return 'perplexity';
289
+ if (host === 'chat.openai.com' || /(^|\.)chatgpt\.com$/.test(host)) return 'chatgpt';
290
+ if (/(^|\.)claude\.ai$/.test(host)) return 'claude';
291
+ if (/(^|\.)gemini\.google\.com$/.test(host)) return 'gemini';
292
+ return null;
293
+ }
294
+
295
+ function inferSource(referrerHost) {
296
+ const surface = inferSearchSurface(referrerHost);
297
+ if (surface === 'reddit') return 'reddit';
298
+ if (surface && /_search$/.test(surface)) return 'organic_search';
299
+ if (surface) return 'ai_search';
300
+ return 'website';
301
+ }
302
+
303
+ function inferSearchQuery(referrer) {
304
+ const normalized = normalizeNullableText(referrer);
305
+ if (!normalized) return null;
306
+ try {
307
+ const ref = new URL(normalized);
308
+ for (const key of ['q', 'query', 'p']) {
309
+ const value = ref.searchParams.get(key);
310
+ if (value && value.trim()) {
311
+ return value.trim().slice(0, 160);
312
+ }
313
+ }
314
+ } catch {
315
+ return null;
316
+ }
317
+ return null;
318
+ }
319
+
320
+ function getAttributionValue(params, key, fallbackValue) {
321
+ const value = params.get(key);
322
+ return value && value.trim() ? value.trim() : fallbackValue;
323
+ }
324
+
325
+ function parseUrlSearchParams(urlValue) {
326
+ const normalized = normalizeNullableText(urlValue);
327
+ if (!normalized) return new URLSearchParams();
328
+ try {
329
+ return new URL(normalized).searchParams;
330
+ } catch {
331
+ return new URLSearchParams();
332
+ }
333
+ }
334
+
335
+ function parseCookies(headerValue) {
336
+ const cookies = {};
337
+ const raw = normalizeNullableText(headerValue);
338
+ if (!raw) return cookies;
339
+ for (const chunk of raw.split(';')) {
340
+ const [name, ...rest] = chunk.split('=');
341
+ const key = normalizeNullableText(name);
342
+ if (!key) continue;
343
+ const value = normalizeNullableText(rest.join('='));
344
+ if (!value) continue;
345
+ try {
346
+ cookies[key] = decodeURIComponent(value);
347
+ } catch {
348
+ cookies[key] = value;
349
+ }
350
+ }
351
+ return cookies;
352
+ }
353
+
354
+ function serializeCookie(name, value, options = {}) {
355
+ const parts = [`${name}=${encodeURIComponent(String(value))}`];
356
+ parts.push(`Path=${options.path || '/'}`);
357
+ parts.push(`SameSite=${options.sameSite || 'Lax'}`);
358
+ if (options.maxAge) parts.push(`Max-Age=${options.maxAge}`);
359
+ if (options.httpOnly !== false) parts.push('HttpOnly');
360
+ if (options.secure) parts.push('Secure');
361
+ return parts.join('; ');
362
+ }
363
+
364
+ function isSecureRequest(req) {
365
+ const forwardedProto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
366
+ return forwardedProto === 'https' || Boolean(req.socket && req.socket.encrypted);
367
+ }
368
+
369
+ function resolveJourneyState(req, parsed) {
370
+ const params = parsed ? parsed.searchParams : new URLSearchParams();
371
+ const cookies = parseCookies(req.headers.cookie);
372
+ const visitorId = pickFirstText(
373
+ params.get('visitor_id'),
374
+ params.get('install_id'),
375
+ cookies[VISITOR_COOKIE_NAME]
376
+ ) || createJourneyId('visitor');
377
+ const sessionId = pickFirstText(
378
+ params.get('visitor_session_id'),
379
+ params.get('session_id'),
380
+ cookies[SESSION_COOKIE_NAME]
381
+ ) || createJourneyId('session');
382
+ const acquisitionId = pickFirstText(
383
+ params.get('acquisition_id'),
384
+ cookies[ACQUISITION_COOKIE_NAME]
385
+ ) || createJourneyId('acq');
386
+ const secure = isSecureRequest(req);
387
+ const setCookieHeaders = [];
388
+
389
+ if (cookies[VISITOR_COOKIE_NAME] !== visitorId) {
390
+ setCookieHeaders.push(serializeCookie(VISITOR_COOKIE_NAME, visitorId, {
391
+ maxAge: VISITOR_COOKIE_MAX_AGE_SECONDS,
392
+ secure,
393
+ }));
394
+ }
395
+ if (cookies[SESSION_COOKIE_NAME] !== sessionId) {
396
+ setCookieHeaders.push(serializeCookie(SESSION_COOKIE_NAME, sessionId, { secure }));
397
+ }
398
+ if (cookies[ACQUISITION_COOKIE_NAME] !== acquisitionId) {
399
+ setCookieHeaders.push(serializeCookie(ACQUISITION_COOKIE_NAME, acquisitionId, { secure }));
400
+ }
401
+
402
+ return {
403
+ visitorId,
404
+ sessionId,
405
+ acquisitionId,
406
+ setCookieHeaders,
407
+ };
408
+ }
409
+
410
+ function buildLandingAttribution(parsed, req) {
411
+ const params = parsed.searchParams;
412
+ const referrer = pickFirstText(req.headers.referer, req.headers.referrer);
413
+ const referrerHost = parseReferrerHost(referrer);
414
+ const seoSurface = getAttributionValue(params, 'seo_surface', inferSearchSurface(referrerHost));
415
+ const source = getAttributionValue(params, 'utm_source', inferSource(referrerHost));
416
+ const community = getAttributionValue(
417
+ params,
418
+ 'community',
419
+ getAttributionValue(params, 'subreddit', parseRedditCommunity(referrer))
420
+ );
421
+
422
+ return {
423
+ source,
424
+ utmSource: source,
425
+ utmMedium: getAttributionValue(
426
+ params,
427
+ 'utm_medium',
428
+ source === 'reddit' ? 'organic_social' : 'landing_page'
429
+ ),
430
+ utmCampaign: getAttributionValue(
431
+ params,
432
+ 'utm_campaign',
433
+ source === 'reddit' ? 'reddit_organic' : 'organic'
434
+ ),
435
+ utmContent: getAttributionValue(params, 'utm_content', null),
436
+ utmTerm: getAttributionValue(params, 'utm_term', null),
437
+ creator: getAttributionValue(params, 'creator', getAttributionValue(params, 'creator_handle', null)),
438
+ community,
439
+ postId: getAttributionValue(params, 'post_id', null),
440
+ commentId: getAttributionValue(params, 'comment_id', null),
441
+ campaignVariant: getAttributionValue(params, 'campaign_variant', null),
442
+ offerCode: getAttributionValue(params, 'offer_code', null),
443
+ landingPath: parsed.pathname || '/',
444
+ page: parsed.pathname || '/',
445
+ referrer,
446
+ referrerHost,
447
+ seoSurface,
448
+ seoQuery: getAttributionValue(params, 'seo_query', inferSearchQuery(referrer)),
449
+ };
450
+ }
451
+
452
+ function buildReferrerAttribution(req) {
453
+ const referrer = pickFirstText(req.headers.referer, req.headers.referrer);
454
+ const referrerHost = parseReferrerHost(referrer);
455
+ const params = parseUrlSearchParams(referrer);
456
+ const source = getAttributionValue(params, 'utm_source', inferSource(referrerHost));
457
+ const community = getAttributionValue(
458
+ params,
459
+ 'community',
460
+ getAttributionValue(params, 'subreddit', parseRedditCommunity(referrer))
461
+ );
462
+ return {
463
+ source,
464
+ utmSource: source,
465
+ utmMedium: getAttributionValue(
466
+ params,
467
+ 'utm_medium',
468
+ source === 'reddit' ? 'organic_social' : 'workflow_sprint_intake'
469
+ ),
470
+ utmCampaign: getAttributionValue(
471
+ params,
472
+ 'utm_campaign',
473
+ source === 'reddit' ? 'reddit_organic' : 'workflow_hardening_sprint'
474
+ ),
475
+ utmContent: getAttributionValue(params, 'utm_content', null),
476
+ utmTerm: getAttributionValue(params, 'utm_term', null),
477
+ creator: getAttributionValue(params, 'creator', getAttributionValue(params, 'creator_handle', null)),
478
+ community,
479
+ postId: getAttributionValue(params, 'post_id', null),
480
+ commentId: getAttributionValue(params, 'comment_id', null),
481
+ campaignVariant: getAttributionValue(params, 'campaign_variant', null),
482
+ offerCode: getAttributionValue(params, 'offer_code', null),
483
+ referrer,
484
+ referrerHost,
485
+ seoSurface: getAttributionValue(params, 'seo_surface', inferSearchSurface(referrerHost)),
486
+ seoQuery: getAttributionValue(params, 'seo_query', inferSearchQuery(referrer)),
487
+ landingPath: (() => {
488
+ try {
489
+ return new URL(referrer).pathname || '/';
490
+ } catch {
491
+ return '/';
492
+ }
493
+ })(),
494
+ page: (() => {
495
+ try {
496
+ return new URL(referrer).pathname || '/';
497
+ } catch {
498
+ return '/';
499
+ }
500
+ })(),
501
+ };
502
+ }
503
+
504
+ function buildCheckoutAttributionMetadata(body, req, traceId) {
505
+ const rawMetadata = body && body.metadata && typeof body.metadata === 'object' ? body.metadata : {};
506
+ const utmSource = pickFirstText(rawMetadata.utmSource, body.utmSource, rawMetadata.source, body.source);
507
+ const utmMedium = pickFirstText(rawMetadata.utmMedium, body.utmMedium, 'checkout_api');
508
+ const referrer = pickFirstText(rawMetadata.referrer, body.referrer, req.headers.referer, req.headers.referrer);
509
+ const planId = normalizePlanId(pickFirstText(rawMetadata.planId, body.planId, 'pro'));
510
+ const billingCycle = normalizeBillingCycle(
511
+ pickFirstText(rawMetadata.billingCycle, rawMetadata.billing_cycle, body.billingCycle, body.billing_cycle, 'monthly')
512
+ );
513
+ const seatCount = planId === 'team'
514
+ ? normalizeSeatCount(pickFirstText(rawMetadata.seatCount, rawMetadata.seat_count, body.seatCount, body.seat_count))
515
+ : 1;
516
+
517
+ return {
518
+ ...rawMetadata,
519
+ traceId,
520
+ acquisitionId: pickFirstText(rawMetadata.acquisitionId, body.acquisitionId),
521
+ visitorId: pickFirstText(rawMetadata.visitorId, body.visitorId),
522
+ sessionId: pickFirstText(rawMetadata.sessionId, body.sessionId),
523
+ source: pickFirstText(rawMetadata.source, body.source, utmSource, 'direct'),
524
+ utmSource,
525
+ utmMedium,
526
+ utmCampaign: pickFirstText(rawMetadata.utmCampaign, body.utmCampaign),
527
+ utmContent: pickFirstText(rawMetadata.utmContent, body.utmContent),
528
+ utmTerm: pickFirstText(rawMetadata.utmTerm, body.utmTerm),
529
+ creator: pickFirstText(rawMetadata.creator, rawMetadata.creatorHandle, rawMetadata.creator_handle, body.creator, body.creatorHandle, body.creator_handle),
530
+ community: pickFirstText(rawMetadata.community, rawMetadata.subreddit, body.community, body.subreddit),
531
+ postId: pickFirstText(rawMetadata.postId, rawMetadata.post_id, body.postId, body.post_id),
532
+ commentId: pickFirstText(rawMetadata.commentId, rawMetadata.comment_id, body.commentId, body.comment_id),
533
+ campaignVariant: pickFirstText(rawMetadata.campaignVariant, rawMetadata.variant, body.campaignVariant, body.variant),
534
+ offerCode: pickFirstText(rawMetadata.offerCode, rawMetadata.offer, rawMetadata.coupon, body.offerCode, body.offer, body.coupon),
535
+ referrer,
536
+ referrerHost: pickFirstText(rawMetadata.referrerHost, body.referrerHost, parseReferrerHost(referrer)),
537
+ landingPath: pickFirstText(rawMetadata.landingPath, body.landingPath, body.page),
538
+ ctaId: pickFirstText(rawMetadata.ctaId, body.ctaId),
539
+ ctaPlacement: pickFirstText(rawMetadata.ctaPlacement, body.ctaPlacement),
540
+ planId,
541
+ billingCycle,
542
+ seatCount,
543
+ };
544
+ }
545
+
546
+ function buildCheckoutPageTelemetryMetadata(parsed, req, journeyState, page) {
547
+ const params = parsed.searchParams;
548
+ const referrer = pickFirstText(
549
+ params.get('referrer'),
550
+ req.headers.referer,
551
+ req.headers.referrer
552
+ );
553
+ const referrerHost = pickFirstText(params.get('referrer_host'), parseReferrerHost(referrer));
554
+ const source = pickFirstText(params.get('source'), params.get('utm_source'), inferSource(referrerHost));
555
+ const planId = normalizePlanId(pickFirstText(params.get('plan_id'), 'pro'));
556
+ const billingCycle = normalizeBillingCycle(pickFirstText(params.get('billing_cycle'), 'monthly'));
557
+ const seatCount = planId === 'team'
558
+ ? normalizeSeatCount(pickFirstText(params.get('seat_count')))
559
+ : 1;
560
+
561
+ return {
562
+ clientType: 'web',
563
+ installId: pickFirstText(params.get('install_id')),
564
+ acquisitionId: journeyState.acquisitionId,
565
+ visitorId: journeyState.visitorId,
566
+ sessionId: journeyState.sessionId,
567
+ traceId: pickFirstText(params.get('trace_id')),
568
+ source,
569
+ utmSource: pickFirstText(params.get('utm_source'), source),
570
+ utmMedium: pickFirstText(params.get('utm_medium'), page === '/cancel' ? 'checkout_cancel' : 'checkout_success'),
571
+ utmCampaign: pickFirstText(params.get('utm_campaign')),
572
+ utmContent: pickFirstText(params.get('utm_content')),
573
+ utmTerm: pickFirstText(params.get('utm_term')),
574
+ creator: pickFirstText(params.get('creator'), params.get('creator_handle')),
575
+ community: pickFirstText(params.get('community'), params.get('subreddit')),
576
+ postId: pickFirstText(params.get('post_id')),
577
+ commentId: pickFirstText(params.get('comment_id')),
578
+ campaignVariant: pickFirstText(params.get('campaign_variant')),
579
+ offerCode: pickFirstText(params.get('offer_code')),
580
+ ctaId: pickFirstText(params.get('cta_id')),
581
+ ctaPlacement: pickFirstText(params.get('cta_placement')),
582
+ planId,
583
+ billingCycle,
584
+ seatCount,
585
+ landingPath: pickFirstText(params.get('landing_path'), '/'),
586
+ page,
587
+ referrer,
588
+ referrerHost,
589
+ };
590
+ }
591
+
592
+ function resolveBillingSummaryOptions(parsed) {
593
+ return resolveAnalyticsWindow({
594
+ window: parsed.searchParams.get('window'),
595
+ timeZone: parsed.searchParams.get('timezone'),
596
+ now: parsed.searchParams.get('now'),
597
+ });
598
+ }
599
+
600
+ function createJourneyId(prefix) {
601
+ return createTraceId(prefix).replace(/^trace_/, `${prefix}_`);
602
+ }
603
+
604
+ function appendQueryParam(url, key, value) {
605
+ const normalized = normalizeNullableText(value);
606
+ if (normalized) {
607
+ url.searchParams.set(key, normalized);
608
+ }
609
+ }
610
+
611
+ function appendVisitorSessionQueryParam(url, value) {
612
+ const normalized = normalizeNullableText(value);
613
+ if (!normalized) {
614
+ return;
615
+ }
616
+
617
+ if (url.searchParams.has('session_id')) {
618
+ url.searchParams.set('visitor_session_id', normalized);
619
+ return;
620
+ }
621
+
622
+ url.searchParams.set('session_id', normalized);
623
+ }
624
+
625
+ function restoreStripeCheckoutPlaceholder(urlString) {
626
+ return String(urlString).replace(/%7BCHECKOUT_SESSION_ID%7D/g, '{CHECKOUT_SESSION_ID}');
627
+ }
628
+
629
+ function buildCheckoutFallbackUrl(baseUrl, metadata = {}) {
630
+ const url = new URL(baseUrl);
631
+ appendQueryParam(url, 'utm_source', metadata.utmSource || metadata.source);
632
+ appendQueryParam(url, 'utm_medium', metadata.utmMedium);
633
+ appendQueryParam(url, 'utm_campaign', metadata.utmCampaign);
634
+ appendQueryParam(url, 'utm_content', metadata.utmContent);
635
+ appendQueryParam(url, 'utm_term', metadata.utmTerm);
636
+ appendQueryParam(url, 'creator', metadata.creator);
637
+ appendQueryParam(url, 'community', metadata.community);
638
+ appendQueryParam(url, 'post_id', metadata.postId);
639
+ appendQueryParam(url, 'comment_id', metadata.commentId);
640
+ appendQueryParam(url, 'campaign_variant', metadata.campaignVariant);
641
+ appendQueryParam(url, 'offer_code', metadata.offerCode);
642
+ appendQueryParam(url, 'trace_id', metadata.traceId);
643
+ appendQueryParam(url, 'acquisition_id', metadata.acquisitionId);
644
+ appendQueryParam(url, 'visitor_id', metadata.visitorId);
645
+ appendVisitorSessionQueryParam(url, metadata.sessionId);
646
+ appendQueryParam(url, 'cta_id', metadata.ctaId);
647
+ appendQueryParam(url, 'cta_placement', metadata.ctaPlacement);
648
+ appendQueryParam(url, 'plan_id', metadata.planId);
649
+ appendQueryParam(url, 'billing_cycle', metadata.billingCycle);
650
+ appendQueryParam(url, 'seat_count', metadata.seatCount);
651
+ appendQueryParam(url, 'landing_path', metadata.landingPath);
652
+ appendQueryParam(url, 'referrer_host', metadata.referrerHost);
653
+ return restoreStripeCheckoutPlaceholder(url.toString());
654
+ }
655
+
656
+ function buildCheckoutBootstrapBody(parsed, req, journeyState = resolveJourneyState(req, parsed)) {
657
+ const params = parsed.searchParams;
658
+ const traceId = pickFirstText(params.get('trace_id')) || createJourneyId('checkout');
659
+ const planId = normalizePlanId(pickFirstText(params.get('plan_id'), 'pro'));
660
+ const billingCycle = normalizeBillingCycle(pickFirstText(params.get('billing_cycle'), 'monthly'));
661
+ const seatCount = planId === 'team'
662
+ ? normalizeSeatCount(pickFirstText(params.get('seat_count')))
663
+ : 1;
664
+ return {
665
+ traceId,
666
+ installId: pickFirstText(params.get('install_id')),
667
+ acquisitionId: journeyState.acquisitionId,
668
+ visitorId: journeyState.visitorId,
669
+ sessionId: journeyState.sessionId,
670
+ customerEmail: pickFirstText(params.get('customer_email')),
671
+ source: pickFirstText(params.get('source'), params.get('utm_source'), 'website'),
672
+ utmSource: pickFirstText(params.get('utm_source'), params.get('source'), 'website'),
673
+ utmMedium: pickFirstText(params.get('utm_medium'), 'cta_button'),
674
+ utmCampaign: pickFirstText(params.get('utm_campaign'), 'pro_pack'),
675
+ utmContent: pickFirstText(params.get('utm_content')),
676
+ utmTerm: pickFirstText(params.get('utm_term')),
677
+ creator: pickFirstText(params.get('creator'), params.get('creator_handle')),
678
+ community: pickFirstText(params.get('community'), params.get('subreddit')),
679
+ postId: pickFirstText(params.get('post_id')),
680
+ commentId: pickFirstText(params.get('comment_id')),
681
+ campaignVariant: pickFirstText(params.get('campaign_variant')),
682
+ offerCode: pickFirstText(params.get('offer_code')),
683
+ landingPath: pickFirstText(params.get('landing_path'), req.headers.referer ? '/' : '/'),
684
+ referrerHost: pickFirstText(params.get('referrer_host')),
685
+ ctaId: pickFirstText(params.get('cta_id'), 'pricing_pro'),
686
+ ctaPlacement: pickFirstText(params.get('cta_placement'), 'pricing'),
687
+ planId,
688
+ billingCycle,
689
+ seatCount,
690
+ metadata: {
691
+ referrer: pickFirstText(params.get('referrer'), req.headers.referer, req.headers.referrer),
692
+ landingPath: pickFirstText(params.get('landing_path'), '/'),
693
+ referrerHost: pickFirstText(params.get('referrer_host')),
694
+ },
695
+ };
696
+ }
697
+
698
+ function resolveCheckoutOfferSummary(metadata = {}) {
699
+ const planId = normalizePlanId(metadata.planId);
700
+ const billingCycle = normalizeBillingCycle(metadata.billingCycle);
701
+
702
+ if (planId === 'team') {
703
+ const seatCount = normalizeSeatCount(metadata.seatCount);
704
+ return {
705
+ planId: 'team',
706
+ billingCycle: 'monthly',
707
+ seatCount,
708
+ type: 'subscription',
709
+ price: TEAM_MONTHLY_PRICE_DOLLARS * seatCount,
710
+ priceLabel: `$${TEAM_MONTHLY_PRICE_DOLLARS}/seat/mo`,
711
+ };
712
+ }
713
+
714
+ if (billingCycle === 'annual') {
715
+ return {
716
+ planId: 'pro',
717
+ billingCycle: 'annual',
718
+ seatCount: 1,
719
+ type: 'subscription',
720
+ price: PRO_ANNUAL_PRICE_DOLLARS,
721
+ priceLabel: '$149/yr',
722
+ };
723
+ }
724
+
725
+ return {
726
+ planId: 'pro',
727
+ billingCycle: 'monthly',
728
+ seatCount: 1,
729
+ type: 'subscription',
730
+ price: PRO_MONTHLY_PRICE_DOLLARS,
731
+ priceLabel: '$19/mo',
732
+ };
733
+ }
734
+
735
+ function sendJson(res, statusCode, payload, extraHeaders = {}, options = {}) {
736
+ const { headOnly = false } = options;
737
+ const body = JSON.stringify(payload);
738
+ res.writeHead(statusCode, {
739
+ 'Content-Type': 'application/json; charset=utf-8',
740
+ 'Content-Length': Buffer.byteLength(body),
741
+ ...extraHeaders,
742
+ });
743
+ res.end(headOnly ? '' : body);
744
+ }
745
+
746
+ function sendText(res, statusCode, text, extraHeaders = {}, options = {}) {
747
+ const { headOnly = false } = options;
748
+ res.writeHead(statusCode, {
749
+ 'Content-Type': 'text/plain; charset=utf-8',
750
+ 'Content-Length': Buffer.byteLength(text),
751
+ ...extraHeaders,
752
+ });
753
+ res.end(headOnly ? '' : text);
754
+ }
755
+
756
+ function sendHtml(res, statusCode, html, extraHeaders = {}, options = {}) {
757
+ const { headOnly = false } = options;
758
+ res.writeHead(statusCode, {
759
+ 'Content-Type': 'text/html; charset=utf-8',
760
+ 'Content-Length': Buffer.byteLength(html),
761
+ ...extraHeaders,
762
+ });
763
+ res.end(headOnly ? '' : html);
764
+ }
765
+
766
+ function getPublicBillingHeaders(traceId = '') {
767
+ const headers = {
768
+ 'Access-Control-Allow-Origin': '*',
769
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
770
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-ThumbGate-Trace-Id',
771
+ 'Access-Control-Expose-Headers': 'X-ThumbGate-Trace-Id',
772
+ };
773
+ if (traceId) {
774
+ headers['X-ThumbGate-Trace-Id'] = traceId;
775
+ }
776
+ return headers;
777
+ }
778
+
779
+ function sendPublicBillingPreflight(res) {
780
+ res.writeHead(204, {
781
+ ...getPublicBillingHeaders(),
782
+ 'Access-Control-Max-Age': '86400',
783
+ 'Content-Length': '0',
784
+ });
785
+ res.end();
786
+ }
787
+
788
+ function appendBestEffortTelemetry(feedbackDir, payload, headers, context) {
789
+ try {
790
+ appendTelemetryPing(feedbackDir, payload, headers);
791
+ return true;
792
+ } catch (err) {
793
+ try {
794
+ appendDiagnosticRecord({
795
+ source: 'telemetry_emit',
796
+ step: 'telemetry_emit',
797
+ context: `best-effort telemetry write failed during ${context}`,
798
+ metadata: {
799
+ context,
800
+ eventType: payload && (payload.eventType || payload.event) ? payload.eventType || payload.event : 'unknown',
801
+ error: err && err.message ? err.message : 'unknown_error',
802
+ },
803
+ diagnosis: {
804
+ diagnosed: true,
805
+ rootCauseCategory: 'system_failure',
806
+ criticalFailureStep: 'telemetry_emit',
807
+ violations: [{
808
+ constraintId: 'telemetry:emit',
809
+ message: 'Server-side telemetry write failed.',
810
+ }],
811
+ evidence: [err && err.message ? err.message : 'unknown_error'],
812
+ },
813
+ });
814
+ } catch (_) {
815
+ // Public telemetry remains best-effort even when diagnostics fail.
816
+ }
817
+ return false;
818
+ }
819
+ }
820
+
821
+ function getPublicOrigin(req) {
822
+ const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() || 'http';
823
+ const host = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim() || 'localhost';
824
+ return `${proto}://${host}`;
825
+ }
826
+
827
+ function isLoopbackHost(hostValue) {
828
+ const rawHost = String(hostValue || '').split(',')[0].trim();
829
+ if (!rawHost) {
830
+ return false;
831
+ }
832
+
833
+ const hostWithoutPort = rawHost.startsWith('[')
834
+ ? rawHost.slice(1).split(']')[0]
835
+ : rawHost.split(':')[0];
836
+ const normalized = hostWithoutPort.toLowerCase();
837
+ return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1';
838
+ }
839
+
840
+ function wantsJson(req, parsed) {
841
+ if (parsed.searchParams.get('format') === 'json') {
842
+ return true;
843
+ }
844
+
845
+ const accept = String(req.headers.accept || '');
846
+ return accept.includes('application/json') && !accept.includes('text/html');
847
+ }
848
+
849
+ function fillTemplate(template, replacements) {
850
+ let output = template;
851
+ for (const [token, value] of Object.entries(replacements)) {
852
+ output = output.split(token).join(String(value));
853
+ }
854
+ return output;
855
+ }
856
+
857
+ function escapeHtmlAttribute(value) {
858
+ return String(value)
859
+ .replaceAll('&', '&')
860
+ .replaceAll('"', '"')
861
+ .replaceAll('<', '&lt;')
862
+ .replaceAll('>', '&gt;');
863
+ }
864
+
865
+ function loadLandingPageHtml(runtimeConfig, pageContext = {}) {
866
+ const template = fs.readFileSync(LANDING_PAGE_PATH, 'utf-8');
867
+ const googleSiteVerificationMeta = runtimeConfig.googleSiteVerification
868
+ ? ` <meta name="google-site-verification" content="${escapeHtmlAttribute(runtimeConfig.googleSiteVerification)}" />`
869
+ : '';
870
+ const gaBootstrap = runtimeConfig.gaMeasurementId
871
+ ? [
872
+ ` <script async src="https://www.googletagmanager.com/gtag/js?id=${runtimeConfig.gaMeasurementId}"></script>`,
873
+ ' <script>',
874
+ ' window.dataLayer = window.dataLayer || [];',
875
+ ' function gtag(){dataLayer.push(arguments);}',
876
+ " gtag('js', new Date());",
877
+ ` gtag('config', '${runtimeConfig.gaMeasurementId}', { send_page_view: false });`,
878
+ ' </script>',
879
+ ].join('\n')
880
+ : '';
881
+ return fillTemplate(template, {
882
+ '__PACKAGE_VERSION__': pkg.version,
883
+ '__APP_ORIGIN__': runtimeConfig.appOrigin,
884
+ '__CHECKOUT_ENDPOINT__': runtimeConfig.checkoutEndpoint,
885
+ '__CHECKOUT_FALLBACK_URL__': runtimeConfig.checkoutFallbackUrl,
886
+ '__PRO_PRICE_DOLLARS__': runtimeConfig.proPriceDollars,
887
+ '__PRO_PRICE_LABEL__': runtimeConfig.proPriceLabel,
888
+ '__GA_MEASUREMENT_ID__': runtimeConfig.gaMeasurementId || '',
889
+ '__GA_BOOTSTRAP__': gaBootstrap,
890
+ '__GOOGLE_SITE_VERIFICATION_META__': googleSiteVerificationMeta,
891
+ '__SERVER_VISITOR_ID__': pageContext.serverVisitorId || '',
892
+ '__SERVER_SESSION_ID__': pageContext.serverSessionId || '',
893
+ '__SERVER_ACQUISITION_ID__': pageContext.serverAcquisitionId || '',
894
+ '__SERVER_TELEMETRY_CAPTURED__': pageContext.serverTelemetryCaptured ? 'true' : 'false',
895
+ '__VERIFICATION_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md',
896
+ '__COMPATIBILITY_REPORT_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/proof/compatibility/report.json',
897
+ '__AUTOMATION_REPORT_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/proof/automation/report.json',
898
+ '__GTM_PLAN_URL__': 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/GO_TO_MARKET_REVENUE_WEDGE_2026-03.md',
899
+ '__GITHUB_URL__': 'https://github.com/IgorGanapolsky/ThumbGate',
900
+ });
901
+ }
902
+
903
+ function loadDashboardPageHtml(req, expectedApiKey) {
904
+ const template = fs.readFileSync(DASHBOARD_PAGE_PATH, 'utf-8');
905
+ const forwardedHost = req.headers['x-forwarded-host'];
906
+ const hostHeader = Array.isArray(forwardedHost)
907
+ ? forwardedHost[0]
908
+ : forwardedHost || req.headers.host || '';
909
+ const localProBootstrap = process.env.THUMBGATE_PRO_MODE === '1' && Boolean(expectedApiKey) && isLoopbackHost(hostHeader);
910
+ // Developer override: auth is disabled (expectedApiKey===null), auto-connect with dummy key
911
+ const devOverride = expectedApiKey === null && isLoopbackHost(hostHeader);
912
+ const bootstrapActive = localProBootstrap || devOverride;
913
+ const serializedBootstrapKey = JSON.stringify(localProBootstrap ? expectedApiKey : devOverride ? 'dev-override' : '').replace(/</g, '\\u003c');
914
+
915
+ return fillTemplate(template, {
916
+ '__DASHBOARD_BOOTSTRAP_KEY__': serializedBootstrapKey,
917
+ '__DASHBOARD_BOOTSTRAP_ENABLED__': bootstrapActive ? 'true' : 'false',
918
+ });
919
+ }
920
+
921
+ function loadLessonsPageHtml(req, expectedApiKey) {
922
+ const template = fs.readFileSync(LESSONS_PAGE_PATH, 'utf-8');
923
+ const forwardedHost = req.headers['x-forwarded-host'];
924
+ const hostHeader = Array.isArray(forwardedHost)
925
+ ? forwardedHost[0]
926
+ : forwardedHost || req.headers.host || '';
927
+ const localProBootstrap = process.env.THUMBGATE_PRO_MODE === '1' && Boolean(expectedApiKey) && isLoopbackHost(hostHeader);
928
+ const devOverride = expectedApiKey === null && isLoopbackHost(hostHeader);
929
+ const bootstrapActive = localProBootstrap || devOverride;
930
+ const serializedBootstrapKey = JSON.stringify(localProBootstrap ? expectedApiKey : devOverride ? 'dev-override' : '').replace(/</g, '\\u003c');
931
+
932
+ return fillTemplate(template, {
933
+ '__LESSONS_BOOTSTRAP_KEY__': serializedBootstrapKey,
934
+ '__LESSONS_BOOTSTRAP_ENABLED__': bootstrapActive ? 'true' : 'false',
935
+ });
936
+ }
937
+
938
+ function esc(s) { return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
939
+
940
+ function renderLessonDetailHtml(record, lessonId) {
941
+ if (!record) {
942
+ return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Lesson Not Found</title>
943
+ <style>*{box-sizing:border-box}body{background:#0a0a0a;color:#fff;font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
944
+ .card{text-align:center;background:#141414;border:1px solid #222;border-radius:16px;padding:48px 40px;max-width:480px}
945
+ a{color:#22d3ee;text-decoration:none}</style></head><body>
946
+ <div class="card"><div style="font-size:48px;margin-bottom:12px">🔍</div>
947
+ <h2 style="margin-bottom:8px">Lesson not found</h2>
948
+ <p style="color:#888;font-size:14px;margin-bottom:20px">No record with ID <code style="background:#1a1a1a;padding:2px 6px;border-radius:4px">${esc(lessonId)}</code></p>
949
+ <a href="/lessons">← Back to Lessons</a></div></body></html>`;
950
+ }
951
+
952
+ const fb = record.feedbackEvent || {};
953
+ const mem = record.memoryRecord || {};
954
+ const merged = { ...fb, ...mem };
955
+ const signal = merged.signal || 'down';
956
+ const emoji = signal === 'up' ? '👍' : '👎';
957
+ const signalColor = signal === 'up' ? '#4ade80' : '#f87171';
958
+ const title = merged.title || merged.context || 'Untitled Lesson';
959
+ const context = merged.context || '';
960
+ const whatWentWrong = merged.whatWentWrong || '';
961
+ const whatWorked = merged.whatWorked || '';
962
+ const tags = Array.isArray(merged.tags) ? merged.tags.join(', ') : (merged.tags || '');
963
+ const timestamp = merged.timestamp ? new Date(merged.timestamp).toLocaleString() : '';
964
+
965
+ // Structured rule
966
+ const rule = merged.structuredRule || merged.rule || null;
967
+ // Conversation window
968
+ const convoWindow = merged.conversationWindow || merged.chatHistory || [];
969
+ // Reflector analysis
970
+ const reflector = merged.reflectorAnalysis || merged.reflector || null;
971
+ // Diagnosis
972
+ const diagnosis = merged.diagnosis || null;
973
+ // Rubric
974
+ const rubric = merged.rubricEvaluation || merged.rubric || null;
975
+ // Synthesis
976
+ const synthesis = merged.synthesis || null;
977
+ // Bayesian
978
+ const bayesian = merged.bayesianBelief || merged.bayesian || null;
979
+
980
+ function sectionCard(titleText, content, id) {
981
+ if (!content) return '';
982
+ return `<div class="detail-card" id="${id || ''}"><h3>${titleText}</h3>${content}</div>`;
983
+ }
984
+
985
+ let structuredRuleHtml = '';
986
+ if (rule) {
987
+ structuredRuleHtml = sectionCard('Structured Rule (IF/THEN)', `
988
+ <table class="detail-table">
989
+ ${rule.trigger ? `<tr><td class="label">Trigger (IF)</td><td>${esc(rule.trigger)}</td></tr>` : ''}
990
+ ${rule.action ? `<tr><td class="label">Action (THEN)</td><td>${esc(rule.action)}</td></tr>` : ''}
991
+ ${rule.confidence !== undefined ? `<tr><td class="label">Confidence</td><td>${esc(String(rule.confidence))}</td></tr>` : ''}
992
+ ${rule.scope ? `<tr><td class="label">Scope</td><td>${esc(rule.scope)}</td></tr>` : ''}
993
+ </table>`, 'structuredRule');
994
+ }
995
+
996
+ let convoHtml = '';
997
+ if (Array.isArray(convoWindow) && convoWindow.length > 0) {
998
+ const msgs = convoWindow.map((m) => {
999
+ const role = esc(m.role || 'unknown');
1000
+ const content = esc(typeof m.content === 'string' ? m.content : JSON.stringify(m.content));
1001
+ return `<div class="convo-msg"><span class="convo-role">${role}</span><span class="convo-content">${content}</span></div>`;
1002
+ }).join('');
1003
+ convoHtml = sectionCard('Conversation Window', `<div class="convo-list">${msgs}</div>`, 'convoWindow');
1004
+ }
1005
+
1006
+ let reflectorHtml = '';
1007
+ if (reflector) {
1008
+ const parts = [];
1009
+ if (reflector.proposedRule) parts.push(`<tr><td class="label">Proposed Rule</td><td>${esc(reflector.proposedRule)}</td></tr>`);
1010
+ if (reflector.recurrence) parts.push(`<tr><td class="label">Recurrence</td><td>${esc(JSON.stringify(reflector.recurrence))}</td></tr>`);
1011
+ if (reflector.correctionsDetected) parts.push(`<tr><td class="label">Corrections</td><td>${esc(JSON.stringify(reflector.correctionsDetected))}</td></tr>`);
1012
+ if (parts.length) reflectorHtml = sectionCard('Reflector Analysis', `<table class="detail-table">${parts.join('')}</table>`, 'reflector');
1013
+ }
1014
+
1015
+ let diagnosisHtml = '';
1016
+ if (diagnosis) {
1017
+ const parts = [];
1018
+ if (diagnosis.rootCause) parts.push(`<tr><td class="label">Root Cause</td><td>${esc(diagnosis.rootCause)}</td></tr>`);
1019
+ if (diagnosis.category) parts.push(`<tr><td class="label">Category</td><td>${esc(diagnosis.category)}</td></tr>`);
1020
+ if (diagnosis.violations) parts.push(`<tr><td class="label">Violations</td><td>${esc(JSON.stringify(diagnosis.violations))}</td></tr>`);
1021
+ if (parts.length) diagnosisHtml = sectionCard('Diagnosis', `<table class="detail-table">${parts.join('')}</table>`, 'diagnosis');
1022
+ }
1023
+
1024
+ let rubricHtml = '';
1025
+ if (rubric) {
1026
+ const parts = [];
1027
+ if (rubric.scores) parts.push(`<tr><td class="label">Scores</td><td><pre>${esc(JSON.stringify(rubric.scores, null, 2))}</pre></td></tr>`);
1028
+ if (rubric.failingCriteria) parts.push(`<tr><td class="label">Failing Criteria</td><td>${esc(JSON.stringify(rubric.failingCriteria))}</td></tr>`);
1029
+ if (rubric.guardrails) parts.push(`<tr><td class="label">Guardrails</td><td>${esc(JSON.stringify(rubric.guardrails))}</td></tr>`);
1030
+ if (parts.length) rubricHtml = sectionCard('Rubric Evaluation', `<table class="detail-table">${parts.join('')}</table>`, 'rubric');
1031
+ }
1032
+
1033
+ let synthesisHtml = '';
1034
+ if (synthesis) {
1035
+ const parts = [];
1036
+ if (synthesis.mergedCount !== undefined) parts.push(`<tr><td class="label">Merged Count</td><td>${esc(String(synthesis.mergedCount))}</td></tr>`);
1037
+ if (synthesis.linkedFeedbackIds) parts.push(`<tr><td class="label">Linked Feedback</td><td>${esc(JSON.stringify(synthesis.linkedFeedbackIds))}</td></tr>`);
1038
+ if (parts.length) synthesisHtml = sectionCard('Synthesis', `<table class="detail-table">${parts.join('')}</table>`, 'synthesis');
1039
+ }
1040
+
1041
+ let bayesianHtml = '';
1042
+ if (bayesian) {
1043
+ const parts = [];
1044
+ if (bayesian.prior !== undefined) parts.push(`<tr><td class="label">Prior</td><td>${esc(String(bayesian.prior))}</td></tr>`);
1045
+ if (bayesian.posterior !== undefined) parts.push(`<tr><td class="label">Posterior</td><td>${esc(String(bayesian.posterior))}</td></tr>`);
1046
+ if (bayesian.uncertainty !== undefined) parts.push(`<tr><td class="label">Uncertainty</td><td>${esc(String(bayesian.uncertainty))}</td></tr>`);
1047
+ if (parts.length) bayesianHtml = sectionCard('Bayesian Belief', `<table class="detail-table">${parts.join('')}</table>`, 'bayesian');
1048
+ }
1049
+
1050
+ return `<!DOCTYPE html>
1051
+ <html lang="en">
1052
+ <head>
1053
+ <meta charset="UTF-8">
1054
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1055
+ <title>Lesson — ${esc(title)}</title>
1056
+ <style>
1057
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
1058
+ :root {
1059
+ --bg: #0a0a0b; --bg-raised: #111113; --bg-card: #141414; --border: #222225;
1060
+ --text: #e8e8ec; --text-muted: #8b8b96; --cyan: #22d3ee;
1061
+ --cyan-dim: rgba(34,211,238,0.12); --green: #4ade80; --red: #f87171;
1062
+ --yellow: #fbbf24; --purple: #a78bfa;
1063
+ --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, sans-serif;
1064
+ --mono: 'SF Mono', 'Cascadia Code', 'JetBrains Mono', 'Fira Code', Consolas, monospace;
1065
+ }
1066
+ body { font-family: var(--font); background: var(--bg); color: var(--text); line-height: 1.6; -webkit-font-smoothing: antialiased; }
1067
+ .container { max-width: 860px; margin: 0 auto; padding: 0 24px; }
1068
+ nav { position: sticky; top: 0; z-index: 50; background: rgba(10,10,11,0.85); backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); padding: 14px 0; }
1069
+ nav .container { display: flex; justify-content: space-between; align-items: center; }
1070
+ .nav-logo { font-weight: 700; font-size: 15px; color: var(--text); text-decoration: none; }
1071
+ .nav-links { display: flex; gap: 16px; align-items: center; }
1072
+ .nav-links a { color: var(--text-muted); text-decoration: none; font-size: 13px; }
1073
+ .nav-links a:hover { color: var(--text); }
1074
+ .header-card { margin: 32px 0 24px; padding: 28px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 14px; }
1075
+ .header-top { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; }
1076
+ .signal-badge { font-size: 40px; }
1077
+ .header-title { font-size: 20px; font-weight: 700; letter-spacing: -0.02em; }
1078
+ .header-meta { display: flex; gap: 20px; flex-wrap: wrap; font-size: 13px; color: var(--text-muted); }
1079
+ .header-meta code { background: var(--bg-raised); padding: 2px 8px; border-radius: 4px; font-family: var(--mono); font-size: 12px; cursor: pointer; }
1080
+ .header-meta code:hover { color: var(--cyan); }
1081
+ .detail-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; padding: 24px; margin-bottom: 16px; }
1082
+ .detail-card h3 { font-size: 14px; font-weight: 600; color: var(--cyan); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 16px; }
1083
+ .form-group { margin-bottom: 16px; }
1084
+ .form-group label { display: block; font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
1085
+ .form-group input, .form-group textarea { width: 100%; background: var(--bg-raised); border: 1px solid var(--border); border-radius: 8px; color: var(--text); padding: 10px 14px; font-size: 14px; font-family: var(--font); }
1086
+ .form-group input:focus, .form-group textarea:focus { outline: none; border-color: var(--cyan); }
1087
+ .form-group textarea { resize: vertical; min-height: 80px; }
1088
+ .detail-table { width: 100%; border-collapse: collapse; }
1089
+ .detail-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: top; }
1090
+ .detail-table td.label { color: var(--text-muted); width: 160px; font-weight: 600; white-space: nowrap; }
1091
+ .detail-table pre { margin: 0; font-size: 12px; font-family: var(--mono); white-space: pre-wrap; color: var(--text-muted); }
1092
+ .convo-list { max-height: 400px; overflow-y: auto; }
1093
+ .convo-msg { padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 13px; }
1094
+ .convo-role { display: inline-block; font-weight: 700; color: var(--cyan); width: 80px; font-size: 11px; text-transform: uppercase; }
1095
+ .convo-content { color: var(--text-muted); }
1096
+ .actions-bar { display: flex; gap: 12px; margin: 24px 0 48px; flex-wrap: wrap; }
1097
+ .btn { padding: 10px 24px; border: none; border-radius: 8px; font-weight: 600; font-size: 14px; cursor: pointer; transition: opacity 0.15s; }
1098
+ .btn:hover { opacity: 0.85; }
1099
+ .btn-primary { background: var(--cyan); color: #000; }
1100
+ .btn-secondary { background: var(--bg-card); color: var(--text); border: 1px solid var(--border); }
1101
+ .btn-danger { background: rgba(248,113,113,0.15); color: var(--red); border: 1px solid rgba(248,113,113,0.3); }
1102
+ .toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); padding: 12px 28px; border-radius: 8px; font-size: 14px; font-weight: 600; display: none; z-index: 100; animation: slideUp .3s ease-out; }
1103
+ @keyframes slideUp{from{opacity:0;transform:translateX(-50%) translateY(12px)}to{opacity:1;transform:translateX(-50%) translateY(0)}}
1104
+ .toast-success { background: var(--green); color: #000; }
1105
+ .toast-error { background: var(--red); color: #000; }
1106
+ @media (max-width: 600px) {
1107
+ .header-meta { flex-direction: column; gap: 8px; }
1108
+ .actions-bar { flex-direction: column; }
1109
+ }
1110
+ </style>
1111
+ </head>
1112
+ <body>
1113
+ <nav><div class="container">
1114
+ <a href="/dashboard" class="nav-logo">👍👎 ThumbGate</a>
1115
+ <div class="nav-links">
1116
+ <a href="/dashboard">Dashboard</a>
1117
+ <a href="/lessons">Lessons</a>
1118
+ <a href="/">Landing Page</a>
1119
+ </div>
1120
+ </div></nav>
1121
+
1122
+ <div class="container">
1123
+ <div class="header-card">
1124
+ <div class="header-top">
1125
+ <div class="signal-badge">${emoji}</div>
1126
+ <div class="header-title">Lesson Detail</div>
1127
+ </div>
1128
+ <div class="header-meta">
1129
+ <span>ID: <code onclick="navigator.clipboard.writeText('${esc(lessonId)}').then(()=>showToast('Copied!','success'))" title="Click to copy">${esc(lessonId)}</code></span>
1130
+ ${timestamp ? `<span>🕐 ${esc(timestamp)}</span>` : ''}
1131
+ <span style="color:${signalColor};font-weight:600">${signal === 'up' ? 'Positive' : 'Negative'} feedback</span>
1132
+ </div>
1133
+ </div>
1134
+
1135
+ <div class="detail-card">
1136
+ <h3>Lesson Content</h3>
1137
+ <div class="form-group">
1138
+ <label>Title</label>
1139
+ <input type="text" id="editTitle" value="${esc(title)}">
1140
+ </div>
1141
+ <div class="form-group">
1142
+ <label>Content / Context</label>
1143
+ <textarea id="editContent" rows="4">${esc(context)}</textarea>
1144
+ </div>
1145
+ <div class="form-group">
1146
+ <label>${signal === 'down' ? 'What went wrong' : 'What worked'}</label>
1147
+ <textarea id="editDetail" rows="3">${esc(signal === 'down' ? whatWentWrong : whatWorked)}</textarea>
1148
+ </div>
1149
+ <div class="form-group">
1150
+ <label>Tags (comma-separated)</label>
1151
+ <input type="text" id="editTags" value="${esc(tags)}">
1152
+ </div>
1153
+ </div>
1154
+
1155
+ ${structuredRuleHtml}
1156
+ ${convoHtml}
1157
+ ${reflectorHtml}
1158
+ ${diagnosisHtml}
1159
+ ${rubricHtml}
1160
+ ${synthesisHtml}
1161
+ ${bayesianHtml}
1162
+
1163
+ <div class="actions-bar">
1164
+ <button class="btn btn-primary" onclick="saveChanges()">Save Changes</button>
1165
+ <a href="/lessons" class="btn btn-secondary">← Back to Lessons</a>
1166
+ <button class="btn btn-danger" onclick="deleteLesson()">Delete Lesson</button>
1167
+ </div>
1168
+ </div>
1169
+
1170
+ <div class="toast toast-success" id="toastSuccess">✓ Saved</div>
1171
+ <div class="toast toast-error" id="toastError">✗ Error</div>
1172
+
1173
+ <script>
1174
+ function showToast(msg, type) {
1175
+ var el = document.getElementById(type === 'success' ? 'toastSuccess' : 'toastError');
1176
+ el.textContent = msg;
1177
+ el.style.display = 'block';
1178
+ setTimeout(function() { el.style.display = 'none'; }, 3000);
1179
+ }
1180
+
1181
+ async function saveChanges() {
1182
+ var body = {
1183
+ title: document.getElementById('editTitle').value,
1184
+ content: document.getElementById('editContent').value,
1185
+ tags: document.getElementById('editTags').value,
1186
+ };
1187
+ var detailVal = document.getElementById('editDetail').value;
1188
+ if ('${signal}' === 'down') { body.whatWentWrong = detailVal; } else { body.whatWorked = detailVal; }
1189
+ try {
1190
+ var resp = await fetch('/lessons/${encodeURIComponent(lessonId)}/update', {
1191
+ method: 'POST',
1192
+ headers: { 'Content-Type': 'application/json' },
1193
+ body: JSON.stringify(body)
1194
+ });
1195
+ if (!resp.ok) throw new Error('Save failed');
1196
+ showToast('Changes saved', 'success');
1197
+ } catch (e) {
1198
+ showToast('Failed to save: ' + e.message, 'error');
1199
+ }
1200
+ }
1201
+
1202
+ async function deleteLesson() {
1203
+ if (!confirm('Delete this lesson permanently?')) return;
1204
+ try {
1205
+ var resp = await fetch('/lessons/${encodeURIComponent(lessonId)}/delete', {
1206
+ method: 'POST',
1207
+ headers: { 'Content-Type': 'application/json' }
1208
+ });
1209
+ if (!resp.ok) throw new Error('Delete failed');
1210
+ window.location.href = '/lessons';
1211
+ } catch (e) {
1212
+ showToast('Failed to delete: ' + e.message, 'error');
1213
+ }
1214
+ }
1215
+ </script>
1216
+ </body>
1217
+ </html>`;
1218
+ }
1219
+
1220
+ function renderRobotsTxt(runtimeConfig) {
1221
+ return [
1222
+ 'User-agent: *',
1223
+ 'Allow: /',
1224
+ `Sitemap: ${runtimeConfig.appOrigin}/sitemap.xml`,
1225
+ ].join('\n');
1226
+ }
1227
+
1228
+ function renderSitemapXml(runtimeConfig) {
1229
+ const entries = [
1230
+ { path: '/', changefreq: 'weekly', priority: '1.0' },
1231
+ ...THUMBGATE_SEO_SITEMAP_ENTRIES,
1232
+ ];
1233
+ return [
1234
+ '<?xml version="1.0" encoding="UTF-8"?>',
1235
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
1236
+ ...entries.map((entry) => {
1237
+ const loc = entry.path === '/'
1238
+ ? `${runtimeConfig.appOrigin}/`
1239
+ : `${runtimeConfig.appOrigin}${entry.path}`;
1240
+ return [
1241
+ ' <url>',
1242
+ ` <loc>${loc}</loc>`,
1243
+ ` <changefreq>${entry.changefreq}</changefreq>`,
1244
+ ` <priority>${entry.priority}</priority>`,
1245
+ ' </url>',
1246
+ ].join('\n');
1247
+ }),
1248
+ '</urlset>',
1249
+ ].join('\n');
1250
+ }
1251
+
1252
+ function isSeoAttributionSource(source) {
1253
+ return source === 'organic_search' || source === 'ai_search';
1254
+ }
1255
+
1256
+ function servePublicMarketingPage({
1257
+ req,
1258
+ res,
1259
+ parsed,
1260
+ hostedConfig,
1261
+ isHeadRequest,
1262
+ renderHtml,
1263
+ extraTelemetry = {},
1264
+ }) {
1265
+ if (isHeadRequest) {
1266
+ sendHtml(res, 200, renderHtml(hostedConfig), {}, {
1267
+ headOnly: true,
1268
+ });
1269
+ return;
1270
+ }
1271
+
1272
+ const { FEEDBACK_DIR } = getFeedbackPaths();
1273
+ const journeyState = resolveJourneyState(req, parsed);
1274
+ const landingAttribution = buildLandingAttribution(parsed, req);
1275
+ const telemetryPayload = {
1276
+ eventType: 'landing_page_view',
1277
+ clientType: 'web',
1278
+ visitorId: journeyState.visitorId,
1279
+ sessionId: journeyState.sessionId,
1280
+ acquisitionId: journeyState.acquisitionId,
1281
+ ...landingAttribution,
1282
+ ...extraTelemetry,
1283
+ };
1284
+ const landingTelemetryCaptured = appendBestEffortTelemetry(
1285
+ FEEDBACK_DIR,
1286
+ telemetryPayload,
1287
+ req.headers,
1288
+ 'landing_page_view'
1289
+ );
1290
+
1291
+ if (isSeoAttributionSource(landingAttribution.source)) {
1292
+ appendBestEffortTelemetry(FEEDBACK_DIR, {
1293
+ eventType: 'seo_landing_view',
1294
+ clientType: 'web',
1295
+ visitorId: journeyState.visitorId,
1296
+ sessionId: journeyState.sessionId,
1297
+ acquisitionId: journeyState.acquisitionId,
1298
+ source: landingAttribution.source,
1299
+ utmSource: landingAttribution.utmSource,
1300
+ utmMedium: landingAttribution.utmMedium,
1301
+ utmCampaign: landingAttribution.utmCampaign,
1302
+ utmContent: landingAttribution.utmContent,
1303
+ utmTerm: landingAttribution.utmTerm,
1304
+ creator: landingAttribution.creator,
1305
+ community: landingAttribution.community,
1306
+ postId: landingAttribution.postId,
1307
+ commentId: landingAttribution.commentId,
1308
+ campaignVariant: landingAttribution.campaignVariant,
1309
+ offerCode: landingAttribution.offerCode,
1310
+ landingPath: landingAttribution.landingPath,
1311
+ page: landingAttribution.page,
1312
+ referrer: landingAttribution.referrer,
1313
+ referrerHost: landingAttribution.referrerHost,
1314
+ seoSurface: landingAttribution.seoSurface || landingAttribution.source,
1315
+ seoQuery: landingAttribution.seoQuery,
1316
+ ...extraTelemetry,
1317
+ }, req.headers, 'seo_landing_view');
1318
+ }
1319
+
1320
+ const html = renderHtml(hostedConfig, {
1321
+ serverVisitorId: journeyState.visitorId,
1322
+ serverSessionId: journeyState.sessionId,
1323
+ serverAcquisitionId: journeyState.acquisitionId,
1324
+ serverTelemetryCaptured: landingTelemetryCaptured,
1325
+ });
1326
+
1327
+ sendHtml(
1328
+ res,
1329
+ 200,
1330
+ html,
1331
+ journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}
1332
+ );
1333
+ }
1334
+
1335
+ function renderCheckoutSuccessPage(runtimeConfig) {
1336
+ return `<!DOCTYPE html>
1337
+ <html lang="en">
1338
+ <head>
1339
+ <meta charset="UTF-8" />
1340
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
1341
+ <title>Context Gateway Activated</title>
1342
+ <meta name="robots" content="noindex,nofollow" />
1343
+ <style>
1344
+ :root {
1345
+ --bg: #f6f1e8;
1346
+ --ink: #1d1b18;
1347
+ --muted: #625a4d;
1348
+ --line: #d7cfbf;
1349
+ --accent: #b85c2d;
1350
+ --accent-dark: #8f451f;
1351
+ --card: #fffdf9;
1352
+ --success: #2f7d4b;
1353
+ --radius: 14px;
1354
+ }
1355
+ * { box-sizing: border-box; }
1356
+ body {
1357
+ margin: 0;
1358
+ font-family: Georgia, 'Times New Roman', serif;
1359
+ background: linear-gradient(180deg, #fcfaf5 0%, var(--bg) 100%);
1360
+ color: var(--ink);
1361
+ line-height: 1.6;
1362
+ }
1363
+ main {
1364
+ max-width: 860px;
1365
+ margin: 0 auto;
1366
+ padding: 48px 20px 80px;
1367
+ }
1368
+ .eyebrow {
1369
+ display: inline-block;
1370
+ padding: 6px 12px;
1371
+ border-radius: 999px;
1372
+ background: #efe3d5;
1373
+ color: var(--accent-dark);
1374
+ font-size: 12px;
1375
+ letter-spacing: 0.08em;
1376
+ text-transform: uppercase;
1377
+ font-weight: 700;
1378
+ }
1379
+ h1 {
1380
+ margin: 18px 0 12px;
1381
+ font-size: clamp(32px, 6vw, 56px);
1382
+ line-height: 1.05;
1383
+ letter-spacing: -0.04em;
1384
+ }
1385
+ p.lead {
1386
+ max-width: 700px;
1387
+ font-size: 19px;
1388
+ color: var(--muted);
1389
+ margin: 0 0 28px;
1390
+ }
1391
+ .card {
1392
+ background: var(--card);
1393
+ border: 1px solid var(--line);
1394
+ border-radius: var(--radius);
1395
+ padding: 24px;
1396
+ margin-top: 22px;
1397
+ box-shadow: 0 10px 30px rgba(29, 27, 24, 0.08);
1398
+ }
1399
+ .status {
1400
+ color: var(--success);
1401
+ font-weight: 700;
1402
+ margin-bottom: 8px;
1403
+ }
1404
+ pre {
1405
+ white-space: pre-wrap;
1406
+ word-break: break-word;
1407
+ background: #171411;
1408
+ color: #f5efe6;
1409
+ padding: 16px;
1410
+ border-radius: 12px;
1411
+ overflow-x: auto;
1412
+ font-family: 'SFMono-Regular', Consolas, monospace;
1413
+ font-size: 13px;
1414
+ }
1415
+ .actions {
1416
+ display: flex;
1417
+ gap: 12px;
1418
+ flex-wrap: wrap;
1419
+ margin-top: 18px;
1420
+ }
1421
+ a.button {
1422
+ display: inline-block;
1423
+ text-decoration: none;
1424
+ background: var(--accent);
1425
+ color: white;
1426
+ padding: 12px 18px;
1427
+ border-radius: 10px;
1428
+ font-weight: 700;
1429
+ }
1430
+ a.button.secondary {
1431
+ background: transparent;
1432
+ color: var(--ink);
1433
+ border: 1px solid var(--line);
1434
+ }
1435
+ .muted {
1436
+ color: var(--muted);
1437
+ font-size: 14px;
1438
+ }
1439
+ </style>
1440
+ </head>
1441
+ <body>
1442
+ <main>
1443
+ <span class="eyebrow">ThumbGate Pro</span>
1444
+ <h1>Your local Pro dashboard is ready.</h1>
1445
+ <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>
1446
+
1447
+ <div class="card">
1448
+ <div class="status" id="status">Verifying payment and provisioning your key...</div>
1449
+ <p class="muted" id="summary">Do not close this tab until the key appears.</p>
1450
+ <pre id="key-block">Waiting for checkout session...</pre>
1451
+ </div>
1452
+
1453
+ <div class="card">
1454
+ <h2>Launch your personal dashboard</h2>
1455
+ <p>Run this command once to save your license key and open ThumbGate locally on <code>localhost</code>:</p>
1456
+ <pre id="activate-block">Waiting for provisioning...</pre>
1457
+ <p class="muted">Your key is saved to <code>~/.thumbgate/license.json</code>. After that, rerun <code>npx thumbgate pro</code> any time to reopen your dashboard.</p>
1458
+ </div>
1459
+
1460
+ <div class="card">
1461
+ <h2>Hosted API setup (optional)</h2>
1462
+ <ol>
1463
+ <li>Copy the environment block below into your workflow runner.</li>
1464
+ <li>Use the curl example to confirm the hosted API captures an event.</li>
1465
+ <li>Keep your key private and rotate by repurchasing or contacting support if needed.</li>
1466
+ </ol>
1467
+ <pre id="env-block">Waiting for provisioning...</pre>
1468
+ <pre id="curl-block">Waiting for provisioning...</pre>
1469
+ <div class="actions">
1470
+ <a class="button" href="/">Back to landing page</a>
1471
+ <a class="button secondary" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md" target="_blank" rel="noreferrer">Verification evidence</a>
1472
+ </div>
1473
+ </div>
1474
+ </main>
1475
+
1476
+ <script>
1477
+ const params = new URLSearchParams(window.location.search);
1478
+ const sessionId = params.get('session_id');
1479
+ const traceId = params.get('trace_id');
1480
+ const sessionEndpoint = ${JSON.stringify(runtimeConfig.sessionEndpoint)};
1481
+ const telemetryEndpoint = '/v1/telemetry/ping';
1482
+ const statusEl = document.getElementById('status');
1483
+ const summaryEl = document.getElementById('summary');
1484
+ const keyBlock = document.getElementById('key-block');
1485
+ const envBlock = document.getElementById('env-block');
1486
+ const curlBlock = document.getElementById('curl-block');
1487
+ const activateBlock = document.getElementById('activate-block');
1488
+ const acquisitionId = params.get('acquisition_id');
1489
+ const visitorId = params.get('visitor_id');
1490
+ const visitorSessionId = params.get('visitor_session_id') || sessionId;
1491
+ const installId = params.get('install_id');
1492
+ const utmSource = params.get('utm_source');
1493
+ const utmMedium = params.get('utm_medium');
1494
+ const utmCampaign = params.get('utm_campaign');
1495
+ const utmContent = params.get('utm_content');
1496
+ const utmTerm = params.get('utm_term');
1497
+ const creator = params.get('creator') || params.get('creator_handle');
1498
+ const community = params.get('community');
1499
+ const postId = params.get('post_id');
1500
+ const commentId = params.get('comment_id');
1501
+ const campaignVariant = params.get('campaign_variant');
1502
+ const offerCode = params.get('offer_code');
1503
+ const ctaId = params.get('cta_id');
1504
+ const ctaPlacement = params.get('cta_placement');
1505
+ const planId = params.get('plan_id');
1506
+ const landingPath = params.get('landing_path') || '/';
1507
+ const referrerHost = params.get('referrer_host');
1508
+
1509
+ function sendTelemetry(eventType, extra = {}) {
1510
+ const payload = {
1511
+ eventType,
1512
+ clientType: 'web',
1513
+ page: '/success',
1514
+ traceId,
1515
+ acquisitionId,
1516
+ visitorId,
1517
+ sessionId: visitorSessionId,
1518
+ installId,
1519
+ source: utmSource || 'website',
1520
+ utmSource,
1521
+ utmMedium,
1522
+ utmCampaign,
1523
+ utmContent,
1524
+ utmTerm,
1525
+ creator,
1526
+ community,
1527
+ postId,
1528
+ commentId,
1529
+ campaignVariant,
1530
+ offerCode,
1531
+ ctaId,
1532
+ ctaPlacement,
1533
+ planId,
1534
+ landingPath,
1535
+ referrerHost,
1536
+ ...extra,
1537
+ };
1538
+ const body = JSON.stringify(payload);
1539
+ if (navigator.sendBeacon) {
1540
+ const blob = new Blob([body], { type: 'application/json' });
1541
+ navigator.sendBeacon(telemetryEndpoint, blob);
1542
+ return;
1543
+ }
1544
+ fetch(telemetryEndpoint, {
1545
+ method: 'POST',
1546
+ headers: { 'Content-Type': 'application/json' },
1547
+ body,
1548
+ keepalive: true,
1549
+ }).catch(() => {});
1550
+ }
1551
+
1552
+ function sendTelemetryOnce(eventType, extra = {}) {
1553
+ const marker = ['thumbgate', eventType, sessionId || traceId || 'unknown'].join(':');
1554
+ try {
1555
+ if (window.sessionStorage && window.sessionStorage.getItem(marker)) {
1556
+ return;
1557
+ }
1558
+ sendTelemetry(eventType, extra);
1559
+ if (window.sessionStorage) {
1560
+ window.sessionStorage.setItem(marker, '1');
1561
+ }
1562
+ } catch (_) {
1563
+ sendTelemetry(eventType, extra);
1564
+ }
1565
+ }
1566
+
1567
+ async function run() {
1568
+ if (!sessionId) {
1569
+ statusEl.textContent = 'Missing checkout session.';
1570
+ summaryEl.textContent = 'Open the landing page and start a new checkout.';
1571
+ keyBlock.textContent = 'No session_id was provided in the URL.';
1572
+ return;
1573
+ }
1574
+
1575
+ try {
1576
+ sendTelemetryOnce('checkout_session_lookup_started');
1577
+ const sessionLookupUrl = sessionEndpoint
1578
+ + '?sessionId=' + encodeURIComponent(sessionId)
1579
+ + (traceId ? '&traceId=' + encodeURIComponent(traceId) : '');
1580
+ const res = await fetch(sessionLookupUrl);
1581
+ const body = await res.json().catch(() => ({}));
1582
+ if (!res.ok) {
1583
+ sendTelemetryOnce('checkout_session_lookup_failed', {
1584
+ failureCode: body.error || 'checkout_session_lookup_failed',
1585
+ httpStatus: res.status,
1586
+ });
1587
+ throw new Error(body.error || 'Unable to load checkout session.');
1588
+ }
1589
+
1590
+ if (!body.paid) {
1591
+ sendTelemetryOnce('checkout_session_pending');
1592
+ statusEl.textContent = 'Payment is still processing.';
1593
+ summaryEl.textContent = 'Refresh this page in a few seconds if Stripe has already confirmed payment.';
1594
+ keyBlock.textContent = JSON.stringify(body, null, 2);
1595
+ return;
1596
+ }
1597
+
1598
+ sendTelemetryOnce('checkout_paid_confirmed');
1599
+ statusEl.textContent = 'ThumbGate Pro activated.';
1600
+ const resolvedTraceId = body.traceId || traceId || '';
1601
+ summaryEl.textContent = resolvedTraceId
1602
+ ? 'Your Pro key is ready. Save it once, launch your local dashboard, and keep the optional hosted snippet for team workflows. Trace: ' + resolvedTraceId + '.'
1603
+ : 'Your Pro key is ready. Save it once, launch your local dashboard, and keep the optional hosted snippet for team workflows.';
1604
+ keyBlock.textContent = body.apiKey || 'Provisioned, but no key was returned.';
1605
+ activateBlock.textContent = body.apiKey
1606
+ ? 'npx thumbgate pro --activate --key=' + body.apiKey
1607
+ : 'Key not available yet — refresh this page.';
1608
+ envBlock.textContent = body.nextSteps && body.nextSteps.env ? body.nextSteps.env : 'Environment snippet unavailable.';
1609
+ curlBlock.textContent = body.nextSteps && body.nextSteps.curl ? body.nextSteps.curl : 'curl snippet unavailable.';
1610
+ } catch (err) {
1611
+ sendTelemetryOnce('checkout_session_lookup_failed', {
1612
+ failureCode: err && err.message ? err.message : 'checkout_session_lookup_failed',
1613
+ });
1614
+ statusEl.textContent = 'Provisioning lookup failed.';
1615
+ summaryEl.textContent = traceId
1616
+ ? 'You can retry this page. If it keeps failing, inspect the hosted API logs with trace ' + traceId + '.'
1617
+ : 'You can retry this page. If it keeps failing, inspect the hosted API logs.';
1618
+ keyBlock.textContent = err && err.message ? err.message : 'Unknown error';
1619
+ }
1620
+ }
1621
+
1622
+ run();
1623
+ </script>
1624
+ </body>
1625
+ </html>`;
1626
+ }
1627
+
1628
+ function renderCheckoutCancelledPage(runtimeConfig) {
1629
+ return `<!DOCTYPE html>
1630
+ <html lang="en">
1631
+ <head>
1632
+ <meta charset="UTF-8" />
1633
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
1634
+ <title>Checkout Cancelled</title>
1635
+ <meta name="robots" content="noindex,nofollow" />
1636
+ <style>
1637
+ :root {
1638
+ --bg: #f6f1e8;
1639
+ --ink: #1d1b18;
1640
+ --muted: #625a4d;
1641
+ --line: #d7cfbf;
1642
+ --accent: #b85c2d;
1643
+ --surface: #fffdf9;
1644
+ }
1645
+ body {
1646
+ margin: 0;
1647
+ font-family: Georgia, 'Times New Roman', serif;
1648
+ background: var(--bg);
1649
+ color: var(--ink);
1650
+ }
1651
+ main {
1652
+ max-width: 720px;
1653
+ margin: 0 auto;
1654
+ padding: 64px 20px 80px;
1655
+ }
1656
+ h1 {
1657
+ font-size: clamp(32px, 6vw, 52px);
1658
+ line-height: 1.05;
1659
+ margin: 0 0 14px;
1660
+ }
1661
+ p {
1662
+ font-size: 18px;
1663
+ color: var(--muted);
1664
+ margin: 0 0 20px;
1665
+ }
1666
+ .card {
1667
+ background: var(--surface);
1668
+ border: 1px solid var(--line);
1669
+ border-radius: 16px;
1670
+ padding: 20px;
1671
+ margin-top: 18px;
1672
+ }
1673
+ .reason-grid {
1674
+ display: grid;
1675
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
1676
+ gap: 10px;
1677
+ margin-top: 14px;
1678
+ }
1679
+ button,
1680
+ a {
1681
+ display: inline-block;
1682
+ text-decoration: none;
1683
+ background: var(--accent);
1684
+ color: white;
1685
+ padding: 12px 18px;
1686
+ border-radius: 10px;
1687
+ font-weight: 700;
1688
+ border: 0;
1689
+ cursor: pointer;
1690
+ font-family: inherit;
1691
+ font-size: 15px;
1692
+ }
1693
+ button.secondary,
1694
+ a.secondary {
1695
+ background: transparent;
1696
+ color: var(--ink);
1697
+ border: 1px solid var(--line);
1698
+ }
1699
+ textarea {
1700
+ width: 100%;
1701
+ min-height: 88px;
1702
+ border-radius: 12px;
1703
+ border: 1px solid var(--line);
1704
+ padding: 12px;
1705
+ font: inherit;
1706
+ resize: vertical;
1707
+ }
1708
+ .actions {
1709
+ display: flex;
1710
+ flex-wrap: wrap;
1711
+ gap: 12px;
1712
+ margin-top: 18px;
1713
+ }
1714
+ .note {
1715
+ font-size: 14px;
1716
+ margin-top: 12px;
1717
+ }
1718
+ </style>
1719
+ </head>
1720
+ <body>
1721
+ <main>
1722
+ <h1>Checkout cancelled.</h1>
1723
+ <p>No charge was made. You can return to the landing page and restart checkout whenever you are ready.</p>
1724
+ <div class="card">
1725
+ <h2>What stopped you?</h2>
1726
+ <p>Pick the closest reason. This writes directly into the first-party telemetry stream so we can fix the real blocker instead of guessing.</p>
1727
+ <div class="reason-grid">
1728
+ <button type="button" data-reason="too_expensive">Too expensive</button>
1729
+ <button type="button" data-reason="not_ready">Just researching</button>
1730
+ <button type="button" data-reason="need_team_features">Need team workflow features</button>
1731
+ <button type="button" data-reason="need_more_proof">Need more proof or trust</button>
1732
+ <button type="button" data-reason="prefer_oss">Sticking with OSS only</button>
1733
+ <button type="button" data-reason="integration_unclear">Integration is unclear</button>
1734
+ </div>
1735
+ <div style="margin-top:16px;">
1736
+ <label for="buyer-note">Anything specific?</label>
1737
+ <textarea id="buyer-note" placeholder="Optional detail: team size, workflow, blocker, competitor, or missing feature."></textarea>
1738
+ </div>
1739
+ <div class="actions">
1740
+ <button type="button" id="submit-reason">Send feedback</button>
1741
+ <a id="retry-checkout" href="/checkout/pro" class="secondary">Try checkout again</a>
1742
+ <a href="${runtimeConfig.appOrigin}" class="secondary">Return to Context Gateway</a>
1743
+ </div>
1744
+ <p class="note" id="status">No feedback sent yet.</p>
1745
+ </div>
1746
+ <script>
1747
+ (function () {
1748
+ const params = new URLSearchParams(window.location.search);
1749
+ const statusEl = document.getElementById('status');
1750
+ const noteEl = document.getElementById('buyer-note');
1751
+ const retryLink = document.getElementById('retry-checkout');
1752
+ let selectedReason = null;
1753
+
1754
+ function sendTelemetry(eventType, extra) {
1755
+ const payload = Object.assign({
1756
+ eventType: eventType,
1757
+ clientType: 'web',
1758
+ traceId: params.get('trace_id'),
1759
+ acquisitionId: params.get('acquisition_id'),
1760
+ visitorId: params.get('visitor_id'),
1761
+ sessionId: params.get('visitor_session_id') || params.get('session_id'),
1762
+ installId: params.get('install_id'),
1763
+ source: params.get('utm_source') || params.get('source') || 'website',
1764
+ utmSource: params.get('utm_source') || params.get('source') || 'website',
1765
+ utmMedium: params.get('utm_medium') || 'checkout_cancel',
1766
+ utmCampaign: params.get('utm_campaign') || 'pro_pack',
1767
+ utmContent: params.get('utm_content'),
1768
+ utmTerm: params.get('utm_term'),
1769
+ creator: params.get('creator') || params.get('creator_handle'),
1770
+ community: params.get('community') || params.get('subreddit'),
1771
+ postId: params.get('post_id'),
1772
+ commentId: params.get('comment_id'),
1773
+ campaignVariant: params.get('campaign_variant'),
1774
+ offerCode: params.get('offer_code'),
1775
+ ctaId: params.get('cta_id') || 'pricing_pro',
1776
+ ctaPlacement: params.get('cta_placement') || 'pricing',
1777
+ planId: params.get('plan_id') || 'pro',
1778
+ page: window.location.pathname,
1779
+ landingPath: params.get('landing_path') || '/',
1780
+ referrerHost: params.get('referrer_host'),
1781
+ referrer: document.referrer || null
1782
+ }, extra || {});
1783
+
1784
+ const body = JSON.stringify(payload);
1785
+ if (navigator.sendBeacon) {
1786
+ navigator.sendBeacon('/v1/telemetry/ping', new Blob([body], { type: 'application/json' }));
1787
+ return;
1788
+ }
1789
+ fetch('/v1/telemetry/ping', {
1790
+ method: 'POST',
1791
+ headers: { 'content-type': 'application/json' },
1792
+ body: body,
1793
+ keepalive: true
1794
+ }).catch(function () {});
1795
+ }
1796
+
1797
+ const retryUrl = new URL(retryLink.href, window.location.origin);
1798
+ ['trace_id', 'acquisition_id', 'visitor_id', 'session_id', 'visitor_session_id', 'install_id', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'creator', 'community', 'post_id', 'comment_id', 'campaign_variant', 'offer_code', 'cta_id', 'cta_placement', 'plan_id', 'billing_cycle', 'seat_count', 'landing_path', 'referrer_host'].forEach(function (key) {
1799
+ const value = params.get(key);
1800
+ if (value) retryUrl.searchParams.set(key, value);
1801
+ });
1802
+ retryLink.href = retryUrl.toString();
1803
+
1804
+ sendTelemetry('checkout_cancelled');
1805
+
1806
+ document.querySelectorAll('[data-reason]').forEach(function (button) {
1807
+ button.addEventListener('click', function () {
1808
+ selectedReason = button.getAttribute('data-reason');
1809
+ statusEl.textContent = 'Selected reason: ' + selectedReason.replaceAll('_', ' ') + '.';
1810
+ });
1811
+ });
1812
+
1813
+ document.getElementById('submit-reason').addEventListener('click', function () {
1814
+ sendTelemetry('reason_not_buying', {
1815
+ reasonCode: selectedReason || 'unspecified',
1816
+ reasonText: noteEl.value || null
1817
+ });
1818
+ statusEl.textContent = selectedReason
1819
+ ? 'Feedback saved: ' + selectedReason.replaceAll('_', ' ') + '.'
1820
+ : 'Feedback saved.';
1821
+ });
1822
+ }());
1823
+ </script>
1824
+ </main>
1825
+ </body>
1826
+ </html>`;
1827
+ }
1828
+
1829
+ function renderWorkflowSprintIntakeResultPage(runtimeConfig, { title, detail, leadId = null }) {
1830
+ const safeTitle = escapeHtmlAttribute(title || 'Sprint intake received');
1831
+ const safeDetail = escapeHtmlAttribute(detail || 'We have your workflow details and will review the proof path next.');
1832
+ const proofPackUrl = 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md';
1833
+ const sprintBriefUrl = 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/WORKFLOW_HARDENING_SPRINT.md';
1834
+ const safeLeadId = leadId ? `<p><strong>Lead ID:</strong> ${escapeHtmlAttribute(leadId)}</p>` : '';
1835
+ return `<!DOCTYPE html>
1836
+ <html lang="en">
1837
+ <head>
1838
+ <meta charset="UTF-8" />
1839
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
1840
+ <title>${safeTitle}</title>
1841
+ <meta name="robots" content="noindex,nofollow" />
1842
+ <style>
1843
+ :root {
1844
+ --bg: #f6f1e8;
1845
+ --ink: #1d1b18;
1846
+ --muted: #625a4d;
1847
+ --line: #d7cfbf;
1848
+ --accent: #b85c2d;
1849
+ --surface: #fffdf9;
1850
+ }
1851
+ body {
1852
+ margin: 0;
1853
+ font-family: Georgia, 'Times New Roman', serif;
1854
+ background: var(--bg);
1855
+ color: var(--ink);
1856
+ line-height: 1.6;
1857
+ }
1858
+ main {
1859
+ max-width: 720px;
1860
+ margin: 0 auto;
1861
+ padding: 64px 20px 80px;
1862
+ }
1863
+ .card {
1864
+ background: var(--surface);
1865
+ border: 1px solid var(--line);
1866
+ border-radius: 16px;
1867
+ padding: 24px;
1868
+ margin-top: 18px;
1869
+ }
1870
+ .actions {
1871
+ display: flex;
1872
+ flex-wrap: wrap;
1873
+ gap: 12px;
1874
+ margin-top: 18px;
1875
+ }
1876
+ a {
1877
+ display: inline-block;
1878
+ text-decoration: none;
1879
+ background: var(--accent);
1880
+ color: white;
1881
+ padding: 12px 18px;
1882
+ border-radius: 10px;
1883
+ font-weight: 700;
1884
+ }
1885
+ a.secondary {
1886
+ background: transparent;
1887
+ color: var(--ink);
1888
+ border: 1px solid var(--line);
1889
+ }
1890
+ </style>
1891
+ </head>
1892
+ <body>
1893
+ <main>
1894
+ <h1>${safeTitle}</h1>
1895
+ <p>${safeDetail}</p>
1896
+ <div class="card">
1897
+ <p>Your workflow intake is now in the sprint queue. Review the proof pack and sprint scope while we assess the rollout blocker.</p>
1898
+ ${safeLeadId}
1899
+ <div class="actions">
1900
+ <a href="${proofPackUrl}">Review Proof Pack</a>
1901
+ <a class="secondary" href="${sprintBriefUrl}">Review Sprint Brief</a>
1902
+ <a class="secondary" href="${runtimeConfig.appOrigin}/#workflow-sprint-intake">Return to Context Gateway</a>
1903
+ </div>
1904
+ </div>
1905
+ </main>
1906
+ </body>
1907
+ </html>`;
1908
+ }
1909
+
1910
+ function readBodyBuffer(req, maxBytes = 1024 * 1024) {
1911
+ return new Promise((resolve, reject) => {
1912
+ let total = 0;
1913
+ const chunks = [];
1914
+
1915
+ req.on('data', (chunk) => {
1916
+ total += chunk.length;
1917
+ if (total > maxBytes) {
1918
+ reject(createHttpError(413, 'Request body too large'));
1919
+ req.destroy();
1920
+ return;
1921
+ }
1922
+ chunks.push(chunk);
1923
+ });
1924
+
1925
+ req.on('end', () => {
1926
+ if (chunks.length === 0) {
1927
+ resolve(Buffer.alloc(0));
1928
+ return;
1929
+ }
1930
+ resolve(Buffer.concat(chunks));
1931
+ });
1932
+
1933
+ req.on('error', reject);
1934
+ });
1935
+ }
1936
+
1937
+ async function parseJsonBody(req, maxBytes = 1024 * 1024) {
1938
+ const body = await readBodyBuffer(req, maxBytes);
1939
+ if (!body.length) return {};
1940
+ try {
1941
+ return JSON.parse(body.toString('utf-8'));
1942
+ } catch {
1943
+ throw createHttpError(400, 'Invalid JSON body');
1944
+ }
1945
+ }
1946
+
1947
+ async function parseFormBody(req, maxBytes = 1024 * 1024) {
1948
+ const body = await readBodyBuffer(req, maxBytes);
1949
+ const decoded = body.toString('utf-8');
1950
+ const params = new URLSearchParams(decoded);
1951
+ return Object.fromEntries(params.entries());
1952
+ }
1953
+
1954
+ function parseOptionalObject(input, name) {
1955
+ if (input == null) return {};
1956
+ if (typeof input === 'object' && !Array.isArray(input)) return input;
1957
+ if (typeof input === 'string') {
1958
+ const trimmed = input.trim();
1959
+ if (!trimmed) return {};
1960
+ const parsed = JSON.parse(trimmed);
1961
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1962
+ throw createHttpError(400, `${name} must be an object`);
1963
+ }
1964
+ return parsed;
1965
+ }
1966
+ throw createHttpError(400, `${name} must be an object`);
1967
+ }
1968
+
1969
+ function getExpectedApiKey() {
1970
+ if (process.env.THUMBGATE_ALLOW_INSECURE === 'true') return null;
1971
+ const configured = process.env.THUMBGATE_API_KEY;
1972
+ // Developer override: ~/.config/thumbgate/dev.json bypass skips API key requirement
1973
+ // Only applies when no THUMBGATE_API_KEY is explicitly configured (avoids test interference)
1974
+ if (!configured) {
1975
+ try {
1976
+ const { hasDevOverride } = require('../../scripts/pro-local-dashboard');
1977
+ if (hasDevOverride()) return null;
1978
+ } catch { /* pro-local-dashboard not available */ }
1979
+ }
1980
+ if (!configured) {
1981
+ throw new Error('THUMBGATE_API_KEY is required unless THUMBGATE_ALLOW_INSECURE=true');
1982
+ }
1983
+ return configured;
1984
+ }
1985
+
1986
+ function isAuthorized(req, expected) {
1987
+ if (!expected) return true;
1988
+ const token = extractApiKey(req);
1989
+
1990
+ // Check static THUMBGATE_API_KEY first
1991
+ if (token === expected) return true;
1992
+
1993
+ // Also accept any valid provisioned billing key
1994
+ if (token) {
1995
+ const result = validateApiKey(token);
1996
+ return result.valid === true;
1997
+ }
1998
+
1999
+ return false;
2000
+ }
2001
+
2002
+ /**
2003
+ * Extract the Bearer token from a request (returns '' if absent).
2004
+ */
2005
+ function extractBearerToken(req) {
2006
+ const auth = req.headers.authorization || '';
2007
+ return auth.startsWith('Bearer ') ? auth.slice(7) : '';
2008
+ }
2009
+
2010
+ function extractApiKey(req) {
2011
+ const bearerToken = extractBearerToken(req);
2012
+ if (bearerToken) {
2013
+ return bearerToken;
2014
+ }
2015
+
2016
+ const alternateHeader = req.headers['x-api-key'];
2017
+ if (Array.isArray(alternateHeader)) {
2018
+ return String(alternateHeader[0] || '').trim();
2019
+ }
2020
+
2021
+ if (typeof alternateHeader === 'string') {
2022
+ return alternateHeader.trim();
2023
+ }
2024
+
2025
+ return '';
2026
+ }
2027
+
2028
+ /**
2029
+ * Admin-only guard for static THUMBGATE_API_KEY.
2030
+ * Billing keys are intentionally excluded from admin actions.
2031
+ */
2032
+ function isStaticAdminAuthorized(req, expected) {
2033
+ if (!expected) return true;
2034
+ return extractApiKey(req) === expected;
2035
+ }
2036
+
2037
+ function extractTags(input) {
2038
+ if (Array.isArray(input)) return input;
2039
+ if (typeof input === 'string') {
2040
+ return input
2041
+ .split(',')
2042
+ .map((t) => t.trim())
2043
+ .filter(Boolean);
2044
+ }
2045
+ return [];
2046
+ }
2047
+
2048
+ function resolveSafePath(inputPath, { mustExist = false } = {}) {
2049
+ const allowExternal = process.env.THUMBGATE_ALLOW_EXTERNAL_PATHS === 'true';
2050
+ const resolved = path.resolve(String(inputPath || ''));
2051
+ const SAFE_DATA_DIR = getSafeDataDir();
2052
+ const inSafeRoot = resolved === SAFE_DATA_DIR || resolved.startsWith(`${SAFE_DATA_DIR}${path.sep}`);
2053
+
2054
+ if (!allowExternal && !inSafeRoot) {
2055
+ throw createHttpError(400, `Path must stay within ${SAFE_DATA_DIR}`);
2056
+ }
2057
+
2058
+ if (mustExist && !fs.existsSync(resolved)) {
2059
+ throw createHttpError(400, `Path does not exist: ${resolved}`);
2060
+ }
2061
+
2062
+ return resolved;
2063
+ }
2064
+
2065
+ function createApiServer() {
2066
+ const expectedApiKey = getExpectedApiKey();
2067
+
2068
+ return http.createServer(async (req, res) => {
2069
+ const parsed = new URL(req.url, 'http://localhost');
2070
+ const pathname = parsed.pathname;
2071
+ const isHeadRequest = req.method === 'HEAD';
2072
+ const isGetLikeRequest = req.method === 'GET' || isHeadRequest;
2073
+ const publicOrigin = getPublicOrigin(req);
2074
+ const hostedConfig = resolveHostedBillingConfig({ requestOrigin: publicOrigin });
2075
+
2076
+ // Public MCP endpoint — responds to Smithery registry scanning and MCP initialize
2077
+ // The initialize handshake is unauthenticated; subsequent tool calls require Bearer auth
2078
+ if (pathname === '/mcp') {
2079
+ if (req.method === 'POST') {
2080
+ let body = '';
2081
+ req.on('data', (chunk) => { body += chunk; });
2082
+ req.on('end', () => {
2083
+ try {
2084
+ const msg = JSON.parse(body);
2085
+ if (msg.method === 'initialize') {
2086
+ sendJson(res, 200, {
2087
+ jsonrpc: '2.0',
2088
+ id: msg.id,
2089
+ result: {
2090
+ protocolVersion: '2024-11-05',
2091
+ capabilities: { tools: {} },
2092
+ serverInfo: { name: 'thumbgate', version: pkg.version },
2093
+ },
2094
+ });
2095
+ } else if (msg.method === 'notifications/initialized') {
2096
+ res.writeHead(204);
2097
+ res.end();
2098
+ } else if (msg.method === 'tools/list') {
2099
+ sendJson(res, 200, {
2100
+ jsonrpc: '2.0',
2101
+ id: msg.id,
2102
+ result: {
2103
+ tools: getPublicMcpTools(),
2104
+ },
2105
+ });
2106
+ } else {
2107
+ // All other tool calls require auth — return method not found for unauthenticated
2108
+ sendJson(res, 200, {
2109
+ jsonrpc: '2.0',
2110
+ id: msg.id,
2111
+ error: { code: -32601, message: 'Method requires authentication. Provide Bearer token.' },
2112
+ });
2113
+ }
2114
+ } catch (_e) {
2115
+ sendProblem(res, {
2116
+ type: PROBLEM_TYPES.INVALID_JSON,
2117
+ title: 'Invalid JSON',
2118
+ status: 400,
2119
+ detail: 'The request body could not be parsed as valid JSON.',
2120
+ });
2121
+ }
2122
+ });
2123
+ return;
2124
+ }
2125
+ if (req.method === 'GET') {
2126
+ // SSE upgrade or capability probe
2127
+ sendJson(res, 200, {
2128
+ name: 'thumbgate',
2129
+ version: pkg.version,
2130
+ transport: ['streamable-http', 'stdio'],
2131
+ });
2132
+ return;
2133
+ }
2134
+ }
2135
+
2136
+ // Plausible analytics proxy — bypasses ad blockers for accurate tracking
2137
+ if (isGetLikeRequest && pathname === '/js/analytics.js') {
2138
+ const proxyReq = https.get('https://plausible.io/js/script.js', (proxyRes) => {
2139
+ const chunks = [];
2140
+ proxyRes.on('data', (chunk) => chunks.push(chunk));
2141
+ proxyRes.on('end', () => {
2142
+ let body = Buffer.concat(chunks).toString();
2143
+ // Rewrite the API endpoint to go through our proxy
2144
+ body = body.replace(
2145
+ 'new URL(i.src).origin+"/api/event"',
2146
+ '"/api/event"'
2147
+ );
2148
+ res.writeHead(proxyRes.statusCode, {
2149
+ 'Content-Type': 'application/javascript; charset=utf-8',
2150
+ 'Cache-Control': 'public, max-age=86400',
2151
+ 'Access-Control-Allow-Origin': '*',
2152
+ });
2153
+ res.end(body);
2154
+ });
2155
+ });
2156
+ proxyReq.on('error', () => sendJson(res, 502, { error: 'Analytics proxy failed' }));
2157
+ return;
2158
+ }
2159
+
2160
+
2161
+ // User feedback → GitHub Issues
2162
+ if (req.method === 'POST' && pathname === '/api/feedback/submit') {
2163
+ const chunks = [];
2164
+ req.on('data', (chunk) => chunks.push(chunk));
2165
+ req.on('end', async () => {
2166
+ try {
2167
+ const body = JSON.parse(Buffer.concat(chunks).toString());
2168
+ const { category, message } = body;
2169
+ if (!message || message.length < 5) {
2170
+ sendJson(res, 400, { error: 'message too short' });
2171
+ return;
2172
+ }
2173
+ const result = await submitProductIssue({
2174
+ title: buildProductIssueTitle(message, category),
2175
+ body: message,
2176
+ category: category || 'bug',
2177
+ source: 'dashboard feedback widget',
2178
+ });
2179
+ sendJson(res, 200, result);
2180
+ } catch (e) {
2181
+ sendJson(res, 500, { error: 'feedback submission failed' });
2182
+ }
2183
+ });
2184
+ return;
2185
+ }
2186
+
2187
+ if (req.method === 'POST' && pathname === '/api/newsletter') {
2188
+ const chunks = [];
2189
+ req.on('data', (chunk) => chunks.push(chunk));
2190
+ req.on('end', () => {
2191
+ try {
2192
+ const body = Buffer.concat(chunks).toString();
2193
+ const params = new URLSearchParams(body);
2194
+ const email = (params.get('email') || '').trim().toLowerCase();
2195
+ if (!email || !email.includes('@')) {
2196
+ sendJson(res, 400, { error: 'valid email required' });
2197
+ return;
2198
+ }
2199
+ const newsletterPath = path.join(getFeedbackPaths().FEEDBACK_DIR, 'newsletter-subscribers.jsonl');
2200
+ const dir = path.dirname(newsletterPath);
2201
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
2202
+ fs.appendFileSync(newsletterPath, JSON.stringify({ email, subscribedAt: new Date().toISOString(), source: 'landing-page' }) + '\n');
2203
+ res.writeHead(302, { Location: '/?subscribed=1' });
2204
+ res.end();
2205
+ } catch {
2206
+ sendJson(res, 500, { error: 'subscription failed' });
2207
+ }
2208
+ });
2209
+ return;
2210
+ }
2211
+
2212
+ if (req.method === 'POST' && pathname === '/api/event') {
2213
+ // Filter bots from analytics to keep Plausible data clean
2214
+ let _botDetector;
2215
+ try { _botDetector = require('../../scripts/bot-detector'); } catch (_e) { _botDetector = null; }
2216
+ if (_botDetector && _botDetector.shouldExcludeFromAnalytics(req)) {
2217
+ sendJson(res, 202, { status: 'filtered', reason: 'bot' });
2218
+ return;
2219
+ }
2220
+ const chunks = [];
2221
+ req.on('data', (chunk) => chunks.push(chunk));
2222
+ req.on('end', () => {
2223
+ const body = Buffer.concat(chunks);
2224
+ const proxyReq = https.request('https://plausible.io/api/event', {
2225
+ method: 'POST',
2226
+ headers: {
2227
+ 'Content-Type': 'text/plain',
2228
+ 'User-Agent': req.headers['user-agent'] || '',
2229
+ 'X-Forwarded-For': req.headers['x-forwarded-for'] || req.socket.remoteAddress || '',
2230
+ },
2231
+ }, (proxyRes) => {
2232
+ const rChunks = [];
2233
+ proxyRes.on('data', (c) => rChunks.push(c));
2234
+ proxyRes.on('end', () => {
2235
+ res.writeHead(proxyRes.statusCode, { 'Access-Control-Allow-Origin': '*' });
2236
+ res.end(Buffer.concat(rChunks));
2237
+ });
2238
+ });
2239
+ proxyReq.on('error', () => sendJson(res, 502, { error: 'Event proxy failed' }));
2240
+ proxyReq.end(body);
2241
+ });
2242
+ return;
2243
+ }
2244
+
2245
+ // Public endpoints — no auth required
2246
+ if (isGetLikeRequest && pathname === '/robots.txt') {
2247
+ sendText(res, 200, renderRobotsTxt(hostedConfig), {
2248
+ 'Content-Type': 'text/plain; charset=utf-8',
2249
+ }, {
2250
+ headOnly: isHeadRequest,
2251
+ });
2252
+ return;
2253
+ }
2254
+
2255
+ if (isGetLikeRequest && pathname === '/sitemap.xml') {
2256
+ sendText(res, 200, renderSitemapXml(hostedConfig), {
2257
+ 'Content-Type': 'application/xml; charset=utf-8',
2258
+ }, {
2259
+ headOnly: isHeadRequest,
2260
+ });
2261
+ return;
2262
+ }
2263
+
2264
+ // Quick feedback capture via GET — for statusline clickable links
2265
+ if (isGetLikeRequest && pathname === '/feedback/quick') {
2266
+ const signal = parsed.searchParams.get('signal');
2267
+ if (signal === 'up' || signal === 'down') {
2268
+ const chatHistory = readRecentConversationWindow({
2269
+ feedbackDir: getSafeDataDir(),
2270
+ limit: 10,
2271
+ });
2272
+ const result = captureFeedback({
2273
+ signal,
2274
+ context: 'Quick capture from Claude Code statusline',
2275
+ chatHistory,
2276
+ tags: ['statusline', 'quick-capture'],
2277
+ });
2278
+ const emoji = signal === 'up' ? '👍' : '👎';
2279
+ const color = signal === 'up' ? '#22c55e' : '#ef4444';
2280
+ const label = signal === 'up' ? 'Positive' : 'Negative';
2281
+ const opposite = signal === 'up' ? 'down' : 'up';
2282
+ const oppEmoji = signal === 'up' ? '👎' : '👍';
2283
+ const feedbackId = result.feedbackEvent?.id || 'saved';
2284
+ const promoted = result.accepted ? 'Promoted to memory' : 'Stored';
2285
+ sendHtml(res, 200, `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ThumbGate — ${label} feedback</title>
2286
+ <style>
2287
+ *{box-sizing:border-box}
2288
+ body{background:#0a0a0a;color:#fff;font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
2289
+ .card{text-align:center;background:#141414;border:1px solid #222;border-radius:16px;padding:48px 40px;max-width:420px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,.5)}
2290
+ .emoji{font-size:80px;margin-bottom:12px;animation:pop .4s ease-out}
2291
+ @keyframes pop{0%{transform:scale(0)}50%{transform:scale(1.2)}100%{transform:scale(1)}}
2292
+ .msg{font-size:22px;color:${color};font-weight:700;margin-bottom:4px}
2293
+ .sub{font-size:13px;color:#666;margin-top:6px;font-family:ui-monospace,monospace}
2294
+ .context-form{margin-top:20px;text-align:left}
2295
+ .context-form label{font-size:12px;color:#888;display:block;margin-bottom:6px}
2296
+ .context-form textarea{width:100%;background:#1a1a1a;border:1px solid #333;border-radius:8px;color:#ccc;padding:10px;font-size:13px;resize:vertical;min-height:60px;font-family:system-ui}
2297
+ .context-form textarea:focus{outline:none;border-color:${color}}
2298
+ .context-form button{margin-top:8px;background:${color};color:#000;border:none;border-radius:8px;padding:8px 20px;font-size:13px;font-weight:600;cursor:pointer}
2299
+ .context-form button:hover{opacity:.85}
2300
+ .actions{margin-top:24px;display:flex;gap:12px;justify-content:center;flex-wrap:wrap}
2301
+ .actions a{color:#22d3ee;text-decoration:none;font-size:13px;padding:8px 16px;border:1px solid #333;border-radius:8px;transition:all .15s}
2302
+ .actions a:hover{background:#1a2a2e;border-color:#22d3ee}
2303
+ .toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#22c55e;color:#000;padding:10px 24px;border-radius:8px;font-size:14px;font-weight:600;display:none;animation:slideUp .3s ease-out}
2304
+ @keyframes slideUp{from{opacity:0;transform:translateX(-50%) translateY(12px)}to{opacity:1;transform:translateX(-50%) translateY(0)}}
2305
+ .badge{display:inline-block;font-size:11px;padding:2px 8px;border-radius:4px;background:#1a1a1a;border:1px solid #333;color:#888;margin-top:8px}
2306
+ </style></head><body>
2307
+ <div class="card">
2308
+ <div class="emoji">${emoji}</div>
2309
+ <div class="msg">${label} feedback recorded</div>
2310
+ <div class="sub">${promoted} · <a href="/lessons/${feedbackId}" class="badge" style="color:#22d3ee;text-decoration:none;cursor:pointer" title="View full lesson">${feedbackId}</a></div>
2311
+ <div class="context-form" id="contextForm">
2312
+ <label>Add follow-up context <span style="color:#555">(what worked or went wrong?)</span></label>
2313
+ <textarea id="contextInput" placeholder="e.g. you forgot to check the API schema first..."></textarea>
2314
+ <button onclick="addContext()">Save follow-up note</button>
2315
+ </div>
2316
+ <div class="actions">
2317
+ <a href="/lessons/${feedbackId}" title="View the full lesson and edit it">📋 View Lesson</a>
2318
+ <a href="/feedback/quick?signal=${opposite}" title="Meant to click ${oppEmoji}?">Undo → send ${oppEmoji} instead</a>
2319
+ <a href="/dashboard">Dashboard →</a>
2320
+ </div>
2321
+ </div>
2322
+ <div class="toast" id="toast">✓ Follow-up note saved</div>
2323
+ <script>
2324
+ async function addContext(){
2325
+ const ctx=document.getElementById('contextInput').value.trim();
2326
+ if(!ctx)return;
2327
+ try{
2328
+ await fetch('/feedback/quick/context',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({signal:'${signal}',context:ctx,relatedFeedbackId:'${feedbackId}'})});
2329
+ document.getElementById('toast').style.display='block';
2330
+ document.getElementById('contextForm').style.display='none';
2331
+ setTimeout(()=>document.getElementById('toast').style.display='none',3000);
2332
+ }catch(e){alert('Failed: '+e.message)}
2333
+ }
2334
+ </script></body></html>`);
2335
+ } else {
2336
+ sendHtml(res, 400, `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ThumbGate</title></head><body style="background:#0a0a0a;color:#fff;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh"><div style="text-align:center"><div style="font-size:48px">⚠️</div><div style="font-size:18px;margin-top:12px">Missing ?signal=up or ?signal=down</div></div></body></html>`);
2337
+ }
2338
+ return;
2339
+ }
2340
+
2341
+ if (req.method === 'POST' && pathname === '/feedback/quick/context') {
2342
+ const body = await parseJsonBody(req);
2343
+ const signal = body.signal;
2344
+ const context = typeof body.context === 'string' ? body.context.trim() : '';
2345
+ const relatedFeedbackId = typeof body.relatedFeedbackId === 'string' ? body.relatedFeedbackId.trim() : '';
2346
+ if (signal !== 'up' && signal !== 'down') {
2347
+ sendJson(res, 400, { error: 'signal must be up or down' });
2348
+ return;
2349
+ }
2350
+ if (!context) {
2351
+ sendJson(res, 400, { error: 'context is required' });
2352
+ return;
2353
+ }
2354
+ const result = captureFeedback({
2355
+ signal,
2356
+ context,
2357
+ relatedFeedbackId,
2358
+ chatHistory: readRecentConversationWindow({
2359
+ feedbackDir: getSafeDataDir(),
2360
+ limit: 10,
2361
+ }),
2362
+ tags: ['statusline', 'quick-capture', 'follow-up-context'],
2363
+ });
2364
+ const code = result.accepted ? 200 : 422;
2365
+ sendJson(res, code, result);
2366
+ return;
2367
+ }
2368
+
2369
+ if (isGetLikeRequest && pathname === '/dashboard') {
2370
+ try {
2371
+ const html = loadDashboardPageHtml(req, expectedApiKey);
2372
+ sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
2373
+ } catch {
2374
+ sendJson(res, 404, { error: 'Dashboard page not found' });
2375
+ }
2376
+ return;
2377
+ }
2378
+
2379
+ // --- Lesson detail: POST /lessons/:id/update ---
2380
+ const lessonUpdateMatch = pathname.match(/^\/lessons\/([^/]+)\/update$/);
2381
+ if (req.method === 'POST' && lessonUpdateMatch) {
2382
+ const lessonId = decodeURIComponent(lessonUpdateMatch[1]);
2383
+ const feedbackDir = getSafeDataDir();
2384
+ const body = await parseJsonBody(req);
2385
+ const memoryLogPath = path.join(feedbackDir, 'memory-log.jsonl');
2386
+ const record = findRecordById(lessonId, feedbackDir);
2387
+ if (!record) {
2388
+ sendJson(res, 404, { error: 'Record not found' });
2389
+ return;
2390
+ }
2391
+ const existing = record.memoryRecord || record.feedbackEvent || {};
2392
+ const updated = { ...existing };
2393
+ if (body.title !== undefined) updated.title = body.title;
2394
+ if (body.content !== undefined) updated.context = body.content;
2395
+ if (body.tags !== undefined) {
2396
+ updated.tags = typeof body.tags === 'string'
2397
+ ? body.tags.split(',').map((t) => t.trim()).filter(Boolean)
2398
+ : body.tags;
2399
+ }
2400
+ if (body.whatWentWrong !== undefined) updated.whatWentWrong = body.whatWentWrong;
2401
+ if (body.whatWorked !== undefined) updated.whatWorked = body.whatWorked;
2402
+ const success = updateRecordInJsonl(memoryLogPath, lessonId, updated);
2403
+ if (!success) {
2404
+ // Try feedback log
2405
+ const feedbackLogPath = path.join(feedbackDir, 'feedback-log.jsonl');
2406
+ updateRecordInJsonl(feedbackLogPath, lessonId, updated);
2407
+ }
2408
+ sendJson(res, 200, { ok: true, updated });
2409
+ return;
2410
+ }
2411
+
2412
+ // --- Lesson detail: POST /lessons/:id/delete ---
2413
+ const lessonDeleteMatch = pathname.match(/^\/lessons\/([^/]+)\/delete$/);
2414
+ if (req.method === 'POST' && lessonDeleteMatch) {
2415
+ const lessonId = decodeURIComponent(lessonDeleteMatch[1]);
2416
+ const feedbackDir = getSafeDataDir();
2417
+ const memoryLogPath = path.join(feedbackDir, 'memory-log.jsonl');
2418
+ const feedbackLogPath = path.join(feedbackDir, 'feedback-log.jsonl');
2419
+ const deletedMemory = deleteRecordFromJsonl(memoryLogPath, lessonId);
2420
+ const deletedFeedback = deleteRecordFromJsonl(feedbackLogPath, lessonId);
2421
+ if (!deletedMemory && !deletedFeedback) {
2422
+ sendJson(res, 404, { error: 'Record not found' });
2423
+ return;
2424
+ }
2425
+ sendJson(res, 200, { ok: true, deleted: lessonId });
2426
+ return;
2427
+ }
2428
+
2429
+ // --- Lesson detail page: GET /lessons/:id ---
2430
+ const lessonDetailMatch = pathname.match(/^\/lessons\/([^/]+)$/);
2431
+ if (isGetLikeRequest && lessonDetailMatch && lessonDetailMatch[1] !== '') {
2432
+ const lessonId = decodeURIComponent(lessonDetailMatch[1]);
2433
+ const feedbackDir = getSafeDataDir();
2434
+ const record = findRecordById(lessonId, feedbackDir);
2435
+ if (!record) {
2436
+ sendHtml(res, 404, renderLessonDetailHtml(null, lessonId));
2437
+ return;
2438
+ }
2439
+ sendHtml(res, 200, renderLessonDetailHtml(record, lessonId), {}, { headOnly: isHeadRequest });
2440
+ return;
2441
+ }
2442
+
2443
+ if (isGetLikeRequest && pathname === '/lessons') {
2444
+ try {
2445
+ const html = loadLessonsPageHtml(req, expectedApiKey);
2446
+ sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
2447
+ } catch {
2448
+ sendJson(res, 404, { error: 'Lessons page not found' });
2449
+ }
2450
+ return;
2451
+ }
2452
+
2453
+ const seoPage = findSeoPageByPath(pathname);
2454
+ if (isGetLikeRequest && seoPage) {
2455
+ try {
2456
+ servePublicMarketingPage({
2457
+ req,
2458
+ res,
2459
+ parsed,
2460
+ hostedConfig,
2461
+ isHeadRequest,
2462
+ renderHtml: (runtimeConfig) => renderSeoPageHtml(seoPage, runtimeConfig),
2463
+ extraTelemetry: {
2464
+ pageType: seoPage.pageType,
2465
+ contentPillar: seoPage.pillar,
2466
+ primaryQuery: seoPage.query,
2467
+ },
2468
+ });
2469
+ } catch (err) {
2470
+ sendText(res, 500, err.message || 'SEO page unavailable');
2471
+ }
2472
+ return;
2473
+ }
2474
+
2475
+ if (isGetLikeRequest && pathname === '/guide') {
2476
+ try {
2477
+ const html = fs.readFileSync(GUIDE_PAGE_PATH, 'utf-8');
2478
+ sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
2479
+ } catch {
2480
+ sendJson(res, 404, { error: 'Guide page not found' });
2481
+ }
2482
+ return;
2483
+ }
2484
+
2485
+ if (isGetLikeRequest && pathname === '/blog') {
2486
+ try {
2487
+ const blogPath = path.resolve(__dirname, '../../public/blog.html');
2488
+ const html = fs.readFileSync(blogPath, 'utf-8');
2489
+ sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
2490
+ } catch {
2491
+ sendJson(res, 404, { error: 'Blog page not found' });
2492
+ }
2493
+ return;
2494
+ }
2495
+
2496
+ if (isGetLikeRequest && pathname === '/learn') {
2497
+ try {
2498
+ const html = fs.readFileSync(LEARN_PAGE_PATH, 'utf-8');
2499
+ sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
2500
+ } catch {
2501
+ sendJson(res, 404, { error: 'Learn page not found' });
2502
+ }
2503
+ return;
2504
+ }
2505
+
2506
+ if (isGetLikeRequest && pathname === '/learn/learn.css') {
2507
+ try {
2508
+ const cssPath = path.join(LEARN_DIR, 'learn.css');
2509
+ const css = fs.readFileSync(cssPath, 'utf-8');
2510
+ res.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8', 'Cache-Control': 'public, max-age=86400' });
2511
+ if (!isHeadRequest) res.end(css);
2512
+ else res.end();
2513
+ } catch {
2514
+ sendJson(res, 404, { error: 'Stylesheet not found' });
2515
+ }
2516
+ return;
2517
+ }
2518
+
2519
+ if (isGetLikeRequest && pathname.startsWith('/learn/')) {
2520
+ try {
2521
+ const slug = pathname.replace('/learn/', '').replace(/[^a-z0-9-]/g, '');
2522
+ const articlePath = path.join(LEARN_DIR, `${slug}.html`);
2523
+ if (!articlePath.startsWith(LEARN_DIR)) {
2524
+ sendJson(res, 403, { error: 'Forbidden' });
2525
+ return;
2526
+ }
2527
+ const html = fs.readFileSync(articlePath, 'utf-8');
2528
+ sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
2529
+ } catch {
2530
+ sendJson(res, 404, { error: 'Article not found' });
2531
+ }
2532
+ return;
2533
+ }
2534
+
2535
+ if (isGetLikeRequest && pathname === '/') {
2536
+ if (wantsJson(req, parsed)) {
2537
+ sendJson(res, 200, {
2538
+ name: 'thumbgate',
2539
+ version: pkg.version,
2540
+ status: 'ok',
2541
+ docs: 'https://github.com/IgorGanapolsky/ThumbGate',
2542
+ endpoints: ['/health', '/dashboard', '/guide', '/learn', '/v1/feedback/capture', '/v1/feedback/stats', '/v1/feedback/summary', '/v1/lessons/search', '/v1/search', '/v1/dashboard', '/v1/dashboard/render-spec', '/v1/settings/status', '/v1/dpo/export', '/v1/analytics/databricks/export'],
2543
+ }, {}, {
2544
+ headOnly: isHeadRequest,
2545
+ });
2546
+ return;
2547
+ }
2548
+
2549
+ try {
2550
+ servePublicMarketingPage({
2551
+ req,
2552
+ res,
2553
+ parsed,
2554
+ hostedConfig,
2555
+ isHeadRequest,
2556
+ renderHtml: loadLandingPageHtml,
2557
+ extraTelemetry: {
2558
+ pageType: 'homepage',
2559
+ },
2560
+ });
2561
+ } catch (err) {
2562
+ sendText(res, 500, err.message || 'Landing page unavailable');
2563
+ }
2564
+ return;
2565
+ }
2566
+
2567
+ if (isGetLikeRequest && pathname === '/checkout/pro') {
2568
+ if (isHeadRequest) {
2569
+ sendText(res, 200, '', {}, {
2570
+ headOnly: true,
2571
+ });
2572
+ return;
2573
+ }
2574
+
2575
+ const { FEEDBACK_DIR } = getFeedbackPaths();
2576
+ const journeyState = resolveJourneyState(req, parsed);
2577
+ const bootstrapBody = buildCheckoutBootstrapBody(parsed, req, journeyState);
2578
+ const traceId = bootstrapBody.traceId || createJourneyId('checkout');
2579
+ const analyticsMetadata = buildCheckoutAttributionMetadata(bootstrapBody, req, traceId);
2580
+ const responseHeaders = journeyState.setCookieHeaders.length
2581
+ ? { 'Set-Cookie': journeyState.setCookieHeaders }
2582
+ : {};
2583
+
2584
+ appendBestEffortTelemetry(FEEDBACK_DIR, {
2585
+ eventType: 'checkout_bootstrap',
2586
+ clientType: 'web',
2587
+ installId: bootstrapBody.installId,
2588
+ acquisitionId: analyticsMetadata.acquisitionId,
2589
+ visitorId: analyticsMetadata.visitorId,
2590
+ sessionId: analyticsMetadata.sessionId,
2591
+ traceId,
2592
+ source: analyticsMetadata.source,
2593
+ utmSource: analyticsMetadata.utmSource,
2594
+ utmMedium: analyticsMetadata.utmMedium,
2595
+ utmCampaign: analyticsMetadata.utmCampaign,
2596
+ utmContent: analyticsMetadata.utmContent,
2597
+ utmTerm: analyticsMetadata.utmTerm,
2598
+ creator: analyticsMetadata.creator,
2599
+ community: analyticsMetadata.community,
2600
+ postId: analyticsMetadata.postId,
2601
+ commentId: analyticsMetadata.commentId,
2602
+ campaignVariant: analyticsMetadata.campaignVariant,
2603
+ offerCode: analyticsMetadata.offerCode,
2604
+ landingPath: analyticsMetadata.landingPath,
2605
+ page: '/checkout/pro',
2606
+ ctaId: analyticsMetadata.ctaId,
2607
+ ctaPlacement: analyticsMetadata.ctaPlacement,
2608
+ planId: analyticsMetadata.planId,
2609
+ billingCycle: analyticsMetadata.billingCycle,
2610
+ seatCount: analyticsMetadata.seatCount,
2611
+ referrer: analyticsMetadata.referrer,
2612
+ referrerHost: analyticsMetadata.referrerHost,
2613
+ }, req.headers, 'checkout_bootstrap');
2614
+
2615
+ try {
2616
+ const result = await createCheckoutSession({
2617
+ successUrl: buildCheckoutFallbackUrl(
2618
+ buildHostedSuccessUrl(hostedConfig.appOrigin, traceId),
2619
+ analyticsMetadata,
2620
+ ),
2621
+ cancelUrl: buildCheckoutFallbackUrl(
2622
+ buildHostedCancelUrl(hostedConfig.appOrigin, traceId),
2623
+ analyticsMetadata,
2624
+ ),
2625
+ customerEmail: bootstrapBody.customerEmail,
2626
+ installId: bootstrapBody.installId,
2627
+ traceId,
2628
+ metadata: analyticsMetadata,
2629
+ });
2630
+
2631
+ if (result.url) {
2632
+ res.writeHead(302, {
2633
+ ...responseHeaders,
2634
+ Location: result.url,
2635
+ });
2636
+ res.end();
2637
+ return;
2638
+ }
2639
+
2640
+ const successUrl = new URL('/success', hostedConfig.appOrigin);
2641
+ successUrl.searchParams.set('session_id', result.sessionId);
2642
+ successUrl.searchParams.set('trace_id', traceId);
2643
+ appendQueryParam(successUrl, 'acquisition_id', analyticsMetadata.acquisitionId);
2644
+ appendQueryParam(successUrl, 'visitor_id', analyticsMetadata.visitorId);
2645
+ appendVisitorSessionQueryParam(successUrl, analyticsMetadata.sessionId);
2646
+ appendQueryParam(successUrl, 'install_id', bootstrapBody.installId);
2647
+ appendQueryParam(successUrl, 'utm_source', analyticsMetadata.utmSource);
2648
+ appendQueryParam(successUrl, 'utm_medium', analyticsMetadata.utmMedium);
2649
+ appendQueryParam(successUrl, 'utm_campaign', analyticsMetadata.utmCampaign);
2650
+ appendQueryParam(successUrl, 'utm_content', analyticsMetadata.utmContent);
2651
+ appendQueryParam(successUrl, 'utm_term', analyticsMetadata.utmTerm);
2652
+ appendQueryParam(successUrl, 'creator', analyticsMetadata.creator);
2653
+ appendQueryParam(successUrl, 'community', analyticsMetadata.community);
2654
+ appendQueryParam(successUrl, 'post_id', analyticsMetadata.postId);
2655
+ appendQueryParam(successUrl, 'comment_id', analyticsMetadata.commentId);
2656
+ appendQueryParam(successUrl, 'campaign_variant', analyticsMetadata.campaignVariant);
2657
+ appendQueryParam(successUrl, 'offer_code', analyticsMetadata.offerCode);
2658
+ appendQueryParam(successUrl, 'cta_id', analyticsMetadata.ctaId);
2659
+ appendQueryParam(successUrl, 'cta_placement', analyticsMetadata.ctaPlacement);
2660
+ appendQueryParam(successUrl, 'plan_id', analyticsMetadata.planId);
2661
+ appendQueryParam(successUrl, 'billing_cycle', analyticsMetadata.billingCycle);
2662
+ appendQueryParam(successUrl, 'seat_count', analyticsMetadata.seatCount);
2663
+ appendQueryParam(successUrl, 'landing_path', analyticsMetadata.landingPath);
2664
+ appendQueryParam(successUrl, 'referrer_host', analyticsMetadata.referrerHost);
2665
+ res.writeHead(302, {
2666
+ ...responseHeaders,
2667
+ Location: successUrl.toString(),
2668
+ });
2669
+ res.end();
2670
+ } catch (err) {
2671
+ appendBestEffortTelemetry(FEEDBACK_DIR, {
2672
+ eventType: 'checkout_api_failed',
2673
+ clientType: 'web',
2674
+ installId: bootstrapBody.installId,
2675
+ acquisitionId: analyticsMetadata.acquisitionId,
2676
+ visitorId: analyticsMetadata.visitorId,
2677
+ sessionId: analyticsMetadata.sessionId,
2678
+ traceId,
2679
+ source: analyticsMetadata.source,
2680
+ utmSource: analyticsMetadata.utmSource,
2681
+ utmMedium: analyticsMetadata.utmMedium,
2682
+ utmCampaign: analyticsMetadata.utmCampaign,
2683
+ utmContent: analyticsMetadata.utmContent,
2684
+ utmTerm: analyticsMetadata.utmTerm,
2685
+ creator: analyticsMetadata.creator,
2686
+ landingPath: analyticsMetadata.landingPath,
2687
+ page: '/checkout/pro',
2688
+ ctaId: analyticsMetadata.ctaId,
2689
+ ctaPlacement: analyticsMetadata.ctaPlacement,
2690
+ planId: analyticsMetadata.planId,
2691
+ billingCycle: analyticsMetadata.billingCycle,
2692
+ seatCount: analyticsMetadata.seatCount,
2693
+ referrer: analyticsMetadata.referrer,
2694
+ referrerHost: analyticsMetadata.referrerHost,
2695
+ failureCode: err && err.message ? err.message : 'checkout_bootstrap_failed',
2696
+ httpStatus: err && err.statusCode ? err.statusCode : null,
2697
+ }, req.headers, 'checkout_api_failed');
2698
+ res.writeHead(302, {
2699
+ ...responseHeaders,
2700
+ Location: buildCheckoutFallbackUrl(hostedConfig.checkoutFallbackUrl, analyticsMetadata),
2701
+ });
2702
+ res.end();
2703
+ }
2704
+ return;
2705
+ }
2706
+
2707
+ if (isGetLikeRequest && pathname === '/success') {
2708
+ if (isHeadRequest) {
2709
+ sendHtml(res, 200, renderCheckoutSuccessPage(hostedConfig), {}, {
2710
+ headOnly: true,
2711
+ });
2712
+ return;
2713
+ }
2714
+
2715
+ const { FEEDBACK_DIR } = getFeedbackPaths();
2716
+ const journeyState = resolveJourneyState(req, parsed);
2717
+ appendBestEffortTelemetry(FEEDBACK_DIR, {
2718
+ eventType: 'checkout_success_page_view',
2719
+ ...buildCheckoutPageTelemetryMetadata(parsed, req, journeyState, '/success'),
2720
+ }, req.headers, 'checkout_success_page_view');
2721
+ sendHtml(
2722
+ res,
2723
+ 200,
2724
+ renderCheckoutSuccessPage(hostedConfig),
2725
+ journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}
2726
+ );
2727
+ return;
2728
+ }
2729
+
2730
+ if (isGetLikeRequest && pathname === '/cancel') {
2731
+ if (isHeadRequest) {
2732
+ sendHtml(res, 200, renderCheckoutCancelledPage(hostedConfig), {}, {
2733
+ headOnly: true,
2734
+ });
2735
+ return;
2736
+ }
2737
+
2738
+ const { FEEDBACK_DIR } = getFeedbackPaths();
2739
+ const journeyState = resolveJourneyState(req, parsed);
2740
+ appendBestEffortTelemetry(FEEDBACK_DIR, {
2741
+ eventType: 'checkout_cancel_page_view',
2742
+ ...buildCheckoutPageTelemetryMetadata(parsed, req, journeyState, '/cancel'),
2743
+ }, req.headers, 'checkout_cancel_page_view');
2744
+ sendHtml(
2745
+ res,
2746
+ 200,
2747
+ renderCheckoutCancelledPage(hostedConfig),
2748
+ journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}
2749
+ );
2750
+ return;
2751
+ }
2752
+
2753
+ if (isGetLikeRequest && pathname === '/.well-known/mcp/server-card.json') {
2754
+ sendJson(res, 200, {
2755
+ serverInfo: {
2756
+ name: 'thumbgate',
2757
+ version: pkg.version,
2758
+ },
2759
+ name: 'thumbgate',
2760
+ description: 'Pre-action gates that physically block AI coding agents from repeating known mistakes. Captures feedback, auto-promotes failures into prevention rules, and enforces them via PreToolUse hooks. Works with Claude Code, Codex, Gemini, Amp, Cursor, OpenCode, and any MCP-compatible agent.',
2761
+ version: pkg.version,
2762
+ tools: getServerCardTools(),
2763
+ repository: 'https://github.com/IgorGanapolsky/ThumbGate',
2764
+ homepage: hostedConfig.appOrigin,
2765
+ }, {}, {
2766
+ headOnly: isHeadRequest,
2767
+ });
2768
+ return;
2769
+ }
2770
+
2771
+ if (isGetLikeRequest && pathname === '/health') {
2772
+ sendJson(res, 200, {
2773
+ status: 'ok',
2774
+ version: pkg.version,
2775
+ buildSha: BUILD_METADATA.buildSha,
2776
+ uptime: process.uptime(),
2777
+ deployment: {
2778
+ appOrigin: hostedConfig.appOrigin,
2779
+ billingApiBaseUrl: hostedConfig.billingApiBaseUrl,
2780
+ },
2781
+ }, {}, {
2782
+ headOnly: isHeadRequest,
2783
+ });
2784
+ return;
2785
+ }
2786
+
2787
+ if (isGetLikeRequest && pathname === '/healthz') {
2788
+ const { FEEDBACK_LOG_PATH, MEMORY_LOG_PATH } = getFeedbackPaths();
2789
+ sendJson(res, 200, {
2790
+ status: 'ok',
2791
+ feedbackLogPath: FEEDBACK_LOG_PATH,
2792
+ memoryLogPath: MEMORY_LOG_PATH,
2793
+ }, {}, {
2794
+ headOnly: isHeadRequest,
2795
+ });
2796
+ return;
2797
+ }
2798
+
2799
+ if (req.method === 'POST' && pathname === '/v1/telemetry/ping') {
2800
+ const { FEEDBACK_DIR } = getFeedbackPaths();
2801
+ try {
2802
+ const payload = await parseJsonBody(req, 16 * 1024);
2803
+ appendTelemetryPing(FEEDBACK_DIR, payload, req.headers);
2804
+ } catch (err) {
2805
+ try {
2806
+ appendDiagnosticRecord({
2807
+ source: 'telemetry_ingest',
2808
+ step: 'telemetry_ingest',
2809
+ context: 'best-effort telemetry ingest failed',
2810
+ metadata: {
2811
+ path: pathname,
2812
+ method: req.method,
2813
+ reason: err && err.statusCode && err.statusCode < 500 ? 'invalid_payload' : 'write_failed',
2814
+ error: err && err.message ? err.message : 'unknown_error',
2815
+ },
2816
+ diagnosis: {
2817
+ diagnosed: true,
2818
+ rootCauseCategory: err && err.statusCode && err.statusCode < 500 ? 'invalid_invocation' : 'system_failure',
2819
+ criticalFailureStep: 'telemetry_ingest',
2820
+ violations: [{
2821
+ constraintId: 'telemetry:ingest',
2822
+ message: 'Telemetry ping could not be processed.',
2823
+ }],
2824
+ evidence: [err && err.message ? err.message : 'unknown_error'],
2825
+ },
2826
+ });
2827
+ } catch (_) {
2828
+ // Telemetry is best-effort and must never fail the caller.
2829
+ }
2830
+ }
2831
+ res.writeHead(204, {
2832
+ 'Access-Control-Allow-Origin': '*',
2833
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
2834
+ 'Access-Control-Allow-Headers': 'Content-Type',
2835
+ 'Content-Length': '0',
2836
+ });
2837
+ res.end();
2838
+ return;
2839
+ }
2840
+
2841
+ if (req.method === 'OPTIONS' && pathname === '/v1/telemetry/ping') {
2842
+ res.writeHead(204, {
2843
+ 'Access-Control-Allow-Origin': '*',
2844
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
2845
+ 'Access-Control-Allow-Headers': 'Content-Type',
2846
+ });
2847
+ res.end();
2848
+ return;
2849
+ }
2850
+
2851
+ if (req.method === 'GET' && pathname === '/v1/metrics/real') {
2852
+ const bd = require('../../scripts/bot-detector');
2853
+ const { FEEDBACK_DIR: metricsDir } = getFeedbackPaths();
2854
+ const telemetryPath = path.join(metricsDir, 'telemetry-pings.jsonl');
2855
+ let entries = [];
2856
+ try {
2857
+ if (fs.existsSync(telemetryPath)) {
2858
+ entries = fs.readFileSync(telemetryPath, 'utf8')
2859
+ .split('\n').filter(Boolean)
2860
+ .map(l => { try { return JSON.parse(l); } catch(_e) { return null; } })
2861
+ .filter(Boolean);
2862
+ }
2863
+ } catch (_) {}
2864
+
2865
+ const now = Date.now();
2866
+ const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000;
2867
+
2868
+ const classified = entries.map(e => {
2869
+ const cls = bd.classifyVisitor({ headers: { 'user-agent': e.userAgent || '' }, email: e.email || '' });
2870
+ return { ...e, visitorType: e.visitorType || cls.type };
2871
+ });
2872
+
2873
+ const recent = classified.filter(e => {
2874
+ const ts = e.timestamp || e.receivedAt;
2875
+ return ts && new Date(ts).getTime() > sevenDaysAgo;
2876
+ });
2877
+
2878
+ const uniqueInstallIds = new Set(classified.filter(e => e.installId).map(e => e.installId));
2879
+ const recentInstallIds = new Set(recent.filter(e => e.installId).map(e => e.installId));
2880
+
2881
+ const byEventType = {};
2882
+ classified.forEach(e => {
2883
+ const et = e.eventType || 'unknown';
2884
+ byEventType[et] = (byEventType[et] || 0) + 1;
2885
+ });
2886
+
2887
+ const byVisitorType = {};
2888
+ classified.forEach(e => {
2889
+ byVisitorType[e.visitorType] = (byVisitorType[e.visitorType] || 0) + 1;
2890
+ });
2891
+
2892
+ sendJson(res, 200, {
2893
+ allTime: {
2894
+ total: classified.length,
2895
+ real_users: classified.filter(e => e.visitorType === 'real_user').length,
2896
+ bots: classified.filter(e => e.visitorType === 'bot').length,
2897
+ owner: classified.filter(e => e.visitorType === 'owner').length,
2898
+ ci: classified.filter(e => e.visitorType === 'ci').length,
2899
+ uniqueInstalls: uniqueInstallIds.size,
2900
+ },
2901
+ last7Days: {
2902
+ total: recent.length,
2903
+ real_users: recent.filter(e => e.visitorType === 'real_user').length,
2904
+ bots: recent.filter(e => e.visitorType === 'bot').length,
2905
+ owner: recent.filter(e => e.visitorType === 'owner').length,
2906
+ ci: recent.filter(e => e.visitorType === 'ci').length,
2907
+ uniqueInstalls: recentInstallIds.size,
2908
+ },
2909
+ byEventType,
2910
+ byVisitorType,
2911
+ });
2912
+ return;
2913
+ }
2914
+
2915
+ if (req.method === 'OPTIONS' && pathname === '/v1/intake/workflow-sprint') {
2916
+ sendPublicBillingPreflight(res);
2917
+ return;
2918
+ }
2919
+
2920
+ if (req.method === 'POST' && pathname === '/v1/intake/workflow-sprint') {
2921
+ const { FEEDBACK_DIR } = getFeedbackPaths();
2922
+ const traceId = createTraceId('sprint_intake');
2923
+ const journeyState = resolveJourneyState(req, parsed);
2924
+ const referrerAttribution = buildReferrerAttribution(req);
2925
+ const contentType = String(req.headers['content-type'] || '').toLowerCase();
2926
+ const isJsonRequest = contentType.includes('application/json');
2927
+ const isFormSubmission = contentType.includes('application/x-www-form-urlencoded');
2928
+ try {
2929
+ const body = isFormSubmission
2930
+ ? await parseFormBody(req, 24 * 1024)
2931
+ : await parseJsonBody(req, 24 * 1024);
2932
+ const lead = appendWorkflowSprintLead({
2933
+ ...body,
2934
+ traceId: body.traceId || traceId,
2935
+ acquisitionId: body.acquisitionId || journeyState.acquisitionId,
2936
+ visitorId: body.visitorId || journeyState.visitorId,
2937
+ sessionId: body.sessionId || journeyState.sessionId,
2938
+ page: body.page || referrerAttribution.page || '/#workflow-sprint-intake',
2939
+ landingPath: body.landingPath || referrerAttribution.landingPath || '/',
2940
+ ctaId: body.ctaId || 'workflow_sprint_intake',
2941
+ ctaPlacement: body.ctaPlacement || 'workflow_sprint',
2942
+ planId: body.planId || 'sprint',
2943
+ source: body.source || body.utmSource || referrerAttribution.source || 'website',
2944
+ utmSource: body.utmSource || body.source || referrerAttribution.utmSource || 'website',
2945
+ utmMedium: body.utmMedium || referrerAttribution.utmMedium || 'workflow_sprint_intake',
2946
+ utmCampaign: body.utmCampaign || referrerAttribution.utmCampaign || 'workflow_hardening_sprint',
2947
+ utmContent: body.utmContent || referrerAttribution.utmContent || null,
2948
+ utmTerm: body.utmTerm || referrerAttribution.utmTerm || null,
2949
+ creator: body.creator || referrerAttribution.creator || null,
2950
+ community: body.community || referrerAttribution.community || null,
2951
+ postId: body.postId || referrerAttribution.postId || null,
2952
+ commentId: body.commentId || referrerAttribution.commentId || null,
2953
+ campaignVariant: body.campaignVariant || referrerAttribution.campaignVariant || null,
2954
+ offerCode: body.offerCode || referrerAttribution.offerCode || null,
2955
+ referrerHost: body.referrerHost || referrerAttribution.referrerHost || null,
2956
+ referrer: body.referrer || referrerAttribution.referrer || null,
2957
+ }, { feedbackDir: FEEDBACK_DIR });
2958
+
2959
+ appendBestEffortTelemetry(FEEDBACK_DIR, {
2960
+ eventType: 'workflow_sprint_lead_submitted',
2961
+ clientType: 'web',
2962
+ traceId: lead.attribution.traceId,
2963
+ acquisitionId: lead.attribution.acquisitionId,
2964
+ visitorId: lead.attribution.visitorId,
2965
+ sessionId: lead.attribution.sessionId,
2966
+ installId: lead.attribution.installId,
2967
+ source: lead.attribution.source,
2968
+ utmSource: lead.attribution.utmSource,
2969
+ utmMedium: lead.attribution.utmMedium,
2970
+ utmCampaign: lead.attribution.utmCampaign,
2971
+ utmContent: lead.attribution.utmContent,
2972
+ utmTerm: lead.attribution.utmTerm,
2973
+ creator: lead.attribution.creator,
2974
+ community: lead.attribution.community,
2975
+ postId: lead.attribution.postId,
2976
+ commentId: lead.attribution.commentId,
2977
+ campaignVariant: lead.attribution.campaignVariant,
2978
+ offerCode: lead.attribution.offerCode,
2979
+ ctaId: lead.attribution.ctaId,
2980
+ ctaPlacement: lead.attribution.ctaPlacement,
2981
+ planId: lead.attribution.planId,
2982
+ page: lead.attribution.page,
2983
+ landingPath: lead.attribution.landingPath,
2984
+ referrerHost: lead.attribution.referrerHost,
2985
+ referrer: lead.attribution.referrer,
2986
+ }, req.headers, 'workflow_sprint_lead_submitted');
2987
+
2988
+ if (isFormSubmission && !wantsJson(req, parsed)) {
2989
+ sendHtml(
2990
+ res,
2991
+ 201,
2992
+ renderWorkflowSprintIntakeResultPage(hostedConfig, {
2993
+ title: 'Workflow sprint intake received',
2994
+ detail: 'The workflow is now queued for review. Check the proof pack and sprint brief while we qualify the rollout blocker.',
2995
+ leadId: lead.leadId,
2996
+ }),
2997
+ journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}
2998
+ );
2999
+ return;
3000
+ }
3001
+
3002
+ sendJson(res, 201, {
3003
+ ok: true,
3004
+ leadId: lead.leadId,
3005
+ status: lead.status,
3006
+ offer: lead.offer,
3007
+ nextStep: 'review_proof_pack',
3008
+ proofPackUrl: 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md',
3009
+ sprintBriefUrl: 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/WORKFLOW_HARDENING_SPRINT.md',
3010
+ }, {
3011
+ ...getPublicBillingHeaders(lead.attribution.traceId),
3012
+ ...(journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}),
3013
+ });
3014
+ } catch (err) {
3015
+ appendBestEffortTelemetry(FEEDBACK_DIR, {
3016
+ eventType: 'workflow_sprint_lead_failed',
3017
+ clientType: 'web',
3018
+ traceId,
3019
+ acquisitionId: journeyState.acquisitionId,
3020
+ visitorId: journeyState.visitorId,
3021
+ sessionId: journeyState.sessionId,
3022
+ source: referrerAttribution.source || 'website',
3023
+ utmSource: referrerAttribution.utmSource || 'website',
3024
+ utmMedium: referrerAttribution.utmMedium || 'workflow_sprint_intake',
3025
+ utmCampaign: referrerAttribution.utmCampaign || 'workflow_hardening_sprint',
3026
+ creator: referrerAttribution.creator,
3027
+ community: referrerAttribution.community,
3028
+ postId: referrerAttribution.postId,
3029
+ commentId: referrerAttribution.commentId,
3030
+ campaignVariant: referrerAttribution.campaignVariant,
3031
+ offerCode: referrerAttribution.offerCode,
3032
+ ctaId: 'workflow_sprint_intake',
3033
+ ctaPlacement: 'workflow_sprint',
3034
+ planId: 'sprint',
3035
+ page: referrerAttribution.page || '/#workflow-sprint-intake',
3036
+ landingPath: referrerAttribution.landingPath || '/',
3037
+ referrerHost: referrerAttribution.referrerHost,
3038
+ referrer: referrerAttribution.referrer,
3039
+ failureCode: err && err.message ? err.message : 'workflow_sprint_lead_failed',
3040
+ httpStatus: err && err.statusCode ? err.statusCode : null,
3041
+ }, req.headers, 'workflow_sprint_lead_failed');
3042
+ if (isFormSubmission && !wantsJson(req, parsed)) {
3043
+ sendHtml(
3044
+ res,
3045
+ err.statusCode || 500,
3046
+ renderWorkflowSprintIntakeResultPage(hostedConfig, {
3047
+ title: 'Workflow sprint intake failed',
3048
+ detail: err.message || 'Unable to capture workflow sprint intake.',
3049
+ }),
3050
+ journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}
3051
+ );
3052
+ return;
3053
+ }
3054
+ sendProblem(res, {
3055
+ type: !err.statusCode || err.statusCode >= 500 ? PROBLEM_TYPES.INTERNAL : PROBLEM_TYPES.BAD_REQUEST,
3056
+ title: !err.statusCode || err.statusCode >= 500 ? 'Internal Server Error' : 'Request Error',
3057
+ status: err.statusCode || 500,
3058
+ detail: err.message || 'Unable to capture workflow sprint intake.',
3059
+ }, getPublicBillingHeaders(traceId));
3060
+ }
3061
+ return;
3062
+ }
3063
+
3064
+ // Public OpenAPI spec — no auth required (needed for ChatGPT GPT Store import)
3065
+ if (isGetLikeRequest && pathname === '/openapi.json') {
3066
+ const specPath = path.join(__dirname, '../../adapters/chatgpt/openapi.yaml');
3067
+ try {
3068
+ const yaml = fs.readFileSync(specPath, 'utf8');
3069
+ // Convert YAML to JSON inline (simple key:value conversion via js-yaml if available, else serve as-is)
3070
+ try {
3071
+ const jsYaml = require('js-yaml');
3072
+ const spec = jsYaml.load(yaml);
3073
+ // Override server URL to current deployment
3074
+ if (spec.servers && spec.servers[0]) {
3075
+ spec.servers[0].url = `${req.headers['x-forwarded-proto'] || 'https'}://${req.headers.host}`;
3076
+ }
3077
+ sendJson(res, 200, spec, {
3078
+ 'Access-Control-Allow-Origin': '*',
3079
+ }, {
3080
+ headOnly: isHeadRequest,
3081
+ });
3082
+ } catch {
3083
+ sendText(res, 200, yaml, {
3084
+ 'Content-Type': 'text/yaml; charset=utf-8',
3085
+ 'Access-Control-Allow-Origin': '*',
3086
+ }, {
3087
+ headOnly: isHeadRequest,
3088
+ });
3089
+ }
3090
+ } catch {
3091
+ sendProblem(res, {
3092
+ type: PROBLEM_TYPES.NOT_FOUND,
3093
+ title: 'Not Found',
3094
+ status: 404,
3095
+ detail: 'OpenAPI spec not found.',
3096
+ });
3097
+ }
3098
+ return;
3099
+ }
3100
+
3101
+ // Public privacy policy — required for GPT Store and marketplace listings
3102
+ if (isGetLikeRequest && pathname === '/privacy') {
3103
+ sendHtml(res, 200, `<!DOCTYPE html><html><head><title>Privacy Policy — ThumbGate</title></head><body>
3104
+ <h1>Privacy Policy</h1>
3105
+ <p><strong>ThumbGate</strong> (npm: thumbgate)</p>
3106
+ <p>Last updated: 2026-03-11</p>
3107
+ <h2>Data Collection</h2>
3108
+ <p>The self-hosted version stores workflow data locally on your machine. Local feedback, memory entries, proof artifacts, and context packs stay in your project files unless you explicitly point the system at a hosted endpoint.</p>
3109
+ <p>The hosted tier (thumbgate-production.up.railway.app) stores feedback signals, memory entries, and related workflow metadata associated with your API key.</p>
3110
+ <p>Optional CLI telemetry is best-effort and covers install or usage metadata needed to understand adoption and failures. You can disable it with <code>THUMBGATE_NO_TELEMETRY=1</code>.</p>
3111
+ <h2>Data Stored</h2><ul>
3112
+ <li>Feedback signals (thumbs up/down) with context you provide</li>
3113
+ <li>Promoted memory entries</li>
3114
+ <li>Prevention rules generated from your feedback</li>
3115
+ </ul>
3116
+ <h2>Data Sharing</h2>
3117
+ <p>We do not sell customer data. Hosted data is used to operate the service and is not shared with third parties except for infrastructure providers needed to run the product.</p>
3118
+ <h2>Data Retention</h2>
3119
+ <p>Local data is retained until you delete the files. Hosted data is retained while your account or API key remains active, or until you request deletion, subject to operational or legal retention requirements.</p>
3120
+ <h2>Data Deletion</h2>
3121
+ <p>Contact igor.ganapolsky@gmail.com to request deletion of hosted data.</p>
3122
+ <h2>Contact</h2><p>igor.ganapolsky@gmail.com</p>
3123
+ <p><a href="https://github.com/IgorGanapolsky/ThumbGate">GitHub</a></p>
3124
+ </body></html>`, {}, {
3125
+ headOnly: isHeadRequest,
3126
+ });
3127
+ return;
3128
+ }
3129
+
3130
+ // Stripe webhook is unauthenticated — uses HMAC signature verification instead
3131
+ if (req.method === 'POST' && pathname === '/v1/billing/webhook') {
3132
+ try {
3133
+ const rawBody = await new Promise((resolve, reject) => {
3134
+ const chunks = [];
3135
+ req.on('data', (c) => chunks.push(c));
3136
+ req.on('end', () => resolve(Buffer.concat(chunks)));
3137
+ req.on('error', reject);
3138
+ });
3139
+
3140
+ const sig = req.headers['stripe-signature'] || '';
3141
+ if (!verifyWebhookSignature(rawBody, sig)) {
3142
+ sendProblem(res, {
3143
+ type: PROBLEM_TYPES.WEBHOOK_INVALID,
3144
+ title: 'Invalid webhook signature',
3145
+ status: 400,
3146
+ detail: 'The webhook signature could not be verified.',
3147
+ });
3148
+ return;
3149
+ }
3150
+
3151
+ const result = await handleWebhook(rawBody, sig);
3152
+ if (result && result.reason === 'invalid_signature') {
3153
+ sendProblem(res, {
3154
+ type: PROBLEM_TYPES.WEBHOOK_INVALID,
3155
+ title: 'Invalid webhook signature',
3156
+ status: 400,
3157
+ detail: result.error || 'The webhook signature could not be verified.',
3158
+ });
3159
+ return;
3160
+ }
3161
+ sendJson(res, 200, result);
3162
+
3163
+ } catch (err) {
3164
+ sendProblem(res, {
3165
+ type: !err.statusCode || err.statusCode >= 500 ? PROBLEM_TYPES.INTERNAL : PROBLEM_TYPES.BAD_REQUEST,
3166
+ title: !err.statusCode || err.statusCode >= 500 ? 'Internal Server Error' : 'Request Error',
3167
+ status: err.statusCode || 500,
3168
+ detail: err.message,
3169
+ });
3170
+ }
3171
+ return;
3172
+ }
3173
+
3174
+ // GitHub Marketplace webhook
3175
+ if (req.method === 'POST' && pathname === '/v1/billing/github-webhook') {
3176
+ try {
3177
+ const rawBody = await new Promise((resolve, reject) => {
3178
+ const chunks = [];
3179
+ req.on('data', (c) => chunks.push(c));
3180
+ req.on('end', () => resolve(Buffer.concat(chunks)));
3181
+ req.on('error', reject);
3182
+ });
3183
+
3184
+ const sig = req.headers['x-hub-signature-256'] || '';
3185
+ if (!verifyGithubWebhookSignature(rawBody, sig)) {
3186
+ sendProblem(res, {
3187
+ type: PROBLEM_TYPES.WEBHOOK_INVALID,
3188
+ title: 'Invalid webhook signature',
3189
+ status: 400,
3190
+ detail: 'The webhook signature could not be verified.',
3191
+ });
3192
+ return;
3193
+ }
3194
+
3195
+ let event;
3196
+ try {
3197
+ event = JSON.parse(rawBody.toString('utf-8'));
3198
+ } catch {
3199
+ sendProblem(res, {
3200
+ type: PROBLEM_TYPES.INVALID_JSON,
3201
+ title: 'Invalid JSON',
3202
+ status: 400,
3203
+ detail: 'Invalid JSON in webhook body.',
3204
+ });
3205
+ return;
3206
+ }
3207
+
3208
+ const result = handleGithubWebhook(event);
3209
+ sendJson(res, 200, result);
3210
+ } catch (err) {
3211
+ sendProblem(res, {
3212
+ type: !err.statusCode || err.statusCode >= 500 ? PROBLEM_TYPES.INTERNAL : PROBLEM_TYPES.BAD_REQUEST,
3213
+ title: !err.statusCode || err.statusCode >= 500 ? 'Internal Server Error' : 'Request Error',
3214
+ status: err.statusCode || 500,
3215
+ detail: err.message,
3216
+ });
3217
+ }
3218
+ return;
3219
+ }
3220
+
3221
+ if (req.method === 'OPTIONS' && (pathname === '/v1/billing/checkout' || pathname === '/v1/billing/session')) {
3222
+ sendPublicBillingPreflight(res);
3223
+ return;
3224
+ }
3225
+
3226
+ // Public checkout session creation for top-of-funnel acquisition.
3227
+ if (req.method === 'POST' && pathname === '/v1/billing/checkout') {
3228
+ try {
3229
+ const body = await parseJsonBody(req);
3230
+ const traceId = body.traceId || createTraceId('checkout');
3231
+ const responseHeaders = getPublicBillingHeaders(traceId);
3232
+ const analyticsMetadata = buildCheckoutAttributionMetadata(body, req, traceId);
3233
+ const offerSummary = resolveCheckoutOfferSummary(analyticsMetadata);
3234
+
3235
+ const result = await createCheckoutSession({
3236
+ successUrl: body.successUrl || buildCheckoutFallbackUrl(
3237
+ buildHostedSuccessUrl(hostedConfig.appOrigin, traceId),
3238
+ analyticsMetadata,
3239
+ ),
3240
+ cancelUrl: body.cancelUrl || buildCheckoutFallbackUrl(
3241
+ buildHostedCancelUrl(hostedConfig.appOrigin, traceId),
3242
+ analyticsMetadata,
3243
+ ),
3244
+ customerEmail: body.customerEmail,
3245
+ installId: body.installId,
3246
+ traceId,
3247
+ metadata: analyticsMetadata,
3248
+ });
3249
+ sendJson(res, 200, {
3250
+ ...result,
3251
+ traceId: result.traceId || traceId,
3252
+ planId: offerSummary.planId,
3253
+ billingCycle: offerSummary.billingCycle,
3254
+ seatCount: offerSummary.seatCount,
3255
+ price: offerSummary.price,
3256
+ priceLabel: offerSummary.priceLabel,
3257
+ type: offerSummary.type,
3258
+ }, responseHeaders);
3259
+ } catch (err) {
3260
+ const fallbackTraceId = createTraceId('checkout_error');
3261
+ sendProblem(res, {
3262
+ type: !err.statusCode || err.statusCode >= 500 ? PROBLEM_TYPES.INTERNAL : PROBLEM_TYPES.BAD_REQUEST,
3263
+ title: !err.statusCode || err.statusCode >= 500 ? 'Internal Server Error' : 'Request Error',
3264
+ status: err.statusCode || 500,
3265
+ detail: err.message || 'An unexpected error occurred.',
3266
+ }, getPublicBillingHeaders(fallbackTraceId));
3267
+ }
3268
+ return;
3269
+ }
3270
+
3271
+ if (req.method === 'GET' && pathname === '/v1/billing/session') {
3272
+ try {
3273
+ const sessionId = parsed.searchParams.get('sessionId');
3274
+ const requestedTraceId = parsed.searchParams.get('traceId') || '';
3275
+ if (!sessionId) {
3276
+ throw createHttpError(400, 'sessionId is required');
3277
+ }
3278
+
3279
+ const result = await getCheckoutSessionStatus(sessionId);
3280
+ if (!result.found) {
3281
+ throw createHttpError(404, 'Checkout session not found');
3282
+ }
3283
+
3284
+ const resolvedTraceId = result.traceId || requestedTraceId;
3285
+
3286
+ sendJson(res, 200, {
3287
+ ...result,
3288
+ traceId: resolvedTraceId || null,
3289
+ appOrigin: hostedConfig.appOrigin,
3290
+ apiBaseUrl: hostedConfig.billingApiBaseUrl,
3291
+ nextSteps: {
3292
+ env: `THUMBGATE_API_KEY=${result.apiKey || ''}\nTHUMBGATE_API_BASE_URL=${hostedConfig.billingApiBaseUrl}`,
3293
+ curl: `curl -X POST ${hostedConfig.billingApiBaseUrl}/v1/feedback/capture \\\n -H 'Authorization: Bearer ${result.apiKey || ''}' \\\n -H 'Content-Type: application/json' \\\n -d '{"signal":"down","context":"example","whatWentWrong":"example","whatToChange":"example"}'`,
3294
+ },
3295
+ }, getPublicBillingHeaders(resolvedTraceId));
3296
+ } catch (err) {
3297
+ const requestedTraceId = parsed.searchParams.get('traceId') || '';
3298
+ sendProblem(res, {
3299
+ type: !err.statusCode || err.statusCode >= 500 ? PROBLEM_TYPES.INTERNAL : PROBLEM_TYPES.BAD_REQUEST,
3300
+ title: !err.statusCode || err.statusCode >= 500 ? 'Internal Server Error' : 'Request Error',
3301
+ status: err.statusCode || 500,
3302
+ detail: err.message || 'An unexpected error occurred.',
3303
+ }, getPublicBillingHeaders(requestedTraceId));
3304
+ }
3305
+ return;
3306
+ }
3307
+
3308
+ if (!isAuthorized(req, expectedApiKey)) {
3309
+ sendProblem(res, {
3310
+ type: PROBLEM_TYPES.UNAUTHORIZED,
3311
+ title: 'Unauthorized',
3312
+ status: 401,
3313
+ detail: 'A valid API key is required to access this endpoint.',
3314
+ });
3315
+ return;
3316
+ }
3317
+
3318
+ // Usage metering — record request for billing keys (not static THUMBGATE_API_KEY)
3319
+ const _token = extractBearerToken(req);
3320
+ if (_token && _token !== expectedApiKey) {
3321
+ recordUsage(_token);
3322
+ }
3323
+
3324
+ try {
3325
+ if (req.method === 'GET' && pathname === '/v1/feedback/stats') {
3326
+ sendJson(res, 200, analyzeFeedback());
3327
+ return;
3328
+ }
3329
+
3330
+ if (req.method === 'GET' && pathname === '/v1/intents/catalog') {
3331
+ const mcpProfile = parsed.searchParams.get('mcpProfile') || undefined;
3332
+ const bundleId = parsed.searchParams.get('bundleId') || undefined;
3333
+ const partnerProfile = parsed.searchParams.get('partnerProfile') || undefined;
3334
+ try {
3335
+ const catalog = listIntents({ mcpProfile, bundleId, partnerProfile });
3336
+ sendJson(res, 200, catalog);
3337
+ } catch (err) {
3338
+ throw createHttpError(400, err.message || 'Invalid intent catalog request');
3339
+ }
3340
+ return;
3341
+ }
3342
+
3343
+ if (req.method === 'POST' && pathname === '/v1/intents/plan') {
3344
+ const body = await parseJsonBody(req);
3345
+ try {
3346
+ const plan = planIntent({
3347
+ intentId: body.intentId,
3348
+ context: body.context || '',
3349
+ mcpProfile: body.mcpProfile,
3350
+ bundleId: body.bundleId,
3351
+ partnerProfile: body.partnerProfile,
3352
+ delegationMode: body.delegationMode,
3353
+ approved: body.approved === true,
3354
+ repoPath: body.repoPath,
3355
+ });
3356
+ sendJson(res, 200, plan);
3357
+ } catch (err) {
3358
+ throw createHttpError(400, err.message || 'Invalid intent plan request');
3359
+ }
3360
+ return;
3361
+ }
3362
+
3363
+ if (req.method === 'POST' && pathname === '/v1/handoffs/start') {
3364
+ const body = await parseJsonBody(req);
3365
+ try {
3366
+ const plan = planIntent({
3367
+ intentId: body.intentId,
3368
+ context: body.context || '',
3369
+ mcpProfile: body.mcpProfile,
3370
+ bundleId: body.bundleId,
3371
+ partnerProfile: body.partnerProfile,
3372
+ delegationMode: 'sequential',
3373
+ approved: body.approved === true,
3374
+ repoPath: body.repoPath,
3375
+ });
3376
+ const result = startHandoff({
3377
+ plan,
3378
+ context: body.context || '',
3379
+ mcpProfile: body.mcpProfile || plan.mcpProfile,
3380
+ partnerProfile: body.partnerProfile || plan.partnerProfile,
3381
+ repoPath: body.repoPath,
3382
+ delegateProfile: body.delegateProfile || null,
3383
+ plannedChecks: Array.isArray(body.plannedChecks) ? body.plannedChecks : [],
3384
+ });
3385
+ sendJson(res, 200, result);
3386
+ } catch (err) {
3387
+ throw createHttpError(err.statusCode || 400, err.message || 'Invalid handoff start request');
3388
+ }
3389
+ return;
3390
+ }
3391
+
3392
+ if (req.method === 'POST' && pathname === '/v1/handoffs/complete') {
3393
+ const body = await parseJsonBody(req);
3394
+ try {
3395
+ const result = completeHandoff({
3396
+ handoffId: body.handoffId,
3397
+ outcome: body.outcome,
3398
+ resultContext: body.resultContext || '',
3399
+ attempts: body.attempts,
3400
+ violationCount: body.violationCount,
3401
+ tokenEstimate: body.tokenEstimate,
3402
+ latencyMs: body.latencyMs,
3403
+ summary: body.summary || '',
3404
+ });
3405
+ sendJson(res, 200, result);
3406
+ } catch (err) {
3407
+ throw createHttpError(err.statusCode || 400, err.message || 'Invalid handoff completion request');
3408
+ }
3409
+ return;
3410
+ }
3411
+
3412
+ if (req.method === 'POST' && pathname === '/v1/internal-agent/bootstrap') {
3413
+ const body = await parseJsonBody(req);
3414
+ try {
3415
+ const result = bootstrapInternalAgent({
3416
+ source: body.source,
3417
+ repoPath: body.repoPath,
3418
+ prepareSandbox: body.prepareSandbox,
3419
+ sandboxRoot: body.sandboxRoot,
3420
+ intentId: body.intentId,
3421
+ context: body.context,
3422
+ mcpProfile: body.mcpProfile,
3423
+ partnerProfile: body.partnerProfile,
3424
+ delegationMode: body.delegationMode,
3425
+ approved: body.approved === true,
3426
+ trigger: body.trigger,
3427
+ thread: body.thread,
3428
+ task: body.task,
3429
+ comments: body.comments,
3430
+ messages: body.messages,
3431
+ });
3432
+ sendJson(res, 200, result);
3433
+ } catch (err) {
3434
+ throw createHttpError(err.statusCode || 400, err.message || 'Invalid internal agent bootstrap request');
3435
+ }
3436
+ return;
3437
+ }
3438
+
3439
+ if (req.method === 'POST' && pathname === '/v1/hosted/sandbox/dispatch') {
3440
+ const body = await parseJsonBody(req);
3441
+ try {
3442
+ const result = buildCloudflareSandboxPlan({
3443
+ source: body.source,
3444
+ workloadType: body.workloadType || body.taskType,
3445
+ tier: body.tier,
3446
+ tenantId: body.tenantId || body.teamId,
3447
+ repoPath: body.repoPath,
3448
+ requiresRepoAccess: body.requiresRepoAccess,
3449
+ requiresIsolation: body.requiresIsolation,
3450
+ requiresNetwork: body.requiresNetwork,
3451
+ untrustedCode: body.untrustedCode,
3452
+ allowedHosts: body.allowedHosts,
3453
+ contextTokens: body.contextTokens,
3454
+ traceId: body.traceId,
3455
+ context: body.context,
3456
+ intentId: body.intentId,
3457
+ mcpProfile: body.mcpProfile,
3458
+ partnerProfile: body.partnerProfile,
3459
+ delegationMode: body.delegationMode,
3460
+ approved: body.approved === true,
3461
+ trigger: body.trigger,
3462
+ thread: body.thread,
3463
+ task: body.task,
3464
+ comments: body.comments,
3465
+ messages: body.messages,
3466
+ providerPreference: body.providerPreference,
3467
+ }, {
3468
+ sharedSecret: process.env.CLOUDFLARE_SANDBOX_SHARED_SECRET,
3469
+ includeBootstrap: body.includeBootstrap !== false,
3470
+ });
3471
+ sendJson(res, 200, result);
3472
+ } catch (err) {
3473
+ throw createHttpError(err.statusCode || 400, err.message || 'Invalid hosted sandbox dispatch request');
3474
+ }
3475
+ return;
3476
+ }
3477
+
3478
+ if (req.method === 'POST' && pathname === '/v1/gates/constraint') {
3479
+ const body = await parseJsonBody(req);
3480
+ if (!body.key || body.value === undefined) {
3481
+ throw createHttpError(400, 'Missing key or value');
3482
+ }
3483
+ const result = setConstraint(body.key, body.value);
3484
+ sendJson(res, 200, result);
3485
+ return;
3486
+ }
3487
+
3488
+ if (req.method === 'GET' && pathname === '/v1/gates/constraints') {
3489
+ sendJson(res, 200, loadConstraints());
3490
+ return;
3491
+ }
3492
+
3493
+ if (req.method === 'GET' && pathname === '/v1/feedback/summary') {
3494
+ const recent = Number(parsed.searchParams.get('recent') || 20);
3495
+ const summary = feedbackSummary(Number.isFinite(recent) ? recent : 20);
3496
+ sendJson(res, 200, { summary });
3497
+ return;
3498
+ }
3499
+
3500
+ if (req.method === 'GET' && pathname === '/v1/lessons/search') {
3501
+ const query = parsed.searchParams.get('q') || parsed.searchParams.get('query') || '';
3502
+ const limit = Number(parsed.searchParams.get('limit') || 10);
3503
+ const category = parsed.searchParams.get('category') || '';
3504
+ const tags = (parsed.searchParams.get('tags') || '')
3505
+ .split(',')
3506
+ .map((tag) => tag.trim())
3507
+ .filter(Boolean);
3508
+ const results = searchLessons(query, {
3509
+ limit: Number.isFinite(limit) ? limit : 10,
3510
+ category,
3511
+ tags,
3512
+ });
3513
+ sendJson(res, 200, results);
3514
+ return;
3515
+ }
3516
+
3517
+ if (req.method === 'GET' && pathname === '/v1/search') {
3518
+ const query = parsed.searchParams.get('q') || parsed.searchParams.get('query') || '';
3519
+ const limit = Number(parsed.searchParams.get('limit') || 10);
3520
+ const source = parsed.searchParams.get('source') || 'all';
3521
+ const signal = parsed.searchParams.get('signal') || null;
3522
+ let results;
3523
+ try {
3524
+ results = searchThumbgate({
3525
+ query,
3526
+ limit: Number.isFinite(limit) ? limit : 10,
3527
+ source,
3528
+ signal,
3529
+ });
3530
+ } catch (err) {
3531
+ throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
3532
+ }
3533
+ sendJson(res, 200, results);
3534
+ return;
3535
+ }
3536
+
3537
+ if (req.method === 'POST' && pathname === '/v1/search') {
3538
+ const body = await parseJsonBody(req);
3539
+ let results;
3540
+ try {
3541
+ results = searchThumbgate({
3542
+ query: body.query || body.q || '',
3543
+ limit: body.limit,
3544
+ source: body.source,
3545
+ signal: body.signal,
3546
+ });
3547
+ } catch (err) {
3548
+ throw createHttpError(400, err.message || 'Invalid ThumbGate search request');
3549
+ }
3550
+ sendJson(res, 200, results);
3551
+ return;
3552
+ }
3553
+
3554
+ if (req.method === 'POST' && pathname === '/v1/feedback/capture') {
3555
+ const captureLimit = checkLimit('capture_feedback');
3556
+ if (!captureLimit.allowed) {
3557
+ sendProblem(res, {
3558
+ type: PROBLEM_TYPES.RATE_LIMIT,
3559
+ title: 'Free tier limit reached',
3560
+ status: 429,
3561
+ detail: RATE_LIMIT_MESSAGE,
3562
+ });
3563
+ return;
3564
+ }
3565
+ const body = await parseJsonBody(req);
3566
+ const result = captureFeedback({
3567
+ signal: body.signal,
3568
+ context: body.context || '',
3569
+ relatedFeedbackId: body.relatedFeedbackId,
3570
+ chatHistory: Array.isArray(body.chatHistory) ? body.chatHistory : body.messages,
3571
+ whatWentWrong: body.whatWentWrong,
3572
+ whatToChange: body.whatToChange,
3573
+ whatWorked: body.whatWorked,
3574
+ reasoning: body.reasoning,
3575
+ visualEvidence: body.visualEvidence,
3576
+ packId: body.packId,
3577
+ utilityScore: body.utilityScore,
3578
+ rubricScores: body.rubricScores,
3579
+ guardrails: body.guardrails,
3580
+ tags: extractTags(body.tags),
3581
+ skill: body.skill,
3582
+ });
3583
+ const code = result.accepted ? 200 : 422;
3584
+ sendJson(res, code, result);
3585
+ return;
3586
+ }
3587
+
3588
+ if (req.method === 'POST' && pathname === '/v1/feedback/rules') {
3589
+ const body = await parseJsonBody(req);
3590
+ const minOccurrences = Number(body.minOccurrences || 2);
3591
+ const outputPath = body.outputPath ? resolveSafePath(body.outputPath) : undefined;
3592
+ const result = writePreventionRules(outputPath, Number.isFinite(minOccurrences) ? minOccurrences : 2);
3593
+ sendJson(res, 200, {
3594
+ path: result.path,
3595
+ markdown: result.markdown,
3596
+ });
3597
+ return;
3598
+ }
3599
+
3600
+ if (req.method === 'POST' && pathname === '/v1/skills/generate') {
3601
+ const body = await parseJsonBody(req);
3602
+ const minOccurrences = Number(body.minOccurrences || 3);
3603
+ const tags = Array.isArray(body.tags) ? body.tags : [];
3604
+ let skills = generateSkills({
3605
+ minClusterSize: Number.isFinite(minOccurrences) ? minOccurrences : 3,
3606
+ });
3607
+ if (tags.length > 0) {
3608
+ const tagSet = new Set(tags.map(t => t.toLowerCase()));
3609
+ skills = skills.filter(s => (s.tags || []).some(t => tagSet.has(t.toLowerCase())));
3610
+ }
3611
+ sendJson(res, 200, { skills });
3612
+ return;
3613
+ }
3614
+
3615
+ if (req.method === 'POST' && pathname === '/v1/dpo/export') {
3616
+ const body = await parseJsonBody(req);
3617
+ let memories = [];
3618
+
3619
+ if (body.inputPath) {
3620
+ const safeInputPath = resolveSafePath(body.inputPath, { mustExist: true });
3621
+ const raw = fs.readFileSync(safeInputPath, 'utf-8');
3622
+ const parsedMemories = JSON.parse(raw);
3623
+ memories = Array.isArray(parsedMemories) ? parsedMemories : parsedMemories.memories || [];
3624
+ } else {
3625
+ const localPath = body.memoryLogPath
3626
+ ? resolveSafePath(body.memoryLogPath, { mustExist: true })
3627
+ : DEFAULT_LOCAL_MEMORY_LOG;
3628
+ memories = readJSONL(localPath);
3629
+ }
3630
+
3631
+ const result = exportDpoFromMemories(memories);
3632
+ if (body.outputPath) {
3633
+ const safeOutputPath = resolveSafePath(body.outputPath);
3634
+ fs.mkdirSync(path.dirname(safeOutputPath), { recursive: true });
3635
+ fs.writeFileSync(safeOutputPath, result.jsonl);
3636
+ }
3637
+
3638
+ sendJson(res, 200, {
3639
+ pairs: result.pairs.length,
3640
+ errors: result.errors.length,
3641
+ learnings: result.learnings.length,
3642
+ unpairedErrors: result.unpairedErrors.length,
3643
+ unpairedLearnings: result.unpairedLearnings.length,
3644
+ outputPath: body.outputPath ? resolveSafePath(body.outputPath) : null,
3645
+ });
3646
+ return;
3647
+ }
3648
+
3649
+ if (req.method === 'POST' && pathname === '/v1/analytics/databricks/export') {
3650
+ const body = await parseJsonBody(req);
3651
+ const outputPath = body.outputPath ? resolveSafePath(body.outputPath) : undefined;
3652
+ const result = exportDatabricksBundle(undefined, outputPath);
3653
+ sendJson(res, 200, result);
3654
+ return;
3655
+ }
3656
+
3657
+ if (req.method === 'POST' && pathname === '/v1/context/construct') {
3658
+ const body = await parseJsonBody(req);
3659
+ ensureContextFs();
3660
+ let namespaces = [];
3661
+ try {
3662
+ namespaces = normalizeNamespaces(Array.isArray(body.namespaces) ? body.namespaces : []);
3663
+ } catch (err) {
3664
+ throw createHttpError(400, err.message || 'Invalid namespaces');
3665
+ }
3666
+ const pack = constructContextPack({
3667
+ query: body.query || '',
3668
+ maxItems: Number(body.maxItems || 8),
3669
+ maxChars: Number(body.maxChars || 6000),
3670
+ namespaces,
3671
+ });
3672
+ sendJson(res, 200, pack);
3673
+ return;
3674
+ }
3675
+
3676
+ if (req.method === 'POST' && pathname === '/v1/context/evaluate') {
3677
+ const body = await parseJsonBody(req);
3678
+ if (!body.packId || !body.outcome) {
3679
+ throw createHttpError(400, 'packId and outcome are required');
3680
+ }
3681
+ let rubricEvaluation = null;
3682
+ if (body.rubricScores != null || body.guardrails != null) {
3683
+ try {
3684
+ rubricEvaluation = buildRubricEvaluation({
3685
+ rubricScores: body.rubricScores,
3686
+ guardrails: parseOptionalObject(body.guardrails, 'guardrails'),
3687
+ });
3688
+ } catch (err) {
3689
+ throw createHttpError(400, `Invalid rubric payload: ${err.message}`);
3690
+ }
3691
+ }
3692
+ const evaluation = evaluateContextPack({
3693
+ packId: body.packId,
3694
+ outcome: body.outcome,
3695
+ signal: body.signal || null,
3696
+ notes: body.notes || '',
3697
+ rubricEvaluation,
3698
+ });
3699
+ sendJson(res, 200, evaluation);
3700
+ return;
3701
+ }
3702
+
3703
+ if (req.method === 'GET' && pathname === '/v1/context/provenance') {
3704
+ const limit = Number(parsed.searchParams.get('limit') || 50);
3705
+ const events = getProvenance(Number.isFinite(limit) ? limit : 50);
3706
+ sendJson(res, 200, { events });
3707
+ return;
3708
+ }
3709
+
3710
+
3711
+ // ----------------------------------------------------------------
3712
+ // Quality / ACO routes
3713
+ // ----------------------------------------------------------------
3714
+
3715
+ if (req.method === 'GET' && pathname === '/v1/quality/scores') {
3716
+ const modelPath = path.join(getSafeDataDir(), 'feedback_model.json');
3717
+ const model = loadModel(modelPath);
3718
+ const reliability = getReliability(model);
3719
+ const category = parsed.searchParams.get('category');
3720
+ if (category) {
3721
+ if (!reliability[category]) {
3722
+ throw createHttpError(404, `Category '${category}' not found`);
3723
+ }
3724
+ sendJson(res, 200, { category, ...reliability[category] });
3725
+ return;
3726
+ }
3727
+ sendJson(res, 200, {
3728
+ categories: reliability,
3729
+ totalEntries: model.total_entries || 0,
3730
+ updated: model.updated || null,
3731
+ });
3732
+ return;
3733
+ }
3734
+
3735
+ if (req.method === 'GET' && pathname === '/v1/quality/rules') {
3736
+ const rulesPath = path.join(getSafeDataDir(), 'prevention-rules.md');
3737
+ let markdown = '';
3738
+ if (fs.existsSync(rulesPath)) {
3739
+ markdown = fs.readFileSync(rulesPath, 'utf8').trim();
3740
+ }
3741
+ const rules = [];
3742
+ for (const line of markdown.split('\n')) {
3743
+ const match = line.match(/^-\s+\*\*(\w+)\*\*.*?:\s*(.+)/);
3744
+ if (match) {
3745
+ rules.push({ severity: match[1].toLowerCase(), rule: match[2].trim() });
3746
+ }
3747
+ }
3748
+ sendJson(res, 200, { count: rules.length, rules, markdown });
3749
+ return;
3750
+ }
3751
+
3752
+ if (req.method === 'GET' && pathname === '/v1/quality/posteriors') {
3753
+ const modelPath = path.join(getSafeDataDir(), 'feedback_model.json');
3754
+ const model = loadModel(modelPath);
3755
+ const posteriors = samplePosteriors(model);
3756
+ sendJson(res, 200, { posteriors });
3757
+ return;
3758
+ }
3759
+
3760
+ // ----------------------------------------------------------------
3761
+ // Semantic routes
3762
+ // ----------------------------------------------------------------
3763
+
3764
+ // GET /v1/semantic/describe — get canonical definition of a business entity
3765
+ if (req.method === 'GET' && pathname === '/v1/semantic/describe') {
3766
+ const { describeSemanticSchema } = require('../../scripts/semantic-layer');
3767
+ const type = parsed.query.type;
3768
+ if (!type) {
3769
+ throw createHttpError(400, 'type query parameter is required');
3770
+ }
3771
+ const schema = describeSemanticSchema();
3772
+ const entity = schema.entities[type] || schema.metrics[type];
3773
+ if (!entity) {
3774
+ sendProblem(res, {
3775
+ type: PROBLEM_TYPES.NOT_FOUND,
3776
+ title: 'Entity Not Found',
3777
+ status: 404,
3778
+ detail: `Semantic entity or metric "${type}" not found in schema.`,
3779
+ });
3780
+ return;
3781
+ }
3782
+ sendJson(res, 200, entity);
3783
+ return;
3784
+ }
3785
+
3786
+ // ----------------------------------------------------------------
3787
+ // Billing routes
3788
+ // ----------------------------------------------------------------
3789
+
3790
+ // GET /v1/billing/usage — usage for the authenticated key
3791
+ if (req.method === 'GET' && pathname === '/v1/billing/usage') {
3792
+ const token = extractBearerToken(req);
3793
+ const validation = validateApiKey(token);
3794
+ if (!validation.valid) {
3795
+ sendProblem(res, {
3796
+ type: PROBLEM_TYPES.UNAUTHORIZED,
3797
+ title: 'Unauthorized',
3798
+ status: 401,
3799
+ detail: 'A valid API key is required to access this endpoint.',
3800
+ });
3801
+ return;
3802
+ }
3803
+ sendJson(res, 200, {
3804
+ key: token,
3805
+ customerId: validation.customerId,
3806
+ usageCount: validation.usageCount,
3807
+ });
3808
+ return;
3809
+ }
3810
+
3811
+ // POST /v1/billing/provision — manually provision key (admin)
3812
+ if (req.method === 'POST' && pathname === '/v1/billing/provision') {
3813
+ if (!isStaticAdminAuthorized(req, expectedApiKey)) {
3814
+ sendProblem(res, {
3815
+ type: PROBLEM_TYPES.FORBIDDEN,
3816
+ title: 'Forbidden',
3817
+ status: 403,
3818
+ detail: 'Admin API key required for this endpoint.',
3819
+ });
3820
+ return;
3821
+ }
3822
+
3823
+ const body = await parseJsonBody(req);
3824
+ if (!body.customerId) {
3825
+ throw createHttpError(400, 'customerId is required');
3826
+ }
3827
+ const result = provisionApiKey(body.customerId, {
3828
+ installId: body.installId,
3829
+ source: 'admin_provision',
3830
+ });
3831
+ sendJson(res, 200, result);
3832
+ return;
3833
+ }
3834
+
3835
+ // GET /v1/billing/summary — admin-only operational billing summary
3836
+ if (req.method === 'GET' && pathname === '/v1/billing/summary') {
3837
+ if (!isStaticAdminAuthorized(req, expectedApiKey)) {
3838
+ sendProblem(res, {
3839
+ type: PROBLEM_TYPES.FORBIDDEN,
3840
+ title: 'Forbidden',
3841
+ status: 403,
3842
+ detail: 'Admin API key required for this endpoint.',
3843
+ });
3844
+ return;
3845
+ }
3846
+
3847
+ let summaryOptions;
3848
+ try {
3849
+ summaryOptions = resolveBillingSummaryOptions(parsed);
3850
+ } catch (err) {
3851
+ sendProblem(res, {
3852
+ type: PROBLEM_TYPES.INVALID_REQUEST,
3853
+ title: 'Invalid billing summary query',
3854
+ status: 400,
3855
+ detail: err && err.message ? err.message : 'Invalid analytics window request.',
3856
+ });
3857
+ return;
3858
+ }
3859
+
3860
+ const summary = await getBillingSummaryLive(summaryOptions);
3861
+ sendJson(res, 200, summary);
3862
+ return;
3863
+ }
3864
+
3865
+ // POST /v1/intake/workflow-sprint/advance — admin-only workflow sprint progression
3866
+ if (req.method === 'POST' && pathname === '/v1/intake/workflow-sprint/advance') {
3867
+ if (!isStaticAdminAuthorized(req, expectedApiKey)) {
3868
+ sendProblem(res, {
3869
+ type: PROBLEM_TYPES.FORBIDDEN,
3870
+ title: 'Forbidden',
3871
+ status: 403,
3872
+ detail: 'Admin API key required for this endpoint.',
3873
+ });
3874
+ return;
3875
+ }
3876
+
3877
+ const { FEEDBACK_DIR } = getFeedbackPaths();
3878
+ try {
3879
+ const body = await parseJsonBody(req, 24 * 1024);
3880
+ const result = advanceWorkflowSprintLead(body, { feedbackDir: FEEDBACK_DIR });
3881
+
3882
+ appendBestEffortTelemetry(FEEDBACK_DIR, {
3883
+ eventType: 'workflow_sprint_lead_advanced',
3884
+ clientType: 'server',
3885
+ traceId: result.lead.attribution.traceId,
3886
+ acquisitionId: result.lead.attribution.acquisitionId,
3887
+ visitorId: result.lead.attribution.visitorId,
3888
+ sessionId: result.lead.attribution.sessionId,
3889
+ installId: result.lead.attribution.installId,
3890
+ source: result.lead.attribution.source,
3891
+ utmSource: result.lead.attribution.utmSource,
3892
+ utmMedium: result.lead.attribution.utmMedium,
3893
+ utmCampaign: result.lead.attribution.utmCampaign,
3894
+ creator: result.lead.attribution.creator,
3895
+ community: result.lead.attribution.community,
3896
+ ctaId: result.lead.attribution.ctaId,
3897
+ ctaPlacement: result.lead.attribution.ctaPlacement,
3898
+ planId: result.lead.attribution.planId,
3899
+ page: result.lead.attribution.page,
3900
+ landingPath: result.lead.attribution.landingPath,
3901
+ pipelineStatus: result.lead.status,
3902
+ workflowRunKey: result.workflowRun ? result.workflowRun.workflowRunKey : null,
3903
+ }, req.headers, 'workflow_sprint_lead_advanced');
3904
+
3905
+ sendJson(res, 200, {
3906
+ ok: true,
3907
+ unchanged: result.unchanged,
3908
+ lead: result.lead,
3909
+ workflowRun: result.workflowRun,
3910
+ });
3911
+ } catch (err) {
3912
+ sendProblem(res, {
3913
+ type: err && err.statusCode === 404 ? PROBLEM_TYPES.NOT_FOUND : PROBLEM_TYPES.BAD_REQUEST,
3914
+ title: err && err.statusCode === 404 ? 'Lead Not Found' : 'Request Error',
3915
+ status: err && err.statusCode ? err.statusCode : 400,
3916
+ detail: err && err.message ? err.message : 'Unable to advance workflow sprint lead.',
3917
+ });
3918
+ }
3919
+ return;
3920
+ }
3921
+
3922
+ // POST /v1/billing/rotate-key — rotate the authenticated key, preserving customer access
3923
+ if (req.method === 'POST' && pathname === '/v1/billing/rotate-key') {
3924
+ const currentKey = extractBearerToken(req);
3925
+ if (!currentKey) {
3926
+ sendProblem(res, {
3927
+ type: PROBLEM_TYPES.UNAUTHORIZED,
3928
+ title: 'Unauthorized',
3929
+ status: 401,
3930
+ detail: 'A valid API key is required to access this endpoint.',
3931
+ });
3932
+ return;
3933
+ }
3934
+ const validation = validateApiKey(currentKey);
3935
+ if (!validation.valid) {
3936
+ sendProblem(res, {
3937
+ type: PROBLEM_TYPES.BAD_REQUEST,
3938
+ title: 'Bad Request',
3939
+ status: 400,
3940
+ detail: 'Key not found or already disabled.',
3941
+ });
3942
+ return;
3943
+ }
3944
+ try {
3945
+ const result = rotateApiKey(currentKey);
3946
+ if (!result.rotated) {
3947
+ sendProblem(res, {
3948
+ type: PROBLEM_TYPES.BAD_REQUEST,
3949
+ title: 'Key Rotation Failed',
3950
+ status: 400,
3951
+ detail: result.reason || 'Key rotation failed.',
3952
+ });
3953
+ return;
3954
+ }
3955
+ sendJson(res, 200, {
3956
+ newKey: result.key,
3957
+ message: 'Key rotated. Update your configuration.',
3958
+ });
3959
+ } catch (err) {
3960
+ sendProblem(res, {
3961
+ type: PROBLEM_TYPES.INTERNAL,
3962
+ title: 'Internal Server Error',
3963
+ status: 500,
3964
+ detail: err.message || 'An unexpected error occurred.',
3965
+ });
3966
+ }
3967
+ return;
3968
+ }
3969
+
3970
+ // GET /v1/analytics/funnel — aggregate acquisition/activation/paid funnel metrics
3971
+ if (req.method === 'GET' && pathname === '/v1/analytics/funnel') {
3972
+ const summary = getFunnelAnalytics();
3973
+ sendJson(res, 200, summary);
3974
+ return;
3975
+ }
3976
+
3977
+ // GET /v1/dashboard -- Full ThumbGate dashboard JSON
3978
+ if (req.method === 'GET' && pathname === '/v1/dashboard') {
3979
+ let summaryOptions;
3980
+ try {
3981
+ summaryOptions = resolveBillingSummaryOptions(parsed);
3982
+ } catch (err) {
3983
+ sendProblem(res, {
3984
+ type: PROBLEM_TYPES.INVALID_REQUEST,
3985
+ title: 'Invalid dashboard query',
3986
+ status: 400,
3987
+ detail: err && err.message ? err.message : 'Invalid analytics window request.',
3988
+ });
3989
+ return;
3990
+ }
3991
+
3992
+ const { FEEDBACK_DIR } = getFeedbackPaths();
3993
+ const billingSummary = await getBillingSummaryLive(summaryOptions);
3994
+ const data = generateDashboard(FEEDBACK_DIR, {
3995
+ analyticsWindow: summaryOptions,
3996
+ billingSummary,
3997
+ billingSource: 'live',
3998
+ authContext: { tier: 'pro' },
3999
+ });
4000
+ sendJson(res, 200, data);
4001
+ return;
4002
+ }
4003
+
4004
+ // GET /v1/dashboard/render-spec -- Constrained hosted dashboard JSON spec
4005
+ if (req.method === 'GET' && pathname === '/v1/dashboard/render-spec') {
4006
+ let summaryOptions;
4007
+ try {
4008
+ summaryOptions = resolveBillingSummaryOptions(parsed);
4009
+ } catch (err) {
4010
+ sendProblem(res, {
4011
+ type: PROBLEM_TYPES.INVALID_REQUEST,
4012
+ title: 'Invalid render-spec query',
4013
+ status: 400,
4014
+ detail: err && err.message ? err.message : 'Invalid analytics window request.',
4015
+ });
4016
+ return;
4017
+ }
4018
+
4019
+ try {
4020
+ const { FEEDBACK_DIR } = getFeedbackPaths();
4021
+ const billingSummary = await getBillingSummaryLive(summaryOptions);
4022
+ const data = generateDashboard(FEEDBACK_DIR, {
4023
+ analyticsWindow: summaryOptions,
4024
+ billingSummary,
4025
+ billingSource: 'live',
4026
+ authContext: { tier: 'pro' },
4027
+ });
4028
+ const renderSpec = buildDashboardRenderSpec(data, {
4029
+ view: parsed.searchParams.get('view') || undefined,
4030
+ now: summaryOptions.now,
4031
+ });
4032
+ sendJson(res, 200, renderSpec);
4033
+ } catch (err) {
4034
+ sendProblem(res, {
4035
+ type: PROBLEM_TYPES.INVALID_REQUEST,
4036
+ title: 'Invalid render spec request',
4037
+ status: 400,
4038
+ detail: err && err.message ? err.message : 'Unable to build dashboard render spec.',
4039
+ });
4040
+ }
4041
+ return;
4042
+ }
4043
+
4044
+ // GET /v1/settings/status -- Resolved settings hierarchy with origin metadata
4045
+ if (req.method === 'GET' && pathname === '/v1/settings/status') {
4046
+ sendJson(res, 200, getSettingsStatus());
4047
+ return;
4048
+ }
4049
+
4050
+ // GET /v1/gates/stats -- Gate enforcement statistics
4051
+ if (req.method === 'GET' && pathname === '/v1/gates/stats') {
4052
+ const stats = loadGateStats();
4053
+ sendJson(res, 200, stats);
4054
+ return;
4055
+ }
4056
+
4057
+ // POST /v1/gates/satisfy -- Record evidence that a gate condition is satisfied
4058
+ if (req.method === 'POST' && pathname === '/v1/gates/satisfy') {
4059
+ const body = await parseJsonBody(req);
4060
+ if (!body.gateId || !body.evidence) {
4061
+ sendProblem(res, {
4062
+ type: PROBLEM_TYPES.BAD_REQUEST,
4063
+ title: 'Bad Request',
4064
+ status: 400,
4065
+ detail: 'gateId and evidence are required.',
4066
+ });
4067
+ return;
4068
+ }
4069
+ const entry = satisfyCondition(body.gateId, body.evidence);
4070
+ sendJson(res, 200, { satisfied: true, gateId: body.gateId, ...entry });
4071
+ return;
4072
+ }
4073
+
4074
+ // POST /webhook/stripe — Track Stripe subscription events (checkout, subscription changes)
4075
+ // TODO: Add STRIPE_WEBHOOK_SECRET to .env and enable signature verification via
4076
+ // verifyWebhookSignature() once the webhook endpoint is registered in the Stripe Dashboard.
4077
+ if (req.method === 'POST' && pathname === '/webhook/stripe') {
4078
+ try {
4079
+ const rawBody = await new Promise((resolve, reject) => {
4080
+ const chunks = [];
4081
+ req.on('data', (c) => chunks.push(c));
4082
+ req.on('end', () => resolve(Buffer.concat(chunks)));
4083
+ req.on('error', reject);
4084
+ });
4085
+ let event;
4086
+ try {
4087
+ event = JSON.parse(rawBody.toString('utf-8'));
4088
+ } catch {
4089
+ sendProblem(res, {
4090
+ type: PROBLEM_TYPES.INVALID_JSON,
4091
+ title: 'Invalid JSON',
4092
+ status: 400,
4093
+ detail: 'Invalid JSON in webhook body.',
4094
+ });
4095
+ return;
4096
+ }
4097
+ const TRACKED_STRIPE_EVENTS = new Set([
4098
+ 'checkout.session.completed',
4099
+ 'customer.subscription.created',
4100
+ 'customer.subscription.deleted',
4101
+ ]);
4102
+ if (TRACKED_STRIPE_EVENTS.has(event.type)) {
4103
+ const obj = event.data && event.data.object ? event.data.object : {};
4104
+ const record = {
4105
+ timestamp: new Date().toISOString(),
4106
+ event_type: event.type,
4107
+ event_id: event.id || null,
4108
+ customer_email:
4109
+ obj.customer_email ||
4110
+ obj.email ||
4111
+ (obj.customer_details && obj.customer_details.email) ||
4112
+ null,
4113
+ plan:
4114
+ obj.plan
4115
+ ? (obj.plan.nickname || obj.plan.id || null)
4116
+ : (
4117
+ obj.items &&
4118
+ obj.items.data &&
4119
+ obj.items.data[0] &&
4120
+ obj.items.data[0].plan
4121
+ ? (obj.items.data[0].plan.nickname || obj.items.data[0].plan.id)
4122
+ : null
4123
+ ),
4124
+ amount_cents: obj.amount_total || (obj.plan && obj.plan.amount) || null,
4125
+ currency: obj.currency || null,
4126
+ subscription_id: obj.subscription || obj.id || null,
4127
+ };
4128
+ appendStripeEvent(record);
4129
+ }
4130
+ sendJson(res, 200, { received: true, event_type: event.type });
4131
+ } catch (err) {
4132
+ sendProblem(res, {
4133
+ type: PROBLEM_TYPES.INTERNAL,
4134
+ title: 'Internal Server Error',
4135
+ status: 500,
4136
+ detail: err.message,
4137
+ });
4138
+ }
4139
+ return;
4140
+ }
4141
+
4142
+ // GET /api/conversions — Conversion stats derived from the Stripe event log
4143
+ if (req.method === 'GET' && pathname === '/api/conversions') {
4144
+ try {
4145
+ const events = readStripeEvents();
4146
+ const stats = computeConversionStats(events);
4147
+ sendJson(res, 200, stats);
4148
+ } catch (err) {
4149
+ sendProblem(res, {
4150
+ type: PROBLEM_TYPES.INTERNAL,
4151
+ title: 'Internal Server Error',
4152
+ status: 500,
4153
+ detail: err.message,
4154
+ });
4155
+ }
4156
+ return;
4157
+ }
4158
+
4159
+ sendProblem(res, {
4160
+ type: PROBLEM_TYPES.NOT_FOUND,
4161
+ title: 'Not Found',
4162
+ status: 404,
4163
+ detail: `No handler for ${req.method} ${pathname}`,
4164
+ });
4165
+ } catch (err) {
4166
+ sendProblem(res, {
4167
+ type: !err.statusCode || err.statusCode >= 500 ? PROBLEM_TYPES.INTERNAL : PROBLEM_TYPES.BAD_REQUEST,
4168
+ title: !err.statusCode || err.statusCode >= 500 ? 'Internal Server Error' : 'Request Error',
4169
+ status: err.statusCode || 500,
4170
+ detail: err.message || 'An unexpected error occurred.',
4171
+ });
4172
+ }
4173
+ });
4174
+ }
4175
+
4176
+ function startServer({ port, host } = {}) {
4177
+ const listenPort = Number(port ?? process.env.PORT ?? 8787);
4178
+ const listenHost = String(host ?? process.env.HOST ?? '0.0.0.0').trim() || '0.0.0.0';
4179
+ const server = createApiServer();
4180
+ return new Promise((resolve) => {
4181
+ server.listen(listenPort, listenHost, () => {
4182
+ const address = server.address();
4183
+ const actualPort = (address && typeof address === 'object' && address.port)
4184
+ ? address.port
4185
+ : listenPort;
4186
+ resolve({
4187
+ server,
4188
+ host: listenHost,
4189
+ port: actualPort,
4190
+ });
4191
+ });
4192
+ });
4193
+ }
4194
+
4195
+ module.exports = {
4196
+ createApiServer,
4197
+ startServer,
4198
+ __test__: {
4199
+ buildCheckoutFallbackUrl,
4200
+ renderSitemapXml,
4201
+ },
4202
+ };
4203
+
4204
+ if (require.main === module) {
4205
+ startServer().then(({ host, port }) => {
4206
+ console.log(`ThumbGate API listening on http://${host}:${port}`);
4207
+ });
4208
+ }