thumbgate 1.27.20 → 1.28.0

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 (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +63 -42
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/mcp/server-stdio.js +1 -1
  6. package/adapters/opencode/opencode.json +1 -1
  7. package/bin/cli.js +25 -8
  8. package/bin/postinstall.js +1 -1
  9. package/config/entitlement-public-keys.json +6 -0
  10. package/config/gates/default.json +1 -1
  11. package/config/github-about.json +2 -2
  12. package/config/merge-quality-checks.json +3 -0
  13. package/package.json +12 -4
  14. package/public/about.html +1 -4
  15. package/public/agent-manager.html +6 -6
  16. package/public/agents-cost-savings.html +3 -3
  17. package/public/ai-malpractice-prevention.html +1 -1
  18. package/public/blog.html +12 -9
  19. package/public/chatgpt-app.html +3 -3
  20. package/public/codex-enterprise.html +3 -3
  21. package/public/codex-plugin.html +5 -5
  22. package/public/compare.html +10 -10
  23. package/public/dashboard.html +1 -1
  24. package/public/federal.html +2 -2
  25. package/public/guide.html +14 -14
  26. package/public/index.html +81 -70
  27. package/public/install.html +14 -14
  28. package/public/learn.html +4 -17
  29. package/public/numbers.html +2 -2
  30. package/public/pricing.html +12 -19
  31. package/public/pro.html +2 -2
  32. package/scripts/agent-reward-model.js +13 -0
  33. package/scripts/bayes-optimal-gate.js +6 -1
  34. package/scripts/cli-feedback.js +17 -1
  35. package/scripts/commercial-offer.js +3 -3
  36. package/scripts/entitlement.js +250 -0
  37. package/scripts/export-databricks-bundle.js +5 -0
  38. package/scripts/export-dpo-pairs.js +6 -0
  39. package/scripts/export-hf-dataset.js +5 -0
  40. package/scripts/gates-engine.js +15 -3
  41. package/scripts/imperative-detector.js +85 -0
  42. package/scripts/intervention-policy.js +13 -0
  43. package/scripts/pr-manager.js +9 -22
  44. package/scripts/pro-local-dashboard.js +198 -0
  45. package/scripts/risk-scorer.js +6 -0
  46. package/scripts/seo-gsd.js +2 -2
  47. package/scripts/thompson-sampling.js +11 -2
  48. package/src/api/server.js +81 -10
@@ -1,10 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
+ const crypto = require('crypto');
4
5
  const os = require('os');
5
6
  const path = require('path');
6
7
 
7
8
  const DEFAULT_PRO_API = 'https://thumbgate-production.up.railway.app';
9
+ const DEFAULT_PRO_ACTIVATION_ALERT_EMAIL = 'igor.ganapolsky@gmail.com';
8
10
  const CREATOR_BYPASS_VALUE = process.env.THUMBGATE_DEV_SECRET || '';
9
11
  const CREATOR_BYPASS_ENV = 'THUMBGATE_DEV_BYPASS';
10
12
  const CREATOR_SYNTHETIC_KEY = process.env.THUMBGATE_DEV_KEY || '';
@@ -55,6 +57,195 @@ function getLicensePath(homeDir = process.env.HOME || process.env.USERPROFILE ||
55
57
  return path.join(getLicenseDir(homeDir), 'license.json');
56
58
  }
57
59
 
60
+ function isTruthyEnv(value) {
61
+ return /^(1|true|yes|on)$/i.test(String(value || '').trim());
62
+ }
63
+
64
+ function normalizeText(value) {
65
+ return String(value || '').trim();
66
+ }
67
+
68
+ function escapeHtml(value) {
69
+ return String(value || '')
70
+ .replace(/&/g, '&')
71
+ .replace(/</g, '&lt;')
72
+ .replace(/>/g, '&gt;')
73
+ .replace(/"/g, '&quot;')
74
+ .replace(/'/g, '&#39;');
75
+ }
76
+
77
+ function fingerprintProKey(key) {
78
+ const normalized = normalizeText(key);
79
+ if (!normalized) return '';
80
+ return `sha256:${crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 12)}`;
81
+ }
82
+
83
+ function resolveProActivationAlertRecipient(env = process.env) {
84
+ return normalizeText(
85
+ env.THUMBGATE_PRO_ACTIVATION_ALERT_EMAIL ||
86
+ env.THUMBGATE_OPERATOR_ALERT_EMAIL ||
87
+ env.THUMBGATE_SUPPORT_EMAIL ||
88
+ DEFAULT_PRO_ACTIVATION_ALERT_EMAIL
89
+ );
90
+ }
91
+
92
+ function canSendProActivationAlert({ env = process.env, sendEmailImpl } = {}) {
93
+ if (isTruthyEnv(env.THUMBGATE_DISABLE_PRO_ACTIVATION_ALERTS)) return false;
94
+ if (!resolveProActivationAlertRecipient(env)) return false;
95
+ if (sendEmailImpl) return true;
96
+ return Boolean(normalizeText(env.RESEND_API_KEY || env.THUMBGATE_RESEND_API_KEY));
97
+ }
98
+
99
+ function renderProActivationAlertBodies({
100
+ keyFingerprint,
101
+ source,
102
+ version,
103
+ activatedAt,
104
+ hostname,
105
+ platform,
106
+ arch,
107
+ nodeVersion,
108
+ customerId,
109
+ installId,
110
+ usageCount,
111
+ } = {}) {
112
+ const occurredAt = activatedAt || new Date().toISOString();
113
+ const runtimePlatform = platform || process.platform;
114
+ const runtimeArch = arch || process.arch;
115
+ const runtimeNode = nodeVersion || process.version;
116
+ const text = [
117
+ 'ThumbGate Pro activation detected.',
118
+ '',
119
+ `Activated at: ${occurredAt}`,
120
+ `Key fingerprint: ${keyFingerprint || 'unknown'}`,
121
+ `Source: ${source || 'unknown'}`,
122
+ `Version: ${version || 'unknown'}`,
123
+ `Customer ID: ${customerId || 'unknown'}`,
124
+ `Install ID: ${installId || 'unknown'}`,
125
+ `Usage count: ${usageCount ?? 'unknown'}`,
126
+ `Host: ${hostname || 'unknown'}`,
127
+ `Runtime: ${runtimePlatform}/${runtimeArch} on ${runtimeNode}`,
128
+ '',
129
+ 'Secret hygiene: this alert intentionally does not include the raw Pro key.',
130
+ ].join('\n');
131
+ const html = `<!doctype html>
132
+ <html>
133
+ <body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;color:#17212b;line-height:1.5;">
134
+ <h1 style="font-size:20px;margin:0 0 12px;">ThumbGate Pro activation detected</h1>
135
+ <table role="presentation" cellspacing="0" cellpadding="0" style="border-collapse:collapse;">
136
+ <tr><td style="padding:4px 14px 4px 0;color:#64748b;">Activated at</td><td style="padding:4px 0;">${escapeHtml(occurredAt)}</td></tr>
137
+ <tr><td style="padding:4px 14px 4px 0;color:#64748b;">Key fingerprint</td><td style="padding:4px 0;"><code>${escapeHtml(keyFingerprint || 'unknown')}</code></td></tr>
138
+ <tr><td style="padding:4px 14px 4px 0;color:#64748b;">Source</td><td style="padding:4px 0;">${escapeHtml(source || 'unknown')}</td></tr>
139
+ <tr><td style="padding:4px 14px 4px 0;color:#64748b;">Version</td><td style="padding:4px 0;">${escapeHtml(version || 'unknown')}</td></tr>
140
+ <tr><td style="padding:4px 14px 4px 0;color:#64748b;">Customer ID</td><td style="padding:4px 0;">${escapeHtml(customerId || 'unknown')}</td></tr>
141
+ <tr><td style="padding:4px 14px 4px 0;color:#64748b;">Install ID</td><td style="padding:4px 0;">${escapeHtml(installId || 'unknown')}</td></tr>
142
+ <tr><td style="padding:4px 14px 4px 0;color:#64748b;">Usage count</td><td style="padding:4px 0;">${escapeHtml(usageCount ?? 'unknown')}</td></tr>
143
+ <tr><td style="padding:4px 14px 4px 0;color:#64748b;">Host</td><td style="padding:4px 0;">${escapeHtml(hostname || 'unknown')}</td></tr>
144
+ <tr><td style="padding:4px 14px 4px 0;color:#64748b;">Runtime</td><td style="padding:4px 0;">${escapeHtml(runtimePlatform)}/${escapeHtml(runtimeArch)} on ${escapeHtml(runtimeNode)}</td></tr>
145
+ </table>
146
+ <p style="margin-top:16px;color:#64748b;">Secret hygiene: this alert intentionally does not include the raw Pro key.</p>
147
+ </body>
148
+ </html>`;
149
+ return { text, html };
150
+ }
151
+
152
+ async function sendProActivationAlert({
153
+ key,
154
+ source = 'unknown',
155
+ version,
156
+ customerId,
157
+ installId,
158
+ usageCount,
159
+ env = process.env,
160
+ sendEmailImpl,
161
+ } = {}) {
162
+ const keyFingerprint = fingerprintProKey(key);
163
+ if (!keyFingerprint) return { sent: false, reason: 'missing_key' };
164
+ if (!canSendProActivationAlert({ env, sendEmailImpl })) {
165
+ return { sent: false, reason: 'activation_alert_disabled_or_unconfigured' };
166
+ }
167
+
168
+ const to = resolveProActivationAlertRecipient(env);
169
+ const { html, text } = renderProActivationAlertBodies({
170
+ keyFingerprint,
171
+ source,
172
+ version,
173
+ customerId,
174
+ installId,
175
+ usageCount,
176
+ activatedAt: new Date().toISOString(),
177
+ hostname: os.hostname(),
178
+ platform: process.platform,
179
+ arch: process.arch,
180
+ nodeVersion: process.version,
181
+ });
182
+ const sender = sendEmailImpl || require('./mailer').sendEmail;
183
+
184
+ try {
185
+ return await sender({
186
+ to,
187
+ subject: 'ThumbGate Pro activated',
188
+ html,
189
+ text,
190
+ });
191
+ } catch (error) {
192
+ return {
193
+ sent: false,
194
+ reason: 'exception',
195
+ error: error && error.message ? error.message : String(error),
196
+ };
197
+ }
198
+ }
199
+
200
+ async function notifyHostedProActivation({
201
+ key,
202
+ source = 'cli_pro_activate',
203
+ version,
204
+ apiBaseUrl = process.env.THUMBGATE_API_BASE_URL || DEFAULT_PRO_API,
205
+ fetchImpl = globalThis.fetch,
206
+ } = {}) {
207
+ const normalizedKey = normalizeText(key);
208
+ if (!normalizedKey) return { notified: false, reason: 'missing_key' };
209
+ if (typeof fetchImpl !== 'function') return { notified: false, reason: 'no_fetch' };
210
+
211
+ const keyFingerprint = fingerprintProKey(normalizedKey);
212
+ const endpoint = new URL('/v1/billing/pro-activation', apiBaseUrl).toString();
213
+ try {
214
+ const response = await fetchImpl(endpoint, {
215
+ method: 'POST',
216
+ headers: {
217
+ authorization: `Bearer ${normalizedKey}`,
218
+ 'content-type': 'application/json',
219
+ },
220
+ body: JSON.stringify({
221
+ keyFingerprint,
222
+ source,
223
+ version: version || null,
224
+ }),
225
+ });
226
+ const body = await response.json().catch(() => ({}));
227
+ if (!response.ok) {
228
+ return {
229
+ notified: false,
230
+ reason: body && body.detail ? body.detail : `http_${response.status}`,
231
+ status: response.status,
232
+ };
233
+ }
234
+ return {
235
+ notified: true,
236
+ status: response.status,
237
+ alert: body.alert || null,
238
+ keyFingerprint: body.keyFingerprint || keyFingerprint,
239
+ };
240
+ } catch (error) {
241
+ return {
242
+ notified: false,
243
+ reason: 'exception',
244
+ error: error && error.message ? error.message : String(error),
245
+ };
246
+ }
247
+ }
248
+
58
249
  function readLicense({ homeDir } = {}) {
59
250
  try {
60
251
  return JSON.parse(fs.readFileSync(getLicensePath(homeDir), 'utf8'));
@@ -162,12 +353,19 @@ module.exports = {
162
353
  CREATOR_BYPASS_VALUE,
163
354
  CREATOR_SYNTHETIC_KEY,
164
355
  DEFAULT_PRO_API,
356
+ DEFAULT_PRO_ACTIVATION_ALERT_EMAIL,
357
+ canSendProActivationAlert,
358
+ fingerprintProKey,
165
359
  getLicenseDir,
166
360
  getLicensePath,
167
361
  hasDevOverride,
168
362
  isCreatorDev,
169
363
  readLicense,
364
+ renderProActivationAlertBodies,
365
+ notifyHostedProActivation,
366
+ resolveProActivationAlertRecipient,
170
367
  saveLicense,
368
+ sendProActivationAlert,
171
369
  resolveProKey,
172
370
  validateProKey,
173
371
  startLocalProDashboard,
@@ -4,6 +4,7 @@
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { resolveFeedbackDir: resolveSharedFeedbackDir } = require('./feedback-paths');
7
+ const { requireLearnedModelsEntitlement } = require('./entitlement');
7
8
 
8
9
  const PROJECT_ROOT = path.join(__dirname, '..');
9
10
  const DEFAULT_FEEDBACK_DIR = resolveSharedFeedbackDir();
@@ -268,6 +269,10 @@ function buildPatternSummary(rows) {
268
269
  }
269
270
 
270
271
  function trainRiskModel(rows, options = {}) {
272
+ requireLearnedModelsEntitlement({
273
+ ...(options.entitlement || {}),
274
+ label: 'risk-scorer AdaBoost training',
275
+ });
271
276
  const registry = buildFeatureRegistry(rows, options);
272
277
  const examples = rows.map((row) => ({
273
278
  row,
@@ -419,6 +424,7 @@ function trainAndPersistRiskModel(feedbackDir, options = {}) {
419
424
  }
420
425
 
421
426
  function getRiskSummary(feedbackDir) {
427
+ requireLearnedModelsEntitlement({ label: 'risk-scorer summary' });
422
428
  const resolvedDir = resolveFeedbackDir(feedbackDir);
423
429
  const rows = readJSONL(sequencePathFor(resolvedDir));
424
430
  if (rows.length === 0) return null;
@@ -562,7 +562,7 @@ const PRETOOLUSE_HOOK_GUIDE_SPEC = Object.freeze({
562
562
  ],
563
563
  [
564
564
  'Does this run locally or call a cloud service?',
565
- 'Local-first. The PreToolUse decision happens in the hook process on your machine in milliseconds — no network round-trip, no cloud dependency, no data leaving the laptop. Optional hosted sync exists for teams that want to share rules across seats.',
565
+ 'Local-first. The PreToolUse decision happens in the hook process on your machine in milliseconds — no network round-trip, no cloud dependency, no data leaving the laptop. Pro adds personal recall, exports, dashboard proof, and managed adapter coverage. Enterprise adds hosted sharing for teams that want to share rules across seats.',
566
566
  ],
567
567
  ],
568
568
  relatedPaths: ['/guides/mcp-tool-governance', '/guides/ai-agent-pre-action-approval-gates', '/guides/ai-coding-agent-zero-trust'],
@@ -1972,7 +1972,7 @@ const PAGE_BLUEPRINTS = [
1972
1972
  faq: [
1973
1973
  {
1974
1974
  question: 'Why pay $19/mo for ThumbGate Pro when disler hooks are free?',
1975
- answer: 'Free disler hooks are static patterns you maintain per machine — re-copying them when they change, debugging false positives alone, and re-applying them in every new project. ThumbGate Pro adds the learning loop (thumbs-down → cross-session prevention rule), the dashboard, hosted sync across machines, and adapter maintenance across the weekly breaking-change cycle of Claude Code, Cursor, and Cline. Free disler is the right answer if you only ever work in one project on one machine and never want to learn from past mistakes. Pro is the right answer when those assumptions stop holding.',
1975
+ answer: 'Free disler hooks are static patterns you maintain per machine — re-copying them when they change, debugging false positives alone, and re-applying them in every new project. ThumbGate Pro adds the learning loop (repeated thumbs-downs → cross-session prevention rule), the personal dashboard, lesson recall/search, DPO export, and adapter maintenance across the weekly breaking-change cycle of Claude Code, Cursor, and Cline. Free disler is the right answer if you only ever work in one project on one machine and never want to learn from past mistakes. Pro is the right answer when those assumptions stop holding; Enterprise adds shared hosted lessons across seats.',
1976
1976
  },
1977
1977
  {
1978
1978
  question: 'Is ThumbGate just a packaged version of disler/claude-code-hooks-mastery?',
@@ -22,6 +22,7 @@
22
22
  const fs = require('fs');
23
23
  const { parseTimestamp } = require('./feedback-schema');
24
24
  const { getEffectiveSetting } = require('./evolution-state');
25
+ const { requireLearnedModelsEntitlement } = require('./entitlement');
25
26
 
26
27
  // ---------------------------------------------------------------------------
27
28
  // Constants
@@ -179,7 +180,11 @@ function saveModel(model, modelPath) {
179
180
  * @param {number} [params.weightMultiplier] - Optional multiplier for stronger/slower updates
180
181
  * @returns {Object} The mutated model
181
182
  */
182
- function updateModel(model, { signal, timestamp, categories, weightMultiplier, failureType }) {
183
+ function updateModel(model, { signal, timestamp, categories, weightMultiplier, failureType, entitlement }) {
184
+ requireLearnedModelsEntitlement({
185
+ ...(entitlement || {}),
186
+ label: 'Thompson Sampling model update',
187
+ });
183
188
  const multiplier = Number.isFinite(weightMultiplier) && weightMultiplier > 0 ? weightMultiplier : 1;
184
189
  const weight = timeDecayWeight(timestamp) * multiplier;
185
190
  const isPositive = signal === 'positive';
@@ -334,7 +339,11 @@ function getTemperatureScaledPosteriorParams(params, temperature = 1.0) {
334
339
  * @param {number} temperature - Scaling factor (default 1.0)
335
340
  * @returns {Object} Map of category → float sample in [0, 1]
336
341
  */
337
- function samplePosteriors(model, temperature = 1.0) {
342
+ function samplePosteriors(model, temperature = 1.0, options = {}) {
343
+ requireLearnedModelsEntitlement({
344
+ ...(options.entitlement || {}),
345
+ label: 'Thompson Sampling posterior sampling',
346
+ });
338
347
  const samples = {};
339
348
 
340
349
  for (const [cat, params] of Object.entries(model.categories || {})) {
package/src/api/server.js CHANGED
@@ -20,6 +20,7 @@ const POSTHOG_STATIC_PATH_PREFIX = '/static/';
20
20
  // Stripe catalog, with the per-tier thumbnails wired in. Re-run the
21
21
  // bootstrap workflow to regenerate; the new URLs surface in the workflow
22
22
  // summary log.
23
+ const PRO_CHECKOUT_URL = 'https://buy.stripe.com/8x2dR91M84r4cSd9uj3sI3f';
23
24
  const FIRST_FAILURE_RULE_CHECKOUT_URL = 'https://buy.stripe.com/7sY6oHaiEbTw6tP5e33sI3e';
24
25
  const QUICK_READ_CHECKOUT_URL = 'https://buy.stripe.com/5kQ7sL76s1eSaK55e33sI2H';
25
26
  const WORKFLOW_TEARDOWN_CHECKOUT_URL = 'https://buy.stripe.com/8x214n2Qc4r44lHayn3sI2I';
@@ -219,6 +220,10 @@ const oauthStore = mcpOauth.createStore();
219
220
  const pendingOauthAuthorizeRequests = new Map();
220
221
  const OAUTH_AUTHORIZE_REQUEST_TTL_MS = 10 * 60 * 1000;
221
222
  const resendMailer = require('../../scripts/mailer/resend-mailer');
223
+ const {
224
+ fingerprintProKey,
225
+ sendProActivationAlert,
226
+ } = require('../../scripts/pro-local-dashboard');
222
227
  const {
223
228
  buildContextFootprintReport,
224
229
  } = require('../../scripts/context-footprint');
@@ -1760,8 +1765,10 @@ function appendFeedbackListLines(lines, { entries, signal, intent }) {
1760
1765
  lines.push(`No ${signal || 'feedback'} entries found ${intent.windowLabel}.`);
1761
1766
  return;
1762
1767
  }
1763
- lines.push(`${FEEDBACK_LIST_LABELS[signal] || 'Recent feedback'} (${intent.windowLabel}):`);
1764
- lines.push(...entries.map(formatFeedbackEntry));
1768
+ lines.push(
1769
+ `${FEEDBACK_LIST_LABELS[signal] || 'Recent feedback'} (${intent.windowLabel}):`,
1770
+ ...entries.map(formatFeedbackEntry)
1771
+ );
1765
1772
  }
1766
1773
 
1767
1774
  function buildFeedbackSection({ ctx, intent, feedbackDir, approval, lessonPipeline }) {
@@ -2244,8 +2251,8 @@ a{display:block;text-decoration:none}a.secondary{border:1px solid #374151;color:
2244
2251
  <div class="brand"><span class="brand-mark"></span><span>ThumbGate</span></div>
2245
2252
  <h1>Start ThumbGate Pro</h1>
2246
2253
  <div class="price">$19<small>/mo</small></div>
2247
- <p>The npm package runs your gates locally. <strong>Pro</strong> is what keeps them working across every machine, every agent runtime, and every breaking-change week.</p>
2248
- <form action="https://buy.stripe.com/8x2dR91M84r4cSd9uj3sI3f" method="GET" data-i="pro_checkout_confirmed">
2254
+ <p>The npm package runs your gates locally. <strong>Pro</strong> removes solo caps and adds personal recall, proof, exports, and adapter maintenance. Shared hosted lessons and org dashboards are Enterprise.</p>
2255
+ <form action="${PRO_CHECKOUT_URL}" method="GET" data-i="pro_checkout_confirmed">
2249
2256
  ${hiddenInputs}
2250
2257
  <input type="email" name="prefilled_email" value="${escapeHtmlAttribute(prefilledEmail)}" placeholder="you@company.com" autocomplete="email">
2251
2258
  <p class="email-note">Optional. Stripe can collect your email on the secure checkout page.</p>
@@ -2264,7 +2271,7 @@ ${hiddenInputs}
2264
2271
  </div>
2265
2272
  <div class="feedback-saved" id="feedback-saved">Feedback saved.</div>
2266
2273
  </div>
2267
- <div class="trust"><div class="trust-item">Lessons synced across all your machines no local SQLite to babysit</div><div class="trust-item">Adapter matrix kept current for Claude Code, Cursor, Codex, Gemini, Amp, Cline, OpenCode — version drift is our problem, not yours</div><div class="trust-item">Hosted dashboard: gate stats, DPO export, org-wide rule library</div><div class="trust-item">24×7 ops on the rule engine SonarCloud regressions fixed in &lt;24h</div></div>
2274
+ <div class="trust"><div class="trust-item">Personal recall and proof exports without free-tier caps</div><div class="trust-item">Adapter matrix kept current for Claude Code, Cursor, Codex, Gemini, Amp, Cline, OpenCode</div><div class="trust-item">Personal dashboard: gate stats, rule evidence, DPO export</div><div class="trust-item">Enterprise adds shared hosted lessons, org visibility, rollout support</div></div>
2268
2275
  <p class="back"><a href="/">← Back to thumbgate.ai</a></p>
2269
2276
  </main>
2270
2277
  <script>
@@ -2789,9 +2796,17 @@ function fillTemplate(template, replacements) {
2789
2796
  }
2790
2797
 
2791
2798
  function escapeHtmlAttribute(value) {
2799
+ // Complete HTML-entity encoder for attribute contexts. Escapes single quotes
2800
+ // and backticks in addition to & " < > so the output is safe in single- AND
2801
+ // double-quoted attributes (and not just the double-quoted case). Fixes
2802
+ // CodeQL js/reflected-xss #252 (search-param `email` reflected into the
2803
+ // checkout page's value="..." attribute) — the prior version omitted ' which
2804
+ // left it context-fragile and unrecognized as a sanitizer.
2792
2805
  return String(value)
2793
2806
  .replaceAll('&', '&amp;')
2794
2807
  .replaceAll('"', '&quot;')
2808
+ .replaceAll("'", '&#39;')
2809
+ .replaceAll('`', '&#96;')
2795
2810
  .replaceAll('<', '&lt;')
2796
2811
  .replaceAll('>', '&gt;');
2797
2812
  }
@@ -5221,7 +5236,14 @@ function createApiServer() {
5221
5236
  if (req.method === 'POST' && pathname === '/api/event') {
5222
5237
  // Filter bots from analytics to keep Plausible data clean
5223
5238
  let _botDetector;
5224
- try { _botDetector = require('../../scripts/bot-detection'); } catch (_e) { _botDetector = null; }
5239
+ try {
5240
+ _botDetector = require('../../scripts/bot-detection');
5241
+ } catch (err) {
5242
+ _botDetector = null;
5243
+ if (process.env.THUMBGATE_DEBUG_BOT_DETECTION === '1') {
5244
+ console.warn(`Optional bot-detection module unavailable: ${err.message}`);
5245
+ }
5246
+ }
5225
5247
  if (_botDetector && _botDetector.shouldExcludeFromAnalytics(req)) {
5226
5248
  sendJson(res, 202, { status: 'filtered', reason: 'bot' });
5227
5249
  return;
@@ -6116,7 +6138,7 @@ async function addContext(){
6116
6138
  // THUMBGATE_CHECKOUT_PRO_STRIPE_URL is supported for future
6117
6139
  // price-link rotation without a redeploy.
6118
6140
  const bypassTarget = process.env.THUMBGATE_CHECKOUT_PRO_STRIPE_URL
6119
- || FIRST_FAILURE_RULE_CHECKOUT_URL;
6141
+ || PRO_CHECKOUT_URL;
6120
6142
  appendBestEffortTelemetry(FEEDBACK_DIR, {
6121
6143
  eventType: 'checkout_interstitial_bypass_redirect',
6122
6144
  clientType: 'web',
@@ -6180,6 +6202,12 @@ async function addContext(){
6180
6202
  ? 'checkout_bot_deflected'
6181
6203
  : 'checkout_interstitial_view';
6182
6204
  const missingConfirmedEmail = hasConfirmFlag && !hasValidCustomerEmailHint;
6205
+ let checkoutDeflectionReason = botClassification.reason;
6206
+ if (missingConfirmedEmail) {
6207
+ checkoutDeflectionReason = hasCustomerEmailHint
6208
+ ? 'invalid_customer_email'
6209
+ : 'missing_customer_email';
6210
+ }
6183
6211
  appendBestEffortTelemetry(FEEDBACK_DIR, {
6184
6212
  eventType,
6185
6213
  clientType: 'web',
@@ -6203,9 +6231,7 @@ async function addContext(){
6203
6231
  isBot: botClassification.isBot ? 'true' : 'false',
6204
6232
  interstitialSampled: interstitialSampled ? 'true' : 'false',
6205
6233
  interstitialSampleRate,
6206
- reason: missingConfirmedEmail
6207
- ? (hasCustomerEmailHint ? 'invalid_customer_email' : 'missing_customer_email')
6208
- : botClassification.reason,
6234
+ reason: checkoutDeflectionReason,
6209
6235
  confirmEmailRequired: missingConfirmedEmail ? 'true' : 'false',
6210
6236
  }, req.headers, eventType);
6211
6237
  const prefilledEmail = parsed?.searchParams?.get('customer_email') || '';
@@ -9206,6 +9232,48 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9206
9232
  return;
9207
9233
  }
9208
9234
 
9235
+ // POST /v1/billing/pro-activation — customer key activation alert.
9236
+ if (req.method === 'POST' && pathname === '/v1/billing/pro-activation') {
9237
+ const token = extractBearerToken(req);
9238
+ const validation = validateApiKey(token);
9239
+ if (!validation.valid) {
9240
+ sendProblem(res, {
9241
+ type: PROBLEM_TYPES.UNAUTHORIZED,
9242
+ title: 'Unauthorized',
9243
+ status: 401,
9244
+ detail: 'A valid API key is required to access this endpoint.',
9245
+ });
9246
+ return;
9247
+ }
9248
+
9249
+ const body = await parseJsonBody(req);
9250
+ const keyFingerprint = fingerprintProKey(token);
9251
+ const alert = await sendProActivationAlert({
9252
+ key: token,
9253
+ source: body.source || 'hosted_pro_activation',
9254
+ version: body.version || null,
9255
+ customerId: validation.customerId,
9256
+ installId: validation.installId,
9257
+ usageCount: validation.usageCount,
9258
+ env: process.env,
9259
+ });
9260
+
9261
+ sendJson(res, 200, {
9262
+ ok: true,
9263
+ customerId: validation.customerId,
9264
+ installId: validation.installId,
9265
+ usageCount: validation.usageCount,
9266
+ keyFingerprint,
9267
+ keyFingerprintMatchesClient: body.keyFingerprint ? body.keyFingerprint === keyFingerprint : null,
9268
+ alert: {
9269
+ sent: Boolean(alert?.sent),
9270
+ reason: alert?.reason || null,
9271
+ id: alert?.id || null,
9272
+ },
9273
+ });
9274
+ return;
9275
+ }
9276
+
9209
9277
  // POST /v1/billing/provision — manually provision key (admin)
9210
9278
  if (req.method === 'POST' && pathname === '/v1/billing/provision') {
9211
9279
  if (!isStaticAdminAuthorized(req, expectedApiKey)) {
@@ -9765,6 +9833,9 @@ module.exports = {
9765
9833
  createApiServer,
9766
9834
  startServer,
9767
9835
  __test__: {
9836
+ PRO_CHECKOUT_URL,
9837
+ escapeHtmlAttribute,
9838
+ renderCheckoutIntentPage,
9768
9839
  buildCheckoutFallbackUrl,
9769
9840
  createPrivateCoreUnavailableError,
9770
9841
  buildPosthogProxyRequestOptions,