thumbgate 1.5.8 → 1.6.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/CHANGELOG.md +198 -0
- package/README.md +7 -6
- package/adapters/README.md +1 -1
- package/adapters/chatgpt/openapi.yaml +25 -0
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +4 -4
- package/adapters/mcp/server-stdio.js +1 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +61 -5
- package/openapi/openapi.yaml +25 -0
- package/package.json +12 -3
- package/public/codex-plugin.html +277 -0
- package/public/dashboard.html +141 -13
- package/public/index.html +92 -34
- package/public/learn.html +13 -2
- package/public/lessons.html +5 -2
- package/public/pro.html +8 -1
- package/scripts/auto-wire-hooks.js +10 -5
- package/scripts/billing.js +503 -8
- package/scripts/contextfs.js +1 -1
- package/scripts/dashboard.js +236 -0
- package/scripts/gates-engine.js +153 -2
- package/scripts/hook-runtime.js +42 -0
- package/scripts/llm-client.js +25 -10
- package/scripts/mailer/index.js +13 -0
- package/scripts/mailer/resend-mailer.js +350 -0
- package/scripts/mcp-config.js +13 -0
- package/scripts/published-cli.js +8 -0
- package/scripts/seo-gsd.js +118 -4
- package/scripts/statusline.sh +8 -0
- package/scripts/vector-store.js +21 -7
- package/src/api/server.js +112 -7
package/scripts/billing.js
CHANGED
|
@@ -16,7 +16,13 @@ function withTimeout(promise, ms = STRIPE_TIMEOUT_MS) {
|
|
|
16
16
|
const fs = require('fs');
|
|
17
17
|
const path = require('path');
|
|
18
18
|
const crypto = require('crypto');
|
|
19
|
-
const
|
|
19
|
+
const https = require('https');
|
|
20
|
+
const {
|
|
21
|
+
DEFAULT_PUBLIC_APP_ORIGIN,
|
|
22
|
+
createTraceId,
|
|
23
|
+
joinPublicUrl,
|
|
24
|
+
normalizeOrigin,
|
|
25
|
+
} = require('./hosted-config');
|
|
20
26
|
const {
|
|
21
27
|
getFeedbackPaths,
|
|
22
28
|
getLegacyFeedbackDir,
|
|
@@ -44,6 +50,7 @@ const {
|
|
|
44
50
|
serializeAnalyticsWindow,
|
|
45
51
|
} = require('./analytics-window');
|
|
46
52
|
const { ensureParentDir } = require('./fs-utils');
|
|
53
|
+
const mailer = require('./mailer');
|
|
47
54
|
|
|
48
55
|
// ---------------------------------------------------------------------------
|
|
49
56
|
// Config
|
|
@@ -74,6 +81,12 @@ const CONFIG = {
|
|
|
74
81
|
get NEWSLETTER_SUBSCRIBERS_PATH() {
|
|
75
82
|
return process.env._TEST_NEWSLETTER_SUBSCRIBERS_PATH || path.join(getFeedbackPaths().FEEDBACK_DIR, 'newsletter-subscribers.jsonl');
|
|
76
83
|
},
|
|
84
|
+
get TRIAL_EMAIL_LEDGER_PATH() {
|
|
85
|
+
return process.env._TEST_TRIAL_EMAIL_LEDGER_PATH || process.env.THUMBGATE_TRIAL_EMAIL_LEDGER_PATH || path.join(getFeedbackPaths().FEEDBACK_DIR, 'trial-emails.jsonl');
|
|
86
|
+
},
|
|
87
|
+
RESEND_API_KEY: process.env.RESEND_API_KEY || process.env.THUMBGATE_RESEND_API_KEY || '',
|
|
88
|
+
TRIAL_EMAIL_FROM: process.env.THUMBGATE_TRIAL_EMAIL_FROM || process.env.RESEND_FROM_EMAIL || process.env.RESEND_FROM || 'onboarding@resend.dev',
|
|
89
|
+
TRIAL_EMAIL_REPLY_TO: process.env.THUMBGATE_TRIAL_EMAIL_REPLY_TO || 'igor.ganapolsky@gmail.com',
|
|
77
90
|
CREDIT_PACKS: {}
|
|
78
91
|
};
|
|
79
92
|
|
|
@@ -379,6 +392,407 @@ function normalizeText(value) {
|
|
|
379
392
|
return text || null;
|
|
380
393
|
}
|
|
381
394
|
|
|
395
|
+
function resolvePublicAppOrigin(appOrigin) {
|
|
396
|
+
return normalizeOrigin(appOrigin) || normalizeOrigin(process.env.THUMBGATE_PUBLIC_APP_ORIGIN) || DEFAULT_PUBLIC_APP_ORIGIN;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function resolveCheckoutBrandUrls(appOrigin) {
|
|
400
|
+
const origin = resolvePublicAppOrigin(appOrigin);
|
|
401
|
+
return {
|
|
402
|
+
icon: joinPublicUrl(origin, '/assets/brand/thumbgate-icon-512.png'),
|
|
403
|
+
logo: joinPublicUrl(origin, '/assets/brand/thumbgate-logo-1200x360.png'),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function buildCheckoutBrandingSettings(appOrigin) {
|
|
408
|
+
const brandUrls = resolveCheckoutBrandUrls(appOrigin);
|
|
409
|
+
return {
|
|
410
|
+
display_name: 'ThumbGate',
|
|
411
|
+
logo: {
|
|
412
|
+
type: 'url',
|
|
413
|
+
url: brandUrls.logo,
|
|
414
|
+
},
|
|
415
|
+
background_color: '#ffffff',
|
|
416
|
+
button_color: '#22d3ee',
|
|
417
|
+
border_style: 'rounded',
|
|
418
|
+
font_family: 'inter',
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function buildCheckoutProductData({ name, description, appOrigin }) {
|
|
423
|
+
const brandUrls = resolveCheckoutBrandUrls(appOrigin);
|
|
424
|
+
return {
|
|
425
|
+
name,
|
|
426
|
+
description,
|
|
427
|
+
images: [brandUrls.icon],
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function buildSubscriptionPriceData(checkoutSelection, appOrigin) {
|
|
432
|
+
const isTeam = checkoutSelection.planId === 'team';
|
|
433
|
+
const annual = checkoutSelection.billingCycle === 'annual';
|
|
434
|
+
const unitAmount = isTeam
|
|
435
|
+
? TEAM_MONTHLY_PRICE_DOLLARS * 100
|
|
436
|
+
: (annual ? PRO_ANNUAL_PRICE_DOLLARS : PRO_MONTHLY_PRICE_DOLLARS) * 100;
|
|
437
|
+
return {
|
|
438
|
+
currency: 'usd',
|
|
439
|
+
unit_amount: unitAmount,
|
|
440
|
+
recurring: {
|
|
441
|
+
interval: annual ? 'year' : 'month',
|
|
442
|
+
},
|
|
443
|
+
product_data: buildCheckoutProductData({
|
|
444
|
+
name: isTeam ? 'ThumbGate Team' : 'ThumbGate Pro',
|
|
445
|
+
description: isTeam
|
|
446
|
+
? 'Shared Pre-Action Gates, team governance, and workflow hardening for AI coding agents.'
|
|
447
|
+
: 'Local dashboard, DPO export, and Pre-Action Gates for AI coding agents.',
|
|
448
|
+
appOrigin,
|
|
449
|
+
}),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function normalizeEmail(value) {
|
|
454
|
+
const text = normalizeText(value);
|
|
455
|
+
if (!text || !text.includes('@')) return null;
|
|
456
|
+
return text.toLowerCase();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function escapeHtml(value) {
|
|
460
|
+
return String(value)
|
|
461
|
+
.replace(/&/g, '&')
|
|
462
|
+
.replace(/</g, '<')
|
|
463
|
+
.replace(/>/g, '>')
|
|
464
|
+
.replace(/"/g, '"')
|
|
465
|
+
.replace(/'/g, ''');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function findTrialEmailRecord({ sessionId, customerEmail, statuses = null } = {}) {
|
|
469
|
+
const normalizedEmail = normalizeEmail(customerEmail);
|
|
470
|
+
const rows = loadJsonlRecords(CONFIG.TRIAL_EMAIL_LEDGER_PATH);
|
|
471
|
+
return rows.find((row) => {
|
|
472
|
+
if (!row || typeof row !== 'object') return false;
|
|
473
|
+
if (statuses && !statuses.includes(row.status)) return false;
|
|
474
|
+
if (sessionId && row.sessionId === sessionId) return true;
|
|
475
|
+
return normalizedEmail && row.customerEmail === normalizedEmail;
|
|
476
|
+
}) || null;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function appendTrialEmailRecord(payload) {
|
|
480
|
+
return appendJsonlRecord(CONFIG.TRIAL_EMAIL_LEDGER_PATH, {
|
|
481
|
+
timestamp: new Date().toISOString(),
|
|
482
|
+
provider: payload.provider || 'resend',
|
|
483
|
+
...payload,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Resolve the trial expiry date for a Stripe checkout session.
|
|
489
|
+
*
|
|
490
|
+
* Prefers an explicit `subscription.trial_end` unix timestamp when the session
|
|
491
|
+
* embeds one (subscriptions with trial_period_days populate it). Falls back to
|
|
492
|
+
* the session's `expires_at`, and finally to now + 7 days. Always returns a
|
|
493
|
+
* Date; never throws.
|
|
494
|
+
*/
|
|
495
|
+
function computeTrialEndAt(session) {
|
|
496
|
+
const TRIAL_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
497
|
+
if (session && session.subscription && typeof session.subscription === 'object') {
|
|
498
|
+
const trialEndUnix = session.subscription.trial_end;
|
|
499
|
+
if (typeof trialEndUnix === 'number' && trialEndUnix > 0) {
|
|
500
|
+
return new Date(trialEndUnix * 1000);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (session && typeof session.trial_end === 'number' && session.trial_end > 0) {
|
|
504
|
+
return new Date(session.trial_end * 1000);
|
|
505
|
+
}
|
|
506
|
+
return new Date(Date.now() + TRIAL_DAYS_MS);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function buildTrialActivationEmail({ customerEmail, apiKey, sessionId, planId, appOrigin } = {}) {
|
|
510
|
+
const email = normalizeEmail(customerEmail);
|
|
511
|
+
const origin = resolvePublicAppOrigin(appOrigin);
|
|
512
|
+
const dashboardUrl = joinPublicUrl(origin, '/dashboard');
|
|
513
|
+
const docsUrl = 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md';
|
|
514
|
+
const command = `npx thumbgate pro --activate --key=${apiKey || ''}`;
|
|
515
|
+
const subject = 'Your 7-day ThumbGate Pro trial is live';
|
|
516
|
+
const preheader = 'Activate Pro in one command, open the dashboard, and start blocking repeated AI coding mistakes.';
|
|
517
|
+
const headline = 'Your 7-day ThumbGate Pro trial is live.';
|
|
518
|
+
const intro = 'ThumbGate turns thumbs up/down feedback into Pre-Action Gates that stop repeated AI coding mistakes before the next tool call. It keeps lessons local and turns repeated mistakes into Reliability Gateway blocks.';
|
|
519
|
+
const exampleFeedback = 'thumbs down: the answer skipped exact files and tests; next time include paths, commands, and verification evidence.';
|
|
520
|
+
const safeDashboardUrl = escapeHtml(dashboardUrl);
|
|
521
|
+
const safeDocsUrl = escapeHtml(docsUrl);
|
|
522
|
+
const safeCommand = escapeHtml(command);
|
|
523
|
+
const safeApiKey = escapeHtml(apiKey || '');
|
|
524
|
+
return {
|
|
525
|
+
from: CONFIG.TRIAL_EMAIL_FROM,
|
|
526
|
+
to: [email],
|
|
527
|
+
reply_to: CONFIG.TRIAL_EMAIL_REPLY_TO,
|
|
528
|
+
subject,
|
|
529
|
+
text: [
|
|
530
|
+
headline,
|
|
531
|
+
'',
|
|
532
|
+
intro,
|
|
533
|
+
'',
|
|
534
|
+
'Next 3 minutes:',
|
|
535
|
+
'1. Activate Pro locally:',
|
|
536
|
+
command,
|
|
537
|
+
'',
|
|
538
|
+
`2. Open your dashboard: ${dashboardUrl}`,
|
|
539
|
+
'',
|
|
540
|
+
'3. Give one concrete thumbs up or thumbs down:',
|
|
541
|
+
exampleFeedback,
|
|
542
|
+
'',
|
|
543
|
+
'Your trial key:',
|
|
544
|
+
apiKey,
|
|
545
|
+
'',
|
|
546
|
+
`Verification evidence: ${docsUrl}`,
|
|
547
|
+
'Keep this key private. Questions? Reply to this email or write hello@thumbgate.app.',
|
|
548
|
+
sessionId ? `Stripe session: ${sessionId}` : null,
|
|
549
|
+
planId ? `Plan: ${planId}` : null,
|
|
550
|
+
].filter(Boolean).join('\n'),
|
|
551
|
+
html: `<!doctype html>
|
|
552
|
+
<html>
|
|
553
|
+
<body style="margin:0;background:#f5f7fb;padding:28px 12px;font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#17212b;">
|
|
554
|
+
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">${escapeHtml(preheader)}</div>
|
|
555
|
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;">
|
|
556
|
+
<tr>
|
|
557
|
+
<td align="center">
|
|
558
|
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;max-width:640px;background:#ffffff;border:1px solid #d8e2ea;border-radius:8px;overflow:hidden;">
|
|
559
|
+
<tr>
|
|
560
|
+
<td style="background:#071115;padding:22px 26px;color:#e7fbff;">
|
|
561
|
+
<div style="font-size:13px;font-weight:700;letter-spacing:0;text-transform:uppercase;color:#73d4e9;">ThumbGate Pro</div>
|
|
562
|
+
<h1 style="margin:12px 0 10px;font-size:28px;line-height:1.15;color:#ffffff;">${escapeHtml(headline)}</h1>
|
|
563
|
+
<p style="margin:0;font-size:15px;line-height:1.6;color:#c6d6de;">${escapeHtml(intro)}</p>
|
|
564
|
+
</td>
|
|
565
|
+
</tr>
|
|
566
|
+
<tr>
|
|
567
|
+
<td style="padding:26px;">
|
|
568
|
+
<p style="margin:0 0 18px;font-size:15px;line-height:1.6;color:#344451;">Run one command, open the dashboard, then give one concrete thumb signal. ThumbGate keeps the lesson local and turns repeated mistakes into Reliability Gateway blocks.</p>
|
|
569
|
+
<p style="margin:0 0 24px;">
|
|
570
|
+
<a href="${safeDashboardUrl}" style="display:inline-block;background:#45bfd8;color:#061015;text-decoration:none;font-weight:700;padding:12px 18px;border-radius:6px;">Open your dashboard</a>
|
|
571
|
+
</p>
|
|
572
|
+
|
|
573
|
+
<h2 style="margin:0 0 8px;font-size:17px;line-height:1.3;color:#17212b;">1. Activate Pro locally</h2>
|
|
574
|
+
<pre style="margin:0 0 22px;background:#081016;color:#d8f7e4;border:1px solid #23343d;border-radius:6px;padding:14px;font-size:13px;line-height:1.45;white-space:pre-wrap;word-break:break-word;"><code>${safeCommand}</code></pre>
|
|
575
|
+
|
|
576
|
+
<h2 style="margin:0 0 8px;font-size:17px;line-height:1.3;color:#17212b;">2. Save your trial key</h2>
|
|
577
|
+
<pre style="margin:0 0 22px;background:#eef6f7;color:#0b343c;border:1px solid #c7e2e7;border-radius:6px;padding:14px;font-size:13px;line-height:1.45;white-space:pre-wrap;word-break:break-word;"><code>${safeApiKey}</code></pre>
|
|
578
|
+
|
|
579
|
+
<h2 style="margin:0 0 8px;font-size:17px;line-height:1.3;color:#17212b;">3. Give one concrete thumbs up or thumbs down</h2>
|
|
580
|
+
<p style="margin:0 0 14px;font-size:14px;line-height:1.6;color:#344451;">Start with the failure you most want your agent to stop repeating.</p>
|
|
581
|
+
<pre style="margin:0 0 24px;background:#f1fff2;color:#22602b;border:1px solid #bae7c0;border-radius:6px;padding:14px;font-size:13px;line-height:1.45;white-space:pre-wrap;word-break:break-word;"><code>${escapeHtml(exampleFeedback)}</code></pre>
|
|
582
|
+
|
|
583
|
+
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;margin:0 0 22px;">
|
|
584
|
+
<tr>
|
|
585
|
+
<td style="border:1px solid #d8e2ea;border-radius:8px;padding:14px;background:#fbfdff;">
|
|
586
|
+
<strong style="display:block;margin:0 0 6px;font-size:14px;color:#17212b;">Why this matters now</strong>
|
|
587
|
+
<span style="font-size:13px;line-height:1.55;color:#526273;">One correction should become a permanent pre-action block, not a note the next agent forgets.</span>
|
|
588
|
+
</td>
|
|
589
|
+
</tr>
|
|
590
|
+
</table>
|
|
591
|
+
|
|
592
|
+
<p style="margin:0;font-size:13px;line-height:1.6;color:#526273;">
|
|
593
|
+
Proof trail: <a href="${safeDocsUrl}" style="color:#087a91;">verification evidence</a>.
|
|
594
|
+
Keep this key private. Questions? Reply here or write <a href="mailto:hello@thumbgate.app" style="color:#087a91;">hello@thumbgate.app</a>.
|
|
595
|
+
</p>
|
|
596
|
+
${sessionId ? `<p style="margin:12px 0 0;font-size:12px;line-height:1.5;color:#7a8790;">Stripe session: ${escapeHtml(sessionId)}</p>` : ''}
|
|
597
|
+
</td>
|
|
598
|
+
</tr>
|
|
599
|
+
</table>
|
|
600
|
+
</td>
|
|
601
|
+
</tr>
|
|
602
|
+
</table>
|
|
603
|
+
</body>
|
|
604
|
+
</html>`,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function sendResendEmail(message) {
|
|
609
|
+
return new Promise((resolve, reject) => {
|
|
610
|
+
const body = JSON.stringify(message);
|
|
611
|
+
const req = https.request({
|
|
612
|
+
hostname: 'api.resend.com',
|
|
613
|
+
path: '/emails',
|
|
614
|
+
method: 'POST',
|
|
615
|
+
headers: {
|
|
616
|
+
Authorization: `Bearer ${CONFIG.RESEND_API_KEY}`,
|
|
617
|
+
'Content-Type': 'application/json',
|
|
618
|
+
'Content-Length': Buffer.byteLength(body),
|
|
619
|
+
},
|
|
620
|
+
timeout: 10000,
|
|
621
|
+
}, (res) => {
|
|
622
|
+
let responseBody = '';
|
|
623
|
+
res.setEncoding('utf8');
|
|
624
|
+
res.on('data', (chunk) => { responseBody += chunk; });
|
|
625
|
+
res.on('end', () => {
|
|
626
|
+
let parsed = {};
|
|
627
|
+
try {
|
|
628
|
+
parsed = responseBody ? JSON.parse(responseBody) : {};
|
|
629
|
+
} catch {
|
|
630
|
+
parsed = { raw: responseBody };
|
|
631
|
+
}
|
|
632
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
633
|
+
resolve({ ok: true, statusCode: res.statusCode, body: parsed });
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
const err = new Error(parsed.message || parsed.error || `Resend API returned HTTP ${res.statusCode}`);
|
|
637
|
+
err.statusCode = res.statusCode;
|
|
638
|
+
err.body = parsed;
|
|
639
|
+
reject(err);
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
req.on('timeout', () => req.destroy(new Error('Resend API timeout')));
|
|
643
|
+
req.on('error', reject);
|
|
644
|
+
req.end(body);
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function sendTrialActivationEmail(params = {}, options = {}) {
|
|
649
|
+
const customerEmail = normalizeEmail(params.customerEmail);
|
|
650
|
+
const sessionId = normalizeText(params.sessionId);
|
|
651
|
+
const apiKey = normalizeText(params.apiKey);
|
|
652
|
+
const injectedMailer = module.exports && module.exports._mailer;
|
|
653
|
+
const mailerTransport = !options.transport && injectedMailer && typeof injectedMailer.sendTrialWelcomeEmail === 'function'
|
|
654
|
+
? injectedMailer
|
|
655
|
+
: null;
|
|
656
|
+
const transport = options.transport || sendResendEmail;
|
|
657
|
+
const planId = normalizeText(params.planId);
|
|
658
|
+
|
|
659
|
+
if (!customerEmail) {
|
|
660
|
+
return { status: 'skipped', reason: 'missing_customer_email' };
|
|
661
|
+
}
|
|
662
|
+
if (!apiKey) {
|
|
663
|
+
return { status: 'skipped', reason: 'missing_api_key', customerEmail };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const previousSent = findTrialEmailRecord({
|
|
667
|
+
sessionId,
|
|
668
|
+
customerEmail,
|
|
669
|
+
statuses: ['sent'],
|
|
670
|
+
});
|
|
671
|
+
if (previousSent) {
|
|
672
|
+
return {
|
|
673
|
+
status: 'already_sent',
|
|
674
|
+
customerEmail,
|
|
675
|
+
sessionId: previousSent.sessionId || sessionId,
|
|
676
|
+
providerId: previousSent.providerId || null,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (!CONFIG.RESEND_API_KEY && !options.transport && !mailerTransport) {
|
|
681
|
+
const previousSkipped = findTrialEmailRecord({
|
|
682
|
+
sessionId,
|
|
683
|
+
customerEmail,
|
|
684
|
+
statuses: ['skipped'],
|
|
685
|
+
});
|
|
686
|
+
if (!previousSkipped) {
|
|
687
|
+
appendTrialEmailRecord({
|
|
688
|
+
status: 'skipped',
|
|
689
|
+
reason: 'missing_resend_api_key',
|
|
690
|
+
sessionId,
|
|
691
|
+
customerEmail,
|
|
692
|
+
planId,
|
|
693
|
+
source: params.source || 'checkout_session_status',
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
return { status: 'skipped', reason: 'missing_resend_api_key', customerEmail, sessionId };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
try {
|
|
700
|
+
let providerId = null;
|
|
701
|
+
if (mailerTransport) {
|
|
702
|
+
const response = await mailerTransport.sendTrialWelcomeEmail({
|
|
703
|
+
to: customerEmail,
|
|
704
|
+
licenseKey: apiKey,
|
|
705
|
+
customerId: params.customerId,
|
|
706
|
+
customerName: params.customerName,
|
|
707
|
+
trialEndAt: params.trialEndAt,
|
|
708
|
+
});
|
|
709
|
+
if (!response || response.sent !== true) {
|
|
710
|
+
const rawReason = normalizeText(response && response.reason) || 'provider_error';
|
|
711
|
+
// Normalize the mailer module's `no_api_key` to billing.js's legacy
|
|
712
|
+
// `missing_resend_api_key` reason so downstream consumers (dashboards,
|
|
713
|
+
// tests, support tooling) see a stable vocabulary regardless of which
|
|
714
|
+
// transport produced the skip.
|
|
715
|
+
const reason = rawReason === 'no_api_key' ? 'missing_resend_api_key' : rawReason;
|
|
716
|
+
const isSkipped = reason === 'missing_resend_api_key';
|
|
717
|
+
const previousSkipped = isSkipped
|
|
718
|
+
? findTrialEmailRecord({ sessionId, customerEmail, statuses: ['skipped'] })
|
|
719
|
+
: null;
|
|
720
|
+
if (!isSkipped || !previousSkipped) {
|
|
721
|
+
appendTrialEmailRecord({
|
|
722
|
+
status: isSkipped ? 'skipped' : 'failed',
|
|
723
|
+
reason,
|
|
724
|
+
sessionId,
|
|
725
|
+
customerEmail,
|
|
726
|
+
planId,
|
|
727
|
+
source: params.source || 'checkout_session_status',
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
return {
|
|
731
|
+
status: isSkipped ? 'skipped' : 'failed',
|
|
732
|
+
reason,
|
|
733
|
+
customerEmail,
|
|
734
|
+
sessionId,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
providerId = response.id || response.providerId || null;
|
|
738
|
+
} else {
|
|
739
|
+
const message = buildTrialActivationEmail({
|
|
740
|
+
customerEmail,
|
|
741
|
+
apiKey,
|
|
742
|
+
sessionId,
|
|
743
|
+
planId,
|
|
744
|
+
appOrigin: params.appOrigin,
|
|
745
|
+
});
|
|
746
|
+
const response = await transport(message, params);
|
|
747
|
+
providerId = response && response.body ? response.body.id : response && response.id ? response.id : null;
|
|
748
|
+
}
|
|
749
|
+
appendTrialEmailRecord({
|
|
750
|
+
status: 'sent',
|
|
751
|
+
sessionId,
|
|
752
|
+
customerEmail,
|
|
753
|
+
planId,
|
|
754
|
+
providerId,
|
|
755
|
+
source: params.source || 'checkout_session_status',
|
|
756
|
+
});
|
|
757
|
+
return { status: 'sent', customerEmail, sessionId, providerId };
|
|
758
|
+
} catch (err) {
|
|
759
|
+
const reason = mailerTransport ? 'exception' : 'provider_error';
|
|
760
|
+
appendTrialEmailRecord({
|
|
761
|
+
status: 'failed',
|
|
762
|
+
reason,
|
|
763
|
+
error: err && err.message ? err.message : 'Email provider failed',
|
|
764
|
+
sessionId,
|
|
765
|
+
customerEmail,
|
|
766
|
+
planId,
|
|
767
|
+
source: params.source || 'checkout_session_status',
|
|
768
|
+
});
|
|
769
|
+
return {
|
|
770
|
+
status: 'failed',
|
|
771
|
+
reason,
|
|
772
|
+
error: err && err.message ? err.message : 'Email provider failed',
|
|
773
|
+
customerEmail,
|
|
774
|
+
sessionId,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function trialEmailToWebhookEmailResult(trialEmail = {}) {
|
|
780
|
+
if (trialEmail.status === 'sent' || trialEmail.status === 'already_sent') {
|
|
781
|
+
return {
|
|
782
|
+
sent: true,
|
|
783
|
+
id: trialEmail.providerId || null,
|
|
784
|
+
providerId: trialEmail.providerId || null,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
return {
|
|
788
|
+
sent: false,
|
|
789
|
+
reason: trialEmail.reason === 'missing_customer_email'
|
|
790
|
+
? 'no_recipient'
|
|
791
|
+
: trialEmail.reason || trialEmail.status || 'unknown',
|
|
792
|
+
error: trialEmail.error || undefined,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
382
796
|
function normalizeCurrency(value) {
|
|
383
797
|
const text = normalizeText(value);
|
|
384
798
|
return text ? text.toUpperCase() : null;
|
|
@@ -1972,7 +2386,7 @@ function saveKeyStore(store) {
|
|
|
1972
2386
|
// Core Exports
|
|
1973
2387
|
// ---------------------------------------------------------------------------
|
|
1974
2388
|
|
|
1975
|
-
async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, installId, traceId, packId = null, metadata = {} } = {}) {
|
|
2389
|
+
async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, installId, traceId, packId = null, metadata = {}, appOrigin } = {}) {
|
|
1976
2390
|
const resolvedTraceId = traceId || metadata.traceId || createTraceId('checkout');
|
|
1977
2391
|
const baseCheckoutMetadata = sanitizeMetadata({
|
|
1978
2392
|
...metadata,
|
|
@@ -1995,9 +2409,12 @@ async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, ins
|
|
|
1995
2409
|
const localSessionId = `test_session_${crypto.randomBytes(8).toString('hex')}`;
|
|
1996
2410
|
const store = loadLocalCheckoutSessions();
|
|
1997
2411
|
const pack = packId ? CONFIG.CREDIT_PACKS[packId] : null;
|
|
2412
|
+
const localCustomerEmail = normalizeEmail(customerEmail);
|
|
1998
2413
|
store.sessions[localSessionId] = {
|
|
1999
2414
|
id: localSessionId,
|
|
2000
2415
|
customer: `local_cus_${crypto.randomBytes(4).toString('hex')}`,
|
|
2416
|
+
customer_email: localCustomerEmail,
|
|
2417
|
+
customer_details: localCustomerEmail ? { email: localCustomerEmail } : null,
|
|
2001
2418
|
metadata: { ...checkoutMetadata, packId: pack ? pack.id : null, credits: pack ? pack.credits : null },
|
|
2002
2419
|
payment_status: 'paid',
|
|
2003
2420
|
status: 'complete'
|
|
@@ -2022,8 +2439,19 @@ async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, ins
|
|
|
2022
2439
|
customerEmail,
|
|
2023
2440
|
checkoutMetadata,
|
|
2024
2441
|
packId,
|
|
2442
|
+
appOrigin,
|
|
2025
2443
|
});
|
|
2026
|
-
|
|
2444
|
+
let session;
|
|
2445
|
+
try {
|
|
2446
|
+
session = await stripe.checkout.sessions.create(sessionPayload);
|
|
2447
|
+
} catch (err) {
|
|
2448
|
+
if (!sessionPayload.branding_settings || !String(err && err.message).includes('branding_settings')) {
|
|
2449
|
+
throw err;
|
|
2450
|
+
}
|
|
2451
|
+
const fallbackPayload = { ...sessionPayload };
|
|
2452
|
+
delete fallbackPayload.branding_settings;
|
|
2453
|
+
session = await stripe.checkout.sessions.create(fallbackPayload);
|
|
2454
|
+
}
|
|
2027
2455
|
|
|
2028
2456
|
appendFunnelEvent({
|
|
2029
2457
|
stage: 'acquisition',
|
|
@@ -2036,7 +2464,7 @@ async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, ins
|
|
|
2036
2464
|
return { sessionId: session.id, url: session.url, localMode: false, traceId: resolvedTraceId, metadata: checkoutMetadata };
|
|
2037
2465
|
}
|
|
2038
2466
|
|
|
2039
|
-
function buildCheckoutSessionPayload({ successUrl, cancelUrl, customerEmail, checkoutMetadata, packId = null } = {}) {
|
|
2467
|
+
function buildCheckoutSessionPayload({ successUrl, cancelUrl, customerEmail, checkoutMetadata, packId = null, appOrigin } = {}) {
|
|
2040
2468
|
const pack = packId ? CONFIG.CREDIT_PACKS[packId] : null;
|
|
2041
2469
|
const checkoutSelection = pack ? null : resolveSubscriptionCheckoutSelection(checkoutMetadata);
|
|
2042
2470
|
if (!pack && !checkoutSelection.priceId) {
|
|
@@ -2046,12 +2474,19 @@ function buildCheckoutSessionPayload({ successUrl, cancelUrl, customerEmail, che
|
|
|
2046
2474
|
? [{
|
|
2047
2475
|
price_data: {
|
|
2048
2476
|
currency: pack.currency.toLowerCase(),
|
|
2049
|
-
product_data: {
|
|
2477
|
+
product_data: buildCheckoutProductData({
|
|
2478
|
+
name: pack.name,
|
|
2479
|
+
description: 'ThumbGate usage credits for hosted agent governance.',
|
|
2480
|
+
appOrigin,
|
|
2481
|
+
}),
|
|
2050
2482
|
unit_amount: pack.amountCents,
|
|
2051
2483
|
},
|
|
2052
2484
|
quantity: 1,
|
|
2053
2485
|
}]
|
|
2054
|
-
: [{
|
|
2486
|
+
: [{
|
|
2487
|
+
price_data: buildSubscriptionPriceData(checkoutSelection, appOrigin),
|
|
2488
|
+
quantity: checkoutSelection.quantity,
|
|
2489
|
+
}];
|
|
2055
2490
|
|
|
2056
2491
|
const sessionPayload = {
|
|
2057
2492
|
success_url: successUrl,
|
|
@@ -2059,6 +2494,7 @@ function buildCheckoutSessionPayload({ successUrl, cancelUrl, customerEmail, che
|
|
|
2059
2494
|
payment_method_types: ['card', 'link'],
|
|
2060
2495
|
mode: pack ? 'payment' : 'subscription',
|
|
2061
2496
|
line_items: lineItems,
|
|
2497
|
+
branding_settings: buildCheckoutBrandingSettings(appOrigin),
|
|
2062
2498
|
metadata: serializeStripeMetadata({
|
|
2063
2499
|
...checkoutMetadata,
|
|
2064
2500
|
planId: pack ? checkoutMetadata.planId : checkoutSelection.planId,
|
|
@@ -2092,6 +2528,20 @@ async function getCheckoutSessionStatus(sessionId) {
|
|
|
2092
2528
|
credits: session.metadata?.credits,
|
|
2093
2529
|
source: 'local_checkout_lookup'
|
|
2094
2530
|
});
|
|
2531
|
+
const customerEmail = session.customer_details?.email || session.customer_email || '';
|
|
2532
|
+
const customerName = session.customer_details?.name || null;
|
|
2533
|
+
const trialEndAt = computeTrialEndAt(session);
|
|
2534
|
+
const trialEmail = await sendTrialActivationEmail({
|
|
2535
|
+
sessionId,
|
|
2536
|
+
customerId: session.customer,
|
|
2537
|
+
customerEmail,
|
|
2538
|
+
customerName,
|
|
2539
|
+
trialEndAt,
|
|
2540
|
+
apiKey: provisioned.key,
|
|
2541
|
+
planId: session.metadata?.planId || session.metadata?.packId || null,
|
|
2542
|
+
appOrigin: process.env.THUMBGATE_PUBLIC_APP_ORIGIN,
|
|
2543
|
+
source: 'local_checkout_lookup',
|
|
2544
|
+
});
|
|
2095
2545
|
return {
|
|
2096
2546
|
found: true,
|
|
2097
2547
|
localMode: true,
|
|
@@ -2100,6 +2550,7 @@ async function getCheckoutSessionStatus(sessionId) {
|
|
|
2100
2550
|
paymentStatus: 'paid',
|
|
2101
2551
|
status: 'complete',
|
|
2102
2552
|
customerId: session.customer,
|
|
2553
|
+
customerEmail,
|
|
2103
2554
|
installId: session.metadata?.installId,
|
|
2104
2555
|
traceId: session.metadata?.traceId || null,
|
|
2105
2556
|
acquisitionId: session.metadata?.acquisitionId || null,
|
|
@@ -2112,6 +2563,7 @@ async function getCheckoutSessionStatus(sessionId) {
|
|
|
2112
2563
|
referrerHost: session.metadata?.referrerHost || null,
|
|
2113
2564
|
apiKey: provisioned.key,
|
|
2114
2565
|
remainingCredits: provisioned.remainingCredits,
|
|
2566
|
+
trialEmail,
|
|
2115
2567
|
};
|
|
2116
2568
|
}
|
|
2117
2569
|
|
|
@@ -2126,6 +2578,20 @@ async function getCheckoutSessionStatus(sessionId) {
|
|
|
2126
2578
|
const installId = session.metadata?.installId || null;
|
|
2127
2579
|
const credits = session.metadata?.credits ? parseInt(session.metadata.credits, 10) : null;
|
|
2128
2580
|
const provisioned = provisionApiKey(session.customer, { installId, credits, source: 'stripe_checkout_session_lookup' });
|
|
2581
|
+
const customerEmail = session.customer_details?.email || session.customer_email || '';
|
|
2582
|
+
const customerName = session.customer_details?.name || null;
|
|
2583
|
+
const trialEndAt = computeTrialEndAt(session);
|
|
2584
|
+
const trialEmail = await sendTrialActivationEmail({
|
|
2585
|
+
sessionId,
|
|
2586
|
+
customerId: session.customer,
|
|
2587
|
+
customerEmail,
|
|
2588
|
+
customerName,
|
|
2589
|
+
trialEndAt,
|
|
2590
|
+
apiKey: provisioned.key,
|
|
2591
|
+
planId: session.metadata?.planId || session.metadata?.packId || null,
|
|
2592
|
+
appOrigin: process.env.THUMBGATE_PUBLIC_APP_ORIGIN,
|
|
2593
|
+
source: 'stripe_checkout_session_lookup',
|
|
2594
|
+
});
|
|
2129
2595
|
|
|
2130
2596
|
return {
|
|
2131
2597
|
found: true,
|
|
@@ -2134,7 +2600,7 @@ async function getCheckoutSessionStatus(sessionId) {
|
|
|
2134
2600
|
paid: true,
|
|
2135
2601
|
paymentStatus: session.payment_status,
|
|
2136
2602
|
customerId: session.customer,
|
|
2137
|
-
customerEmail
|
|
2603
|
+
customerEmail,
|
|
2138
2604
|
installId,
|
|
2139
2605
|
traceId,
|
|
2140
2606
|
acquisitionId: session.metadata?.acquisitionId || null,
|
|
@@ -2147,6 +2613,7 @@ async function getCheckoutSessionStatus(sessionId) {
|
|
|
2147
2613
|
referrerHost: session.metadata?.referrerHost || null,
|
|
2148
2614
|
apiKey: provisioned.key,
|
|
2149
2615
|
remainingCredits: provisioned.remainingCredits,
|
|
2616
|
+
trialEmail,
|
|
2150
2617
|
};
|
|
2151
2618
|
} catch {
|
|
2152
2619
|
return { found: false };
|
|
@@ -2312,6 +2779,9 @@ async function handleWebhook(rawBody, signature) {
|
|
|
2312
2779
|
const traceId = session.metadata?.traceId || null;
|
|
2313
2780
|
const credits = session.metadata?.credits ? parseInt(session.metadata.credits, 10) : null;
|
|
2314
2781
|
const packId = session.metadata?.packId || null;
|
|
2782
|
+
const customerEmail = session.customer_details?.email || session.customer_email || '';
|
|
2783
|
+
const customerName = session.customer_details?.name || null;
|
|
2784
|
+
const trialEndAt = computeTrialEndAt(session);
|
|
2315
2785
|
|
|
2316
2786
|
const attribution = extractAttribution(session.metadata);
|
|
2317
2787
|
const result = provisionApiKey(customerId, {
|
|
@@ -2319,6 +2789,17 @@ async function handleWebhook(rawBody, signature) {
|
|
|
2319
2789
|
credits,
|
|
2320
2790
|
source: 'stripe_webhook_checkout_completed'
|
|
2321
2791
|
});
|
|
2792
|
+
const trialEmail = await sendTrialActivationEmail({
|
|
2793
|
+
sessionId: session.id,
|
|
2794
|
+
customerId,
|
|
2795
|
+
customerEmail,
|
|
2796
|
+
customerName,
|
|
2797
|
+
trialEndAt,
|
|
2798
|
+
apiKey: result.key,
|
|
2799
|
+
planId: session.metadata?.planId || packId || null,
|
|
2800
|
+
appOrigin: process.env.THUMBGATE_PUBLIC_APP_ORIGIN,
|
|
2801
|
+
source: 'stripe_webhook_checkout_completed',
|
|
2802
|
+
});
|
|
2322
2803
|
const funnelRecord = {
|
|
2323
2804
|
stage: 'paid',
|
|
2324
2805
|
event: 'stripe_checkout_completed',
|
|
@@ -2384,7 +2865,13 @@ async function handleWebhook(rawBody, signature) {
|
|
|
2384
2865
|
attribution,
|
|
2385
2866
|
});
|
|
2386
2867
|
}
|
|
2387
|
-
return {
|
|
2868
|
+
return {
|
|
2869
|
+
handled: true,
|
|
2870
|
+
action: 'provisioned_api_key',
|
|
2871
|
+
result,
|
|
2872
|
+
trialEmail,
|
|
2873
|
+
email: trialEmailToWebhookEmailResult(trialEmail),
|
|
2874
|
+
};
|
|
2388
2875
|
}
|
|
2389
2876
|
case 'customer.subscription.deleted': {
|
|
2390
2877
|
const sub = event.data.object;
|
|
@@ -2527,11 +3014,19 @@ function handleGithubWebhook(event) {
|
|
|
2527
3014
|
module.exports = {
|
|
2528
3015
|
CONFIG, createCheckoutSession, getCheckoutSessionStatus, provisionApiKey, rotateApiKey, validateApiKey, recordUsage, disableCustomerKeys, handleWebhook, verifyWebhookSignature, verifyGithubWebhookSignature, handleGithubWebhook, loadKeyStore, appendFunnelEvent, appendRevenueEvent, loadFunnelLedger, loadRevenueLedger, loadNewsletterSubscribers, loadResolvedRevenueEvents, getFunnelAnalytics, getBusinessAnalytics, getBillingSummary, getBillingSummaryLive, listStripeReconciledRevenueEvents, repairGithubMarketplaceRevenueLedger,
|
|
2529
3016
|
_buildCheckoutSessionPayload: buildCheckoutSessionPayload,
|
|
3017
|
+
_buildTrialActivationEmail: buildTrialActivationEmail,
|
|
3018
|
+
_sendTrialActivationEmail: sendTrialActivationEmail,
|
|
2530
3019
|
_resolveSubscriptionCheckoutSelection: resolveSubscriptionCheckoutSelection,
|
|
2531
3020
|
_API_KEYS_PATH: () => CONFIG.API_KEYS_PATH,
|
|
2532
3021
|
_FUNNEL_LEDGER_PATH: () => CONFIG.FUNNEL_LEDGER_PATH,
|
|
2533
3022
|
_REVENUE_LEDGER_PATH: () => CONFIG.REVENUE_LEDGER_PATH,
|
|
2534
3023
|
_LOCAL_CHECKOUT_SESSIONS_PATH: () => CONFIG.LOCAL_CHECKOUT_SESSIONS_PATH,
|
|
3024
|
+
_TRIAL_EMAIL_LEDGER_PATH: () => CONFIG.TRIAL_EMAIL_LEDGER_PATH,
|
|
2535
3025
|
_LOCAL_MODE: () => LOCAL_MODE(),
|
|
2536
3026
|
_withTimeout: withTimeout,
|
|
3027
|
+
// Default to the real Resend-backed mailer so production webhooks send the
|
|
3028
|
+
// marketing-grade trial-welcome template. Tests overwrite this with a stub
|
|
3029
|
+
// (freshBilling() re-requires the module so the default is restored between
|
|
3030
|
+
// tests — see tests/billing-webhook-email.test.js).
|
|
3031
|
+
_mailer: mailer,
|
|
2537
3032
|
};
|
package/scripts/contextfs.js
CHANGED
|
@@ -90,7 +90,7 @@ const PACK_TEMPLATES = {
|
|
|
90
90
|
namespaces: ['research', 'memoryLearning', 'rules'],
|
|
91
91
|
maxItems: 12,
|
|
92
92
|
maxChars: 10000,
|
|
93
|
-
queryPrefix: 'research benchmark experiment reliability',
|
|
93
|
+
queryPrefix: 'research benchmark experiment holdout proof reward hacking reliability',
|
|
94
94
|
},
|
|
95
95
|
'gtm-research': {
|
|
96
96
|
namespaces: ['research', 'memoryLearning'],
|