thumbgate 1.27.18 → 1.27.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/dashboard.md +15 -0
- package/.claude/commands/thumbgate-blocked.md +27 -0
- package/.claude/commands/thumbgate-dashboard.md +15 -0
- package/.claude/commands/thumbgate-doctor.md +30 -0
- package/.claude/commands/thumbgate-guard.md +36 -0
- package/.claude/commands/thumbgate-protect.md +30 -0
- package/.claude/commands/thumbgate-rules.md +30 -0
- package/.claude-plugin/plugin.json +4 -3
- package/.well-known/agentic-verify.txt +1 -0
- package/.well-known/llms.txt +33 -12
- package/.well-known/mcp/server-card.json +8 -8
- package/README.md +246 -30
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +2 -2
- package/adapters/gcp/dfcx-webhook-gate.js +295 -0
- package/adapters/gemini/function-declarations.json +1 -0
- package/adapters/letta/README.md +41 -0
- package/adapters/letta/thumbgate-letta-adapter.js +133 -0
- package/adapters/mcp/server-stdio.js +263 -11
- package/adapters/opencode/opencode.json +1 -1
- package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
- package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
- package/bench/observability-eval-suite.json +26 -0
- package/bench/thumbgate-bench.json +2 -2
- package/bin/cli.js +1456 -122
- package/bin/dashboard-cli.js +7 -0
- package/bin/postinstall.js +11 -27
- package/commands/dashboard.md +15 -0
- package/commands/thumbgate-dashboard.md +15 -0
- package/config/gate-classifier-routing.json +98 -0
- package/config/gate-templates.json +216 -0
- package/config/gates/claim-verification.json +12 -0
- package/config/gates/default.json +31 -2
- package/config/github-about.json +2 -2
- package/config/mcp-allowlists.json +23 -13
- package/config/merge-quality-checks.json +0 -1
- package/config/model-candidates.json +121 -6
- package/config/post-deploy-marketing-pages.json +80 -0
- package/config/tessl-tiles.json +1 -3
- package/openapi/openapi.yaml +12 -0
- package/package.json +225 -100
- package/public/about.html +162 -0
- package/public/agent-manager.html +179 -0
- package/public/agents-cost-savings.html +153 -0
- package/public/ai-malpractice-prevention.html +818 -0
- package/public/assets/brand/github-social-preview.png +0 -0
- package/public/assets/brand/thumbgate-icon-512.png +0 -0
- package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
- package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
- package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
- package/public/assets/brand/thumbgate-logo-transparent.svg +28 -0
- package/public/assets/brand/thumbgate-mark-inline-v3.svg +18 -0
- package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
- package/public/assets/brand/thumbgate-mark-team.svg +26 -0
- package/public/assets/brand/thumbgate-mark.svg +21 -0
- package/public/assets/brand/thumbgate-wordmark.svg +20 -0
- package/public/assets/claude-thumbgate-statusbar.svg +8 -0
- package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
- package/public/assets/legal-intake-control-flow.svg +66 -0
- package/public/blog.html +4 -4
- package/public/brand/thumbgate-mark.svg +19 -0
- package/public/brand/thumbgate-og.svg +16 -0
- package/public/chatgpt-app.html +330 -0
- package/public/codex-enterprise.html +123 -0
- package/public/codex-plugin.html +72 -20
- package/public/compare.html +31 -8
- package/public/dashboard.html +930 -166
- package/public/diagnostic.html +345 -0
- package/public/federal.html +2 -2
- package/public/guide.html +33 -13
- package/public/index.html +469 -111
- package/public/install.html +193 -0
- package/public/js/buyer-intent.js +672 -0
- package/public/learn.html +183 -18
- package/public/lessons.html +168 -10
- package/public/numbers.html +7 -7
- package/public/pricing.html +399 -0
- package/public/pro.html +34 -11
- package/scripts/action-receipts.js +324 -0
- package/scripts/activation-quickstart.js +187 -0
- package/scripts/agent-memory-lifecycle.js +211 -0
- package/scripts/agent-operations-planner.js +621 -0
- package/scripts/agent-readiness.js +20 -3
- package/scripts/agent-reward-model.js +53 -1
- package/scripts/ai-component-inventory.js +367 -0
- package/scripts/async-eval-observability.js +236 -0
- package/scripts/audit.js +65 -0
- package/scripts/auto-promote-gates.js +82 -10
- package/scripts/auto-wire-hooks.js +14 -0
- package/scripts/aws-blocks-guardrails.js +272 -0
- package/scripts/billing.js +93 -1
- package/scripts/bot-detection.js +61 -3
- package/scripts/build-metadata.js +50 -10
- package/scripts/classifier-routing.js +130 -0
- package/scripts/cli-feedback.js +4 -2
- package/scripts/cli-schema.js +97 -0
- package/scripts/cli-telemetry.js +6 -1
- package/scripts/commercial-offer.js +82 -2
- package/scripts/context-manager.js +74 -6
- package/scripts/dashboard-chat.js +332 -0
- package/scripts/dashboard.js +68 -2
- package/scripts/export-databricks-bundle.js +5 -1
- package/scripts/export-dpo-pairs.js +7 -2
- package/scripts/feedback-aggregate.js +281 -0
- package/scripts/feedback-loop.js +123 -1
- package/scripts/feedback-quality.js +87 -0
- package/scripts/feedback-sanitizer.js +105 -0
- package/scripts/filesystem-search.js +35 -10
- package/scripts/gate-stats.js +89 -0
- package/scripts/gates-engine.js +1176 -85
- package/scripts/gemini-embedding-policy.js +2 -1
- package/scripts/hook-runtime.js +20 -14
- package/scripts/hook-stop-anti-claim.js +301 -0
- package/scripts/hook-thumbgate-cache-updater.js +18 -2
- package/scripts/hybrid-feedback-context.js +142 -7
- package/scripts/install-shim.js +87 -0
- package/scripts/lesson-inference.js +8 -3
- package/scripts/lesson-search.js +17 -1
- package/scripts/license.js +10 -10
- package/scripts/llm-client.js +169 -4
- package/scripts/local-model-profile.js +15 -8
- package/scripts/mcp-config.js +7 -1
- package/scripts/mcp-oauth.js +293 -0
- package/scripts/memory-scope-readiness.js +159 -0
- package/scripts/meta-agent-loop.js +36 -0
- package/scripts/noop-detect.js +285 -0
- package/scripts/operational-integrity.js +39 -5
- package/scripts/oss-pr-opportunity-scout.js +35 -5
- package/scripts/parallel-workflow-orchestrator.js +293 -0
- package/scripts/plan-gate.js +243 -0
- package/scripts/plausible-domain-config.js +99 -0
- package/scripts/plausible-server-events.js +9 -6
- package/scripts/pro-local-dashboard.js +4 -4
- package/scripts/proxy-pointer-rag-guardrails.js +42 -1
- package/scripts/published-cli.js +0 -8
- package/scripts/qa-scenario-planner.js +136 -0
- package/scripts/rate-limiter.js +64 -13
- package/scripts/repeat-metric.js +137 -0
- package/scripts/secret-fixture-tokens.js +61 -0
- package/scripts/secret-redaction.js +166 -0
- package/scripts/secret-scanner.js +44 -5
- package/scripts/security-scanner.js +260 -10
- package/scripts/self-distill-agent.js +3 -1
- package/scripts/self-harness-optimizer.js +141 -0
- package/scripts/self-healing-check.js +193 -0
- package/scripts/self-protection.js +90 -0
- package/scripts/seo-gsd.js +916 -7
- package/scripts/silent-failure-cluster.js +531 -0
- package/scripts/statusline-cache-path.js +17 -2
- package/scripts/statusline-cache-read.js +57 -0
- package/scripts/statusline-local-stats.js +9 -1
- package/scripts/statusline-meta.js +28 -2
- package/scripts/statusline.sh +20 -4
- package/scripts/sync-telemetry-from-prod.js +374 -0
- package/scripts/telemetry-analytics.js +357 -0
- package/scripts/thompson-sampling.js +31 -10
- package/scripts/thumbgate-bench.js +16 -1
- package/scripts/thumbgate-search.js +85 -19
- package/scripts/tool-contract-validator.js +76 -0
- package/scripts/tool-registry.js +169 -1
- package/scripts/trajectory-scorer.js +63 -0
- package/scripts/vector-store.js +45 -0
- package/scripts/verify-marketing-pages-deployed.js +212 -0
- package/scripts/visitor-journey.js +172 -0
- package/scripts/workflow-sentinel.js +286 -53
- package/scripts/workspace-evolver.js +62 -2
- package/src/api/server.js +2683 -319
- package/.claude-plugin/marketplace.json +0 -85
- package/adapters/chatgpt/openapi.yaml +0 -1695
- package/scripts/bot-detector.js +0 -50
package/scripts/audit.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* scripts/audit.js
|
|
5
|
+
*
|
|
6
|
+
* Heuristic-based AI bill auditor. Finds repeated mistakes in agent transcripts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
|
|
11
|
+
const PATTERNS = [
|
|
12
|
+
{
|
|
13
|
+
id: 'force-push-retry',
|
|
14
|
+
name: 'git push --force after correction',
|
|
15
|
+
regex: /git\s+push.*--force/gi,
|
|
16
|
+
tokenEstimate: 6000,
|
|
17
|
+
costPerRepeat: 0.44,
|
|
18
|
+
why: 'Full diff context reload on error.'
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: 'import-loop',
|
|
22
|
+
name: 'Hallucinated import retry',
|
|
23
|
+
regex: /(Cannot find module|Module not found|import .* from .*error)/gi,
|
|
24
|
+
tokenEstimate: 4000,
|
|
25
|
+
costPerRepeat: 0.12,
|
|
26
|
+
why: 'Re-indexing and path searching.'
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'apology-loop',
|
|
30
|
+
name: '"I apologize" retry cycle',
|
|
31
|
+
regex: /(I apologize|Let me try a different approach|I will now attempt)/gi,
|
|
32
|
+
tokenEstimate: 5000,
|
|
33
|
+
costPerRepeat: 0.15,
|
|
34
|
+
why: 'Reasoning chain reset.'
|
|
35
|
+
}
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function runAudit(filePath) {
|
|
39
|
+
if (!fs.existsSync(filePath)) {
|
|
40
|
+
return { error: 'File not found: ' + filePath };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
44
|
+
const results = [];
|
|
45
|
+
let totalWaste = 0;
|
|
46
|
+
|
|
47
|
+
PATTERNS.forEach(p => {
|
|
48
|
+
const matches = (content.match(p.regex) || []).length;
|
|
49
|
+
if (matches > 1) { // It's only a "repeat" if it happens more than once
|
|
50
|
+
const repeats = matches - 1;
|
|
51
|
+
const waste = repeats * p.costPerRepeat;
|
|
52
|
+
totalWaste += waste;
|
|
53
|
+
results.push({
|
|
54
|
+
pattern: p.name,
|
|
55
|
+
occurrences: repeats,
|
|
56
|
+
waste: waste.toFixed(2),
|
|
57
|
+
why: p.why
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return { results, totalWaste: totalWaste.toFixed(2) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { runAudit, PATTERNS };
|
|
@@ -58,6 +58,47 @@ function readJSONL(filePath) {
|
|
|
58
58
|
}).filter(Boolean);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// --- Self-Harness stage 3: regression-gated promotion -----------------------
|
|
62
|
+
// Inspired by "Self-Harness: Harnesses That Improve Themselves" (arXiv 2606.09498).
|
|
63
|
+
// Stages 1-2 (weakness mining -> rule extraction) already exist via lesson
|
|
64
|
+
// inference + this promoter. Stage 3 — accept a harness change only after
|
|
65
|
+
// regression-testing it does not degrade behavior — was missing: a noisy 3x
|
|
66
|
+
// capture could hard-block an over-broad pattern with no check that it wouldn't
|
|
67
|
+
// have wrongly blocked actions that were previously ALLOWED. This replays a
|
|
68
|
+
// candidate BLOCK rule against the audit trail's prior `allow` decisions; if it
|
|
69
|
+
// would have blocked safe actions, the caller quarantines it to `warn` instead.
|
|
70
|
+
const REGRESSION_FALSE_BLOCK_LIMIT = 0; // any prior safe action it would block => quarantine
|
|
71
|
+
|
|
72
|
+
function getAuditTrailPath() {
|
|
73
|
+
return path.join(path.dirname(getFeedbackLogPath()), 'audit-trail.jsonl');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Returns { falseBlocks, allowSampleSize } or null when there is no history /
|
|
77
|
+
// matcher available — in which case the caller promotes as usual (fail-open to
|
|
78
|
+
// existing behavior, since regression gating is an enhancement, not a hard gate).
|
|
79
|
+
function regressionCheck(gate, options = {}) {
|
|
80
|
+
const auditPath = options.auditTrailPath || getAuditTrailPath();
|
|
81
|
+
const entries = readJSONL(auditPath);
|
|
82
|
+
if (!entries.length) return null;
|
|
83
|
+
// Lazy-require to avoid the gates-engine <-> auto-promote-gates require cycle.
|
|
84
|
+
let matchesGate;
|
|
85
|
+
try { ({ matchesGate } = require('./gates-engine')); } catch { return null; }
|
|
86
|
+
if (typeof matchesGate !== 'function') return null;
|
|
87
|
+
const allowed = entries.filter((e) => e && e.decision === 'allow' && e.toolName);
|
|
88
|
+
if (!allowed.length) return null;
|
|
89
|
+
let falseBlocks = 0;
|
|
90
|
+
for (const e of allowed) {
|
|
91
|
+
try {
|
|
92
|
+
if (matchesGate(gate, e.toolName, e.toolInput || {})) falseBlocks += 1;
|
|
93
|
+
} catch { /* a bad pattern/entry never counts as a false block */ }
|
|
94
|
+
}
|
|
95
|
+
return { falseBlocks, allowSampleSize: allowed.length };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function safeRegressionCheck(gate, options) {
|
|
99
|
+
try { return regressionCheck(gate, options); } catch { return null; }
|
|
100
|
+
}
|
|
101
|
+
|
|
61
102
|
function loadAutoGates() {
|
|
62
103
|
const autoGatesPath = getAutoGatesPath();
|
|
63
104
|
if (!fs.existsSync(autoGatesPath)) {
|
|
@@ -193,9 +234,9 @@ function patternToGateId(key) {
|
|
|
193
234
|
return 'auto-' + key.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').slice(0, 50).toLowerCase();
|
|
194
235
|
}
|
|
195
236
|
|
|
196
|
-
function buildGateRule(group) {
|
|
197
|
-
const action = group.count === 'MANUAL' ? group.manualAction || 'block' : (group.count >= BLOCK_THRESHOLD ? 'block' : 'warn');
|
|
198
|
-
const severity = action === 'block' ? 'critical' : 'medium';
|
|
237
|
+
function buildGateRule(group, actionOverride) {
|
|
238
|
+
const action = actionOverride || (group.count === 'MANUAL' ? group.manualAction || 'block' : (group.count >= BLOCK_THRESHOLD ? 'block' : 'warn'));
|
|
239
|
+
const severity = action === 'block' ? 'critical' : action === 'approve' ? 'high' : 'medium';
|
|
199
240
|
const context = group.latestContext.slice(0, 120);
|
|
200
241
|
const kind = group.key.startsWith('diagnosis:')
|
|
201
242
|
? 'repeated diagnosis'
|
|
@@ -332,7 +373,8 @@ function forcePromote(context, action = 'block') {
|
|
|
332
373
|
return { gateId, action, totalGates: data.gates.length };
|
|
333
374
|
}
|
|
334
375
|
|
|
335
|
-
function promote(feedbackLogPath) {
|
|
376
|
+
function promote(feedbackLogPath, options) {
|
|
377
|
+
const opts = options || {};
|
|
336
378
|
const logPath = feedbackLogPath || getFeedbackLogPath();
|
|
337
379
|
const entries = readJSONL(logPath);
|
|
338
380
|
const groups = groupNegativeFeedback(entries, WINDOW_DAYS);
|
|
@@ -357,17 +399,38 @@ function promote(feedbackLogPath) {
|
|
|
357
399
|
const existing = data.gates[existingIdx];
|
|
358
400
|
const newAction = group.count >= BLOCK_THRESHOLD ? 'block' : 'warn';
|
|
359
401
|
if (existing.action !== newAction && newAction === 'block') {
|
|
360
|
-
//
|
|
361
|
-
|
|
362
|
-
|
|
402
|
+
// Self-Harness stage 3: regression-test before upgrading warn -> block.
|
|
403
|
+
const regression = opts.skipRegression ? null : safeRegressionCheck(buildGateRule(group, 'block'), opts);
|
|
404
|
+
if (regression && regression.falseBlocks > REGRESSION_FALSE_BLOCK_LIMIT) {
|
|
405
|
+
// Would block prior safe actions — hold at warn instead of upgrading.
|
|
406
|
+
promotions.push({ type: 'upgrade-quarantined', gateId, from: existing.action, occurrences: group.count, falseBlocks: regression.falseBlocks });
|
|
407
|
+
} else {
|
|
408
|
+
// Upgrade from warn to block
|
|
409
|
+
data.gates[existingIdx] = { ...existing, action: 'block', severity: 'critical', occurrences: group.count, upgradedAt: new Date().toISOString() };
|
|
410
|
+
promotions.push({ type: 'upgrade', gateId, from: existing.action, to: 'block', occurrences: group.count });
|
|
411
|
+
}
|
|
363
412
|
}
|
|
364
413
|
// Update occurrence count even if no action change
|
|
365
414
|
data.gates[existingIdx].occurrences = group.count;
|
|
366
415
|
continue;
|
|
367
416
|
}
|
|
368
417
|
|
|
369
|
-
// New gate
|
|
370
|
-
const gate = buildGateRule(group);
|
|
418
|
+
// New gate — respect explicit gateAction override (e.g. 'approve' for human-approval rules)
|
|
419
|
+
const gate = buildGateRule(group, opts.gateAction);
|
|
420
|
+
|
|
421
|
+
// Self-Harness stage 3: before a feedback rule goes live as a hard block,
|
|
422
|
+
// regression-test it against prior allowed actions. If it would have blocked
|
|
423
|
+
// safe actions, quarantine it to `warn` instead of `block`.
|
|
424
|
+
let regression = null;
|
|
425
|
+
if (gate.action === 'block' && !opts.gateAction && !opts.skipRegression) {
|
|
426
|
+
regression = safeRegressionCheck(gate, opts);
|
|
427
|
+
if (regression && regression.falseBlocks > REGRESSION_FALSE_BLOCK_LIMIT) {
|
|
428
|
+
gate.action = 'warn';
|
|
429
|
+
gate.severity = 'medium';
|
|
430
|
+
gate.quarantined = true;
|
|
431
|
+
gate.regression = regression;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
371
434
|
|
|
372
435
|
// Enforce max limit — rotate oldest
|
|
373
436
|
if (data.gates.length >= MAX_AUTO_GATES) {
|
|
@@ -376,7 +439,13 @@ function promote(feedbackLogPath) {
|
|
|
376
439
|
}
|
|
377
440
|
|
|
378
441
|
data.gates.push(gate);
|
|
379
|
-
promotions.push({
|
|
442
|
+
promotions.push({
|
|
443
|
+
type: gate.quarantined ? 'new-quarantined' : 'new',
|
|
444
|
+
gateId: gate.id,
|
|
445
|
+
action: gate.action,
|
|
446
|
+
occurrences: group.count,
|
|
447
|
+
...(gate.quarantined ? { falseBlocks: regression.falseBlocks, allowSampleSize: regression.allowSampleSize } : {}),
|
|
448
|
+
});
|
|
380
449
|
}
|
|
381
450
|
|
|
382
451
|
// Log promotions
|
|
@@ -437,6 +506,9 @@ module.exports = {
|
|
|
437
506
|
groupNegativeFeedback,
|
|
438
507
|
patternToGateId,
|
|
439
508
|
buildGateRule,
|
|
509
|
+
regressionCheck,
|
|
510
|
+
getAuditTrailPath,
|
|
511
|
+
REGRESSION_FALSE_BLOCK_LIMIT,
|
|
440
512
|
extractPatternKey,
|
|
441
513
|
normalizeCommandSignature,
|
|
442
514
|
isNegative,
|
|
@@ -27,6 +27,7 @@ const {
|
|
|
27
27
|
statuslineCommand,
|
|
28
28
|
userPromptHookCommand,
|
|
29
29
|
} = require('./hook-runtime');
|
|
30
|
+
const { installShim } = require('./install-shim');
|
|
30
31
|
|
|
31
32
|
function getHome() {
|
|
32
33
|
return process.env.HOME || process.env.USERPROFILE || '';
|
|
@@ -338,6 +339,19 @@ function wireClaudeHooks(options) {
|
|
|
338
339
|
options.projectSettingsPath || claudeProjectSettingsPath(options.projectDir);
|
|
339
340
|
const dryRun = options.dryRun || false;
|
|
340
341
|
const projectDir = options.projectDir || process.cwd();
|
|
342
|
+
|
|
343
|
+
// --- Install stable shim before resolving hook commands ---
|
|
344
|
+
// The shim at ~/.thumbgate/bin/thumbgate-hook always resolves @latest,
|
|
345
|
+
// so hooks never go stale across version bumps (Volta-style pattern).
|
|
346
|
+
// Skip in source-checkout mode — developers use direct node commands.
|
|
347
|
+
if (!dryRun && !require('./mcp-config').isSourceCheckout(path.join(__dirname, '..'))) {
|
|
348
|
+
try {
|
|
349
|
+
installShim();
|
|
350
|
+
} catch {
|
|
351
|
+
// Non-fatal: fall back to version-pinned commands
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
341
355
|
const desiredStatusLine = statuslineCommand();
|
|
342
356
|
|
|
343
357
|
// --- Step 0: clean up stale hooks from BOTH settings locations ---
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const AWS_BLOCKS_DEPENDENCY_PATTERN = /(^|\/)@aws-blocks\/(?:blocks|[^/\s"']+)/;
|
|
8
|
+
const PRODUCTION_DEPLOY_PATTERNS = [
|
|
9
|
+
/\bcdk\s+deploy\b/i,
|
|
10
|
+
/\bnpm\s+run\s+deploy\b/i,
|
|
11
|
+
/\bpnpm\s+deploy\b/i,
|
|
12
|
+
/\byarn\s+deploy\b/i,
|
|
13
|
+
/\bsst\s+deploy\b/i,
|
|
14
|
+
/\bblocks?\s+deploy\b/i,
|
|
15
|
+
];
|
|
16
|
+
const DESTROY_PATTERNS = [
|
|
17
|
+
/\bcdk\s+destroy\b/i,
|
|
18
|
+
/\bnpm\s+run\s+destroy\b/i,
|
|
19
|
+
/\bpnpm\s+destroy\b/i,
|
|
20
|
+
/\byarn\s+destroy\b/i,
|
|
21
|
+
/\bsandbox\b.*--destroy\b/i,
|
|
22
|
+
/\b--destroy\b/i,
|
|
23
|
+
/\baws\s+cloudformation\s+delete-stack\b/i,
|
|
24
|
+
];
|
|
25
|
+
const DESTRUCTIVE_AWS_PATTERNS = [
|
|
26
|
+
/\baws\s+dynamodb\s+delete-table\b/i,
|
|
27
|
+
/\baws\s+rds\s+delete-db-instance\b/i,
|
|
28
|
+
/\baws\s+s3\s+rm\b[\s\S]*--recursive\b/i,
|
|
29
|
+
/\baws\s+lambda\s+delete-function\b/i,
|
|
30
|
+
/\baws\s+bedrock-agent\s+delete-/i,
|
|
31
|
+
/\baws\s+bedrock-agent-runtime\b/i,
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function toList(value) {
|
|
35
|
+
if (Array.isArray(value)) return value.map(String).map((entry) => entry.trim()).filter(Boolean);
|
|
36
|
+
if (typeof value === 'string') return value.split(',').map((entry) => entry.trim()).filter(Boolean);
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeText(value) {
|
|
41
|
+
if (value == null) return '';
|
|
42
|
+
if (typeof value === 'string') return value;
|
|
43
|
+
try {
|
|
44
|
+
return JSON.stringify(value);
|
|
45
|
+
} catch {
|
|
46
|
+
return String(value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readPackageJson(projectDir) {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8'));
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function matchesAny(text, patterns) {
|
|
59
|
+
return patterns.some((pattern) => pattern.test(text));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function hasDestructiveSql(text) {
|
|
63
|
+
if (/\bdrop\s+(table|database|schema|index|column)\b/i.test(text)) return true;
|
|
64
|
+
if (/\btruncate\s+table\b/i.test(text)) return true;
|
|
65
|
+
if (/\bdelete\s+from\s+[\w".-]+(?:\s*;|\s*$)/i.test(text)) return true;
|
|
66
|
+
const updateMatch = /\bupdate\s+[\w".-]+\s+set\b/i.exec(text);
|
|
67
|
+
if (!updateMatch) return false;
|
|
68
|
+
const afterUpdate = text.slice(updateMatch.index, updateMatch.index + 180);
|
|
69
|
+
return !/\bwhere\b/i.test(afterUpdate);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function packageUsesAwsBlocks(pkg) {
|
|
73
|
+
if (!pkg || typeof pkg !== 'object') return false;
|
|
74
|
+
const dependencyText = JSON.stringify({
|
|
75
|
+
dependencies: pkg.dependencies || {},
|
|
76
|
+
devDependencies: pkg.devDependencies || {},
|
|
77
|
+
peerDependencies: pkg.peerDependencies || {},
|
|
78
|
+
optionalDependencies: pkg.optionalDependencies || {},
|
|
79
|
+
});
|
|
80
|
+
const scriptsText = JSON.stringify(pkg.scripts || {});
|
|
81
|
+
return AWS_BLOCKS_DEPENDENCY_PATTERN.test(dependencyText) || /aws-blocks|blocks-app|cdk|sandbox/i.test(scriptsText);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function detectAwsBlocksProject(projectDir = process.cwd()) {
|
|
85
|
+
const pkg = readPackageJson(projectDir);
|
|
86
|
+
if (packageUsesAwsBlocks(pkg)) return true;
|
|
87
|
+
return fs.existsSync(path.join(projectDir, 'aws-blocks', 'index.ts'))
|
|
88
|
+
|| fs.existsSync(path.join(projectDir, 'aws-blocks', 'index.js'))
|
|
89
|
+
|| fs.existsSync(path.join(projectDir, 'blocks.spec.json'));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function detectAction(input = {}) {
|
|
93
|
+
const command = normalizeText(input.command || input.args || input.shell || input.toolInput);
|
|
94
|
+
const toolName = normalizeText(input.toolName || input.tool || input.name);
|
|
95
|
+
const code = normalizeText(input.code || input.file || input.diff || input.body);
|
|
96
|
+
const combined = `${toolName}\n${command}\n${code}`;
|
|
97
|
+
const lower = combined.toLowerCase();
|
|
98
|
+
|
|
99
|
+
const signals = [];
|
|
100
|
+
const add = (id, label, severity = 'medium') => signals.push({ id, label, severity });
|
|
101
|
+
|
|
102
|
+
if (matchesAny(combined, PRODUCTION_DEPLOY_PATTERNS)) {
|
|
103
|
+
add('aws-blocks-production-deploy', 'production deploy from an AWS Blocks workflow', 'high');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (matchesAny(combined, DESTROY_PATTERNS)) {
|
|
107
|
+
add('aws-blocks-destroy', 'destroy command can remove AWS resources created from local Blocks code', 'critical');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (hasDestructiveSql(combined)) {
|
|
111
|
+
add('destructive-sql-or-ddl', 'destructive or unscoped SQL mutation', 'critical');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (matchesAny(combined, DESTRUCTIVE_AWS_PATTERNS)) {
|
|
115
|
+
add('destructive-aws-cli', 'destructive AWS CLI or Bedrock agent action', 'critical');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (/\b(gcloud|aws)\b[\s\S]{0,220}\b(add-iam-policy-binding|put-role-policy|attach-role-policy|create-policy-version)\b/i.test(combined)
|
|
119
|
+
|| /\b(AdministratorAccess|roles\/owner|iam:PassRole|serviceAccountTokenCreator)\b/i.test(combined)) {
|
|
120
|
+
add('iam-escalation', 'agent session attempts to grant broad IAM authority', 'critical');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (/\b(new\s+Agent\s*\(|@aws-blocks\/(?:agent|blocks)|\bAgent\b[\s\S]{0,120}\btools?\b)/.test(combined)) {
|
|
124
|
+
add('blocks-agent-tool-call', 'AWS Blocks Agent or Bedrock-style tool action needs a tool boundary', 'medium');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (/\b(npm\s+run\s+dev|npm\s+start|pnpm\s+dev|yarn\s+dev|create-blocks-app)\b/i.test(combined)) {
|
|
128
|
+
add('local-dev', 'local AWS Blocks development loop', 'low');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
command,
|
|
133
|
+
toolName,
|
|
134
|
+
code,
|
|
135
|
+
signals,
|
|
136
|
+
highRisk: signals.some((signal) => ['high', 'critical'].includes(signal.severity)),
|
|
137
|
+
localOnly: signals.some((signal) => signal.id === 'local-dev') && signals.every((signal) => signal.severity === 'low'),
|
|
138
|
+
lower,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function evaluateAwsBlocksAction(input = {}) {
|
|
143
|
+
const projectDir = input.projectDir || input.cwd || process.cwd();
|
|
144
|
+
const projectUsesAwsBlocks = Boolean(
|
|
145
|
+
input.projectUsesAwsBlocks
|
|
146
|
+
|| input.awsBlocks
|
|
147
|
+
|| input.blocksProject
|
|
148
|
+
|| detectAwsBlocksProject(projectDir)
|
|
149
|
+
);
|
|
150
|
+
const evidence = new Set(toList(input.evidence || input.proof || input.receipts));
|
|
151
|
+
const action = detectAction(input);
|
|
152
|
+
const requiredEvidence = [];
|
|
153
|
+
|
|
154
|
+
const requireEvidence = (id, label) => {
|
|
155
|
+
if (!evidence.has(id)) requiredEvidence.push({ id, label });
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (projectUsesAwsBlocks && action.signals.some((signal) => signal.id === 'aws-blocks-production-deploy')) {
|
|
159
|
+
requireEvidence('local-tests-pass', 'local AWS Blocks tests pass against local implementations');
|
|
160
|
+
requireEvidence('cdk-diff-reviewed', 'CDK diff or synthesized CloudFormation change set reviewed');
|
|
161
|
+
requireEvidence('cost-blast-radius-reviewed', 'AWS cost and resource blast radius reviewed');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (action.signals.some((signal) => signal.id === 'aws-blocks-destroy')) {
|
|
165
|
+
requireEvidence('resource-inventory-exported', 'resource inventory exported before destroy');
|
|
166
|
+
requireEvidence('human-destroy-approval', 'named human approval for destroy');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (action.signals.some((signal) => signal.id === 'destructive-sql-or-ddl')) {
|
|
170
|
+
requireEvidence('backup-or-rollback-ready', 'backup, rollback, or restore point exists');
|
|
171
|
+
requireEvidence('bounded-row-count-reviewed', 'row/table impact was previewed before mutation');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (action.signals.some((signal) => signal.id === 'destructive-aws-cli')) {
|
|
175
|
+
requireEvidence('aws-account-and-region-confirmed', 'target AWS account and region confirmed');
|
|
176
|
+
requireEvidence('rollback-plan-attached', 'rollback or recovery plan attached');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (action.signals.some((signal) => signal.id === 'iam-escalation')) {
|
|
180
|
+
requireEvidence('least-privilege-review', 'least-privilege review completed');
|
|
181
|
+
requireEvidence('security-owner-approval', 'security owner approval captured');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (action.signals.some((signal) => signal.id === 'blocks-agent-tool-call')) {
|
|
185
|
+
requireEvidence('agent-tool-allowlist', 'agent tool allowlist and data boundary declared');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const shouldBlock = projectUsesAwsBlocks && requiredEvidence.length > 0;
|
|
189
|
+
let status = 'allowed';
|
|
190
|
+
if (shouldBlock) {
|
|
191
|
+
status = 'blocked';
|
|
192
|
+
} else if (action.highRisk) {
|
|
193
|
+
status = 'needs-review';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
name: 'thumbgate-aws-blocks-guardrails',
|
|
198
|
+
status,
|
|
199
|
+
projectUsesAwsBlocks,
|
|
200
|
+
signals: action.signals,
|
|
201
|
+
requiredEvidence,
|
|
202
|
+
enforcementBoundary: 'local AWS Blocks confidence must not automatically become production AWS authority',
|
|
203
|
+
gates: [
|
|
204
|
+
'allow local AWS Blocks dev loops without AWS credentials',
|
|
205
|
+
'require local test proof plus CDK diff before production deploy',
|
|
206
|
+
'block destroy and destructive data mutations until backup, blast-radius, and human approval evidence exists',
|
|
207
|
+
'block IAM escalation and Bedrock/Agent tool expansion until an owner approves the tool boundary',
|
|
208
|
+
'write a ThumbGate receipt for every allowed high-risk cloud action',
|
|
209
|
+
],
|
|
210
|
+
nextActions: shouldBlock
|
|
211
|
+
? requiredEvidence.map((item) => `Provide ${item.id}: ${item.label}`)
|
|
212
|
+
: ['Record an allow receipt when this action touches real AWS resources'],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function buildAwsBlocksHardeningOffer(input = {}) {
|
|
217
|
+
const workflow = String(input.workflow || input.name || 'AWS Blocks backend').trim();
|
|
218
|
+
const buyer = String(input.buyer || input.owner || 'platform owner').trim();
|
|
219
|
+
return {
|
|
220
|
+
name: 'thumbgate-aws-blocks-hardening-offer',
|
|
221
|
+
status: 'ready-for-positioning',
|
|
222
|
+
buyer,
|
|
223
|
+
workflow,
|
|
224
|
+
headline: 'AWS Blocks helps agents build the backend. ThumbGate stops them before unsafe cloud actions run.',
|
|
225
|
+
offer: 'AWS Blocks Agent Safety Review',
|
|
226
|
+
diagnosticPrice: '$499',
|
|
227
|
+
proofPlan: [
|
|
228
|
+
'map one AWS Blocks local-to-cloud workflow',
|
|
229
|
+
'install ThumbGate against the agent running that workflow',
|
|
230
|
+
'add gates for deploy, destroy, data mutation, IAM, Bedrock Agent, and cost-blast-radius actions',
|
|
231
|
+
'produce a receipt showing the first blocked repeat and the evidence required to allow it',
|
|
232
|
+
],
|
|
233
|
+
cta: 'Send one AWS Blocks workflow that is about to deploy, mutate data, or call Bedrock tools.',
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function parseArgs(argv = process.argv.slice(2)) {
|
|
238
|
+
const args = {};
|
|
239
|
+
for (const arg of argv) {
|
|
240
|
+
if (arg === '--json') args.json = true;
|
|
241
|
+
else if (arg === '--aws-blocks' || arg === '--project-uses-aws-blocks') args.projectUsesAwsBlocks = true;
|
|
242
|
+
else if (arg.startsWith('--command=')) args.command = arg.slice('--command='.length);
|
|
243
|
+
else if (arg.startsWith('--tool=')) args.toolName = arg.slice('--tool='.length);
|
|
244
|
+
else if (arg.startsWith('--code=')) args.code = arg.slice('--code='.length);
|
|
245
|
+
else if (arg.startsWith('--cwd=')) args.projectDir = arg.slice('--cwd='.length);
|
|
246
|
+
else if (arg.startsWith('--evidence=')) args.evidence = arg.slice('--evidence='.length);
|
|
247
|
+
else if (arg.startsWith('--workflow=')) args.workflow = arg.slice('--workflow='.length);
|
|
248
|
+
else if (arg.startsWith('--buyer=')) args.buyer = arg.slice('--buyer='.length);
|
|
249
|
+
else if (arg === 'offer') args.commandName = 'offer';
|
|
250
|
+
}
|
|
251
|
+
return args;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function runCli(args = parseArgs()) {
|
|
255
|
+
const report = args.commandName === 'offer'
|
|
256
|
+
? buildAwsBlocksHardeningOffer(args)
|
|
257
|
+
: evaluateAwsBlocksAction(args);
|
|
258
|
+
if (args.json) console.log(JSON.stringify(report, null, 2));
|
|
259
|
+
else {
|
|
260
|
+
console.log(`${report.name}: ${report.status}`);
|
|
261
|
+
for (const action of report.nextActions || report.proofPlan || []) console.log(`- ${action}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (require.main?.filename === __filename) runCli();
|
|
266
|
+
|
|
267
|
+
module.exports = {
|
|
268
|
+
detectAwsBlocksProject,
|
|
269
|
+
detectAction,
|
|
270
|
+
evaluateAwsBlocksAction,
|
|
271
|
+
buildAwsBlocksHardeningOffer,
|
|
272
|
+
};
|
package/scripts/billing.js
CHANGED
|
@@ -51,6 +51,7 @@ const {
|
|
|
51
51
|
} = require('./analytics-window');
|
|
52
52
|
const { ensureParentDir } = require('./fs-utils');
|
|
53
53
|
const mailer = require('./mailer');
|
|
54
|
+
const { recordCheckoutFunnelEvent } = require('./plausible-server-events');
|
|
54
55
|
|
|
55
56
|
function loadWorkflowSprintIntakeModule() {
|
|
56
57
|
const modulePath = path.resolve(__dirname, 'workflow-sprint-intake.js');
|
|
@@ -138,6 +139,10 @@ const IS_TEST = !!(
|
|
|
138
139
|
process.env.NODE_ENV === 'test'
|
|
139
140
|
);
|
|
140
141
|
|
|
142
|
+
function allowUnsignedStripeWebhooks() {
|
|
143
|
+
return IS_TEST && process.env.THUMBGATE_ALLOW_UNSIGNED_STRIPE_WEBHOOKS === '1';
|
|
144
|
+
}
|
|
145
|
+
|
|
141
146
|
function shouldMergeLegacyBillingData() {
|
|
142
147
|
return process.env._TEST_INCLUDE_LEGACY_BILLING_DATA === '1'
|
|
143
148
|
|| process.env.THUMBGATE_INCLUDE_LEGACY_BILLING_DATA === '1';
|
|
@@ -444,6 +449,57 @@ function buildCheckoutProductData({ name, description, appOrigin, planId }) {
|
|
|
444
449
|
};
|
|
445
450
|
}
|
|
446
451
|
|
|
452
|
+
/**
|
|
453
|
+
* Verify an ACTIVE Stripe product exists for the given plan name before
|
|
454
|
+
* we let buildSubscriptionPriceData create inline price_data under it.
|
|
455
|
+
*
|
|
456
|
+
* Stripe matches product_data by `name`. If only an archived product
|
|
457
|
+
* matches, new prices created via that path inherit active=false and the
|
|
458
|
+
* generated checkout URL renders "Something went wrong / The page you
|
|
459
|
+
* were looking for could not be found." for the buyer. Stripe Dashboard
|
|
460
|
+
* shows the session as `open` with no email captured — looks like the
|
|
461
|
+
* buyer abandoned, but they were never given a working page.
|
|
462
|
+
*
|
|
463
|
+
* Failing here surfaces the misconfiguration at the first checkout
|
|
464
|
+
* attempt instead of silently breaking every buyer for days.
|
|
465
|
+
*
|
|
466
|
+
* Verified incident: ThumbGate#2188 (May 2026) — 20 sessions abandoned in
|
|
467
|
+
* 7 days, all because the only product named "ThumbGate Pro" matching the
|
|
468
|
+
* inline product_data was archived (prod_UXxOHAfbDsPyRb), while an active
|
|
469
|
+
* product with the same name existed (prod_UW82THPxfNvwKT) that should
|
|
470
|
+
* have been used instead.
|
|
471
|
+
*/
|
|
472
|
+
async function verifyActiveProductForPlan(stripe, planId) {
|
|
473
|
+
const expectedName = planId === 'team' ? 'ThumbGate Team' : 'ThumbGate Pro';
|
|
474
|
+
let products;
|
|
475
|
+
try {
|
|
476
|
+
products = await stripe.products.list({ limit: 100 });
|
|
477
|
+
} catch (err) {
|
|
478
|
+
// Network/transient failures shouldn't block checkout creation.
|
|
479
|
+
// The original session.create call will surface real Stripe errors.
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const matching = (products && products.data ? products.data : [])
|
|
483
|
+
.filter((p) => p && p.name === expectedName);
|
|
484
|
+
if (matching.length === 0) {
|
|
485
|
+
// No product with this name exists; Stripe will create a new one when
|
|
486
|
+
// session.create fires with inline product_data. Safe path.
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const active = matching.find((p) => p.active === true);
|
|
490
|
+
if (!active) {
|
|
491
|
+
const archived = matching[0];
|
|
492
|
+
throw new Error(
|
|
493
|
+
`Refusing to create checkout session: Stripe product named "${expectedName}" ` +
|
|
494
|
+
`exists only in archived state (id=${archived.id}, active=false). New prices ` +
|
|
495
|
+
`created via inline product_data would inherit active=false, rendering ` +
|
|
496
|
+
`"page not found" on Stripe checkout for every buyer. Fix: reactivate the ` +
|
|
497
|
+
`archived product in Stripe Dashboard, or rename the active product to ` +
|
|
498
|
+
`match "${expectedName}". See ThumbGate#2188 for the May 2026 incident.`
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
447
503
|
function buildSubscriptionPriceData(checkoutSelection, appOrigin) {
|
|
448
504
|
const isTeam = checkoutSelection.planId === 'team';
|
|
449
505
|
const annual = checkoutSelection.billingCycle === 'annual';
|
|
@@ -2525,6 +2581,18 @@ async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, ins
|
|
|
2525
2581
|
}
|
|
2526
2582
|
|
|
2527
2583
|
const stripe = getStripeClient();
|
|
2584
|
+
|
|
2585
|
+
// Defensive guard against ThumbGate#2188:
|
|
2586
|
+
// When buildSubscriptionPriceData passes inline `product_data` to Stripe,
|
|
2587
|
+
// Stripe name-matches existing products. If the only existing product with
|
|
2588
|
+
// that name is ARCHIVED (active=false), the new price inherits active=false
|
|
2589
|
+
// and every Stripe checkout page renders "page not found" for the buyer.
|
|
2590
|
+
// That bug burnt 20+ silent abandoned sessions in May 2026. Fail fast
|
|
2591
|
+
// instead of letting the broken page ship.
|
|
2592
|
+
if (!packId) {
|
|
2593
|
+
await verifyActiveProductForPlan(stripe, checkoutSelection.planId);
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2528
2596
|
const sessionPayload = buildCheckoutSessionPayload({
|
|
2529
2597
|
successUrl,
|
|
2530
2598
|
cancelUrl,
|
|
@@ -2837,7 +2905,7 @@ function disableCustomerKeys(customerId) {
|
|
|
2837
2905
|
}
|
|
2838
2906
|
|
|
2839
2907
|
function verifyWebhookSignature(rawBody, signature) {
|
|
2840
|
-
if (!CONFIG.STRIPE_WEBHOOK_SECRET) return
|
|
2908
|
+
if (!CONFIG.STRIPE_WEBHOOK_SECRET) return allowUnsignedStripeWebhooks();
|
|
2841
2909
|
if (!signature || !rawBody) return false;
|
|
2842
2910
|
|
|
2843
2911
|
// Stripe signature format: t=<timestamp>,v1=<hmac>,...
|
|
@@ -2867,6 +2935,13 @@ function verifyWebhookSignature(rawBody, signature) {
|
|
|
2867
2935
|
|
|
2868
2936
|
async function handleWebhook(rawBody, signature) {
|
|
2869
2937
|
if (LOCAL_MODE()) return { handled: false, reason: 'local_mode' };
|
|
2938
|
+
if (!CONFIG.STRIPE_WEBHOOK_SECRET && !allowUnsignedStripeWebhooks()) {
|
|
2939
|
+
return {
|
|
2940
|
+
handled: false,
|
|
2941
|
+
reason: 'invalid_signature',
|
|
2942
|
+
error: 'STRIPE_WEBHOOK_SECRET is required before Stripe webhooks can be processed.',
|
|
2943
|
+
};
|
|
2944
|
+
}
|
|
2870
2945
|
let event;
|
|
2871
2946
|
try {
|
|
2872
2947
|
if (CONFIG.STRIPE_WEBHOOK_SECRET) {
|
|
@@ -2975,6 +3050,22 @@ async function handleWebhook(rawBody, signature) {
|
|
|
2975
3050
|
attribution,
|
|
2976
3051
|
});
|
|
2977
3052
|
}
|
|
3053
|
+
// Fire Plausible purchase event so the funnel poller can measure
|
|
3054
|
+
// end-to-end conversion: visitor → CTA → checkout → email → Stripe → purchase.
|
|
3055
|
+
// Fire-and-forget (never blocks the webhook response).
|
|
3056
|
+
void recordCheckoutFunnelEvent('purchase', {
|
|
3057
|
+
page: '/success',
|
|
3058
|
+
props: {
|
|
3059
|
+
sessionId: session.id,
|
|
3060
|
+
customerId,
|
|
3061
|
+
traceId: traceId || '',
|
|
3062
|
+
packId: packId || '',
|
|
3063
|
+
amount: session.amount_total != null ? String(session.amount_total) : '',
|
|
3064
|
+
currency: session.currency || '',
|
|
3065
|
+
...attribution,
|
|
3066
|
+
},
|
|
3067
|
+
});
|
|
3068
|
+
|
|
2978
3069
|
return {
|
|
2979
3070
|
handled: true,
|
|
2980
3071
|
action: 'provisioned_api_key',
|
|
@@ -3127,6 +3218,7 @@ module.exports = {
|
|
|
3127
3218
|
_buildTrialActivationEmail: buildTrialActivationEmail,
|
|
3128
3219
|
_sendTrialActivationEmail: sendTrialActivationEmail,
|
|
3129
3220
|
_resolveSubscriptionCheckoutSelection: resolveSubscriptionCheckoutSelection,
|
|
3221
|
+
_verifyActiveProductForPlan: verifyActiveProductForPlan,
|
|
3130
3222
|
_API_KEYS_PATH: () => CONFIG.API_KEYS_PATH,
|
|
3131
3223
|
_FUNNEL_LEDGER_PATH: () => CONFIG.FUNNEL_LEDGER_PATH,
|
|
3132
3224
|
_REVENUE_LEDGER_PATH: () => CONFIG.REVENUE_LEDGER_PATH,
|