thumbgate 1.28.4 → 1.29.2

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 (87) hide show
  1. package/.claude/commands/dashboard.md +11 -1
  2. package/.claude/commands/thumbgate-dashboard.md +23 -8
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.well-known/llms.txt +18 -10
  5. package/.well-known/mcp/server-card.json +1 -1
  6. package/README.md +66 -3
  7. package/adapters/claude/.mcp.json +2 -2
  8. package/adapters/forge/forge.yaml +3 -3
  9. package/adapters/mcp/server-stdio.js +88 -2
  10. package/adapters/opencode/opencode.json +1 -1
  11. package/bin/cli.js +8 -8
  12. package/bin/postinstall.js +4 -13
  13. package/commands/dashboard.md +11 -1
  14. package/commands/thumbgate-dashboard.md +23 -8
  15. package/config/agent-outcome-monitor-thresholds.json +63 -0
  16. package/config/evals/agent-outcomes-baseline.json +17 -0
  17. package/config/evals/agent-outcomes-golden.json +412 -0
  18. package/config/evals/prompt-eval-baseline.json +23 -0
  19. package/config/github-about.json +5 -4
  20. package/config/post-deploy-marketing-pages.json +6 -6
  21. package/config/schemas/task-outcome-receipt.schema.json +296 -0
  22. package/docs/integrations/grafana/README.md +109 -0
  23. package/docs/integrations/grafana/thumbgate-revenue-evidence-dashboard.json +1930 -0
  24. package/openapi/openapi.yaml +475 -5
  25. package/package.json +75 -22
  26. package/public/agent-manager.html +10 -11
  27. package/public/agents-cost-savings.html +2 -2
  28. package/public/assets/brand/thumbgate-logo-transparent.svg +6 -11
  29. package/public/assets/brand/thumbgate-mark-inline-v3.svg +11 -10
  30. package/public/assets/brand/thumbgate-mark.svg +10 -11
  31. package/public/blog/inside-your-boundary.html +114 -0
  32. package/public/blog/process-over-outcome-gates.html +119 -0
  33. package/public/blog.html +296 -402
  34. package/public/brand/thumbgate-mark.svg +5 -9
  35. package/public/codex-enterprise.html +2 -2
  36. package/public/compare.html +12 -3
  37. package/public/diagnostic.html +79 -29
  38. package/public/guide.html +4 -4
  39. package/public/index.html +1090 -2098
  40. package/public/install.html +3 -3
  41. package/public/js/buyer-intent.js +33 -18
  42. package/public/numbers.html +2 -2
  43. package/public/pricing.html +268 -408
  44. package/public/pro.html +4 -4
  45. package/scripts/agent-outcome-eval.js +130 -0
  46. package/scripts/agent-outcome-monitor.js +261 -0
  47. package/scripts/agent-reasoning-traces.js +8 -9
  48. package/scripts/async-job-runner.js +107 -13
  49. package/scripts/billing.js +456 -126
  50. package/scripts/buyer-paths.js +102 -0
  51. package/scripts/cli-feedback.js +2 -2
  52. package/scripts/commercial-offer.js +18 -10
  53. package/scripts/durability/step.js +121 -12
  54. package/scripts/external-customer-audit.js +881 -0
  55. package/scripts/feedback-loop.js +26 -0
  56. package/scripts/gates-engine.js +554 -19
  57. package/scripts/grafana-revenue-evidence.js +856 -0
  58. package/scripts/human-escalation.js +265 -0
  59. package/scripts/hybrid-feedback-context.js +93 -50
  60. package/scripts/jsonl-window.js +89 -0
  61. package/scripts/judge-reward-function.js +30 -18
  62. package/scripts/lesson-embedding-index.js +3 -7
  63. package/scripts/meta-agent-loop.js +20 -2
  64. package/scripts/observability-env.js +139 -0
  65. package/scripts/observability-setup.js +55 -0
  66. package/scripts/plausible-domain-config.js +4 -0
  67. package/scripts/prompt-eval.js +81 -4
  68. package/scripts/provider-live-evidence.js +1290 -0
  69. package/scripts/provider-payment-reconciler.js +442 -0
  70. package/scripts/provider-revenue-evidence.js +249 -0
  71. package/scripts/rate-limiter.js +1 -5
  72. package/scripts/revenue-action-eligibility.js +414 -0
  73. package/scripts/revenue-evidence-remediation.js +694 -0
  74. package/scripts/revenue-offer-system.js +709 -0
  75. package/scripts/sales-pipeline.js +1117 -0
  76. package/scripts/schedule-manager.js +249 -0
  77. package/scripts/seo-gsd.js +8 -4
  78. package/scripts/stripe-credentials.js +37 -0
  79. package/scripts/stripe-revenue-catalog-audit.js +363 -0
  80. package/scripts/stripe-revenue-catalog.js +164 -0
  81. package/scripts/task-outcomes.js +425 -0
  82. package/scripts/telemetry-analytics.js +23 -3
  83. package/scripts/tool-contract-validator.js +287 -59
  84. package/scripts/tool-registry.js +143 -0
  85. package/scripts/vector-store.js +83 -7
  86. package/scripts/workflow-intake-queue.js +483 -0
  87. package/src/api/server.js +647 -118
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const { execSync } = require('child_process');
8
+ const { buildAgenticDataPipelineJobSpec } = require('./agentic-data-pipeline');
9
+ const { ensureDir } = require('./fs-utils');
10
+
11
+ const SCHEDULES_DIR = path.join(os.homedir(), '.thumbgate', 'schedules');
12
+ const PLIST_PREFIX = 'com.thumbgate.schedule';
13
+
14
+
15
+ function escapePlistString(value) {
16
+ return String(value || '')
17
+ .replace(/&/g, '&')
18
+ .replace(/</g, '&lt;')
19
+ .replace(/>/g, '&gt;')
20
+ .replace(/"/g, '&quot;')
21
+ .replace(/'/g, '&#39;');
22
+ }
23
+
24
+ /**
25
+ * Parse a simple cron-like spec into LaunchAgent calendar intervals
26
+ * Supports: "daily 9:00", "weekly monday 8:30", "hourly", "every 6h"
27
+ */
28
+ function parseCronSpec(spec) {
29
+ const s = spec.toLowerCase().trim();
30
+
31
+ if (s === 'hourly') {
32
+ return { Minute: 0 };
33
+ }
34
+
35
+ const everyHMatch = s.match(/^every\s+(\d+)\s*h/);
36
+ if (everyHMatch) {
37
+ return { Minute: 0 }; // LaunchAgent doesn't support "every Nh" natively, use hourly
38
+ }
39
+
40
+ const dailyMatch = s.match(/^daily\s+(\d{1,2}):(\d{2})$/);
41
+ if (dailyMatch) {
42
+ return { Hour: parseInt(dailyMatch[1]), Minute: parseInt(dailyMatch[2]) };
43
+ }
44
+
45
+ const weeklyMatch = s.match(/^weekly\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s+(\d{1,2}):(\d{2})$/);
46
+ if (weeklyMatch) {
47
+ const dayMap = { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6 };
48
+ return {
49
+ Weekday: dayMap[weeklyMatch[1]],
50
+ Hour: parseInt(weeklyMatch[2]),
51
+ Minute: parseInt(weeklyMatch[3]),
52
+ };
53
+ }
54
+
55
+ // Fallback: try to parse as "HH:MM" (daily)
56
+ const timeMatch = s.match(/^(\d{1,2}):(\d{2})$/);
57
+ if (timeMatch) {
58
+ return { Hour: parseInt(timeMatch[1]), Minute: parseInt(timeMatch[2]) };
59
+ }
60
+
61
+ return null;
62
+ }
63
+
64
+ function generatePlist(schedule) {
65
+ const label = escapePlistString(`${PLIST_PREFIX}.${schedule.id}`);
66
+ const interval = schedule.calendarInterval;
67
+
68
+ let intervalXml = '<dict>\n';
69
+ for (const [key, value] of Object.entries(interval)) {
70
+ intervalXml += ` <key>${key}</key>\n <integer>${value}</integer>\n`;
71
+ }
72
+ intervalXml += ' </dict>';
73
+
74
+ const logDir = escapePlistString(path.join(os.homedir(), '.thumbgate', 'logs'));
75
+ const workingDirectory = escapePlistString(schedule.workingDirectory || os.homedir());
76
+ const command = escapePlistString(schedule.command);
77
+ const homeDir = escapePlistString(os.homedir());
78
+ const escapedScheduleId = escapePlistString(schedule.id);
79
+
80
+ return `<?xml version="1.0" encoding="UTF-8"?>
81
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
82
+ <plist version="1.0">
83
+ <dict>
84
+ <key>Label</key>
85
+ <string>${label}</string>
86
+ <key>ProgramArguments</key>
87
+ <array>
88
+ <string>${process.execPath}</string>
89
+ <string>-e</string>
90
+ <string>${command}</string>
91
+ </array>
92
+ <key>WorkingDirectory</key>
93
+ <string>${workingDirectory}</string>
94
+ <key>StartCalendarInterval</key>
95
+ ${intervalXml}
96
+ <key>StandardOutPath</key>
97
+ <string>${logDir}/schedule-${escapedScheduleId}.log</string>
98
+ <key>StandardErrorPath</key>
99
+ <string>${logDir}/schedule-${escapedScheduleId}-error.log</string>
100
+ <key>EnvironmentVariables</key>
101
+ <dict>
102
+ <key>PATH</key>
103
+ <string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
104
+ <key>HOME</key>
105
+ <string>${homeDir}</string>
106
+ </dict>
107
+ </dict>
108
+ </plist>`;
109
+ }
110
+
111
+ function buildManagedScheduleCommand(params = {}) {
112
+ if (!params.jobFile) {
113
+ throw new Error('buildManagedScheduleCommand requires jobFile');
114
+ }
115
+
116
+ const runnerPath = path.join(__dirname, 'async-job-runner.js');
117
+ const jobFile = path.resolve(params.jobFile);
118
+ const autoResume = params.autoResume !== false;
119
+
120
+ return [
121
+ `const runner = require(${JSON.stringify(runnerPath)});`,
122
+ `const result = runner.runJobFromFile(${JSON.stringify(jobFile)}, ${JSON.stringify({ autoResume })});`,
123
+ 'process.stdout.write(JSON.stringify(result, null, 2) + "\\n");',
124
+ 'if (["failed", "cancelled"].includes(result.status)) process.exit(1);',
125
+ ].join(' ');
126
+ }
127
+
128
+ function buildAgenticDataPipelineSchedule(params = {}) {
129
+ const id = params.id || params.name || 'agentic-data-pipeline';
130
+ const jobFile = path.resolve(
131
+ params.jobFile || path.join(SCHEDULES_DIR, `${id}.job.json`)
132
+ );
133
+ const jobSpec = buildAgenticDataPipelineJobSpec({
134
+ jobId: id,
135
+ feedbackDir: params.feedbackDir,
136
+ outDir: params.outDir,
137
+ window: params.window,
138
+ liveBilling: params.liveBilling,
139
+ recordWorkflowRun: params.recordWorkflowRun,
140
+ });
141
+
142
+ return {
143
+ id,
144
+ jobFile,
145
+ jobSpec,
146
+ command: buildManagedScheduleCommand({
147
+ jobFile,
148
+ autoResume: params.autoResume !== false,
149
+ }),
150
+ };
151
+ }
152
+
153
+ function createSchedule(params) {
154
+ ensureDir(SCHEDULES_DIR);
155
+
156
+ const id = params.id || params.name || `sched_${Date.now()}`;
157
+ const calendarInterval = parseCronSpec(params.schedule);
158
+ if (!calendarInterval) {
159
+ return { success: false, error: `Cannot parse schedule: "${params.schedule}". Use formats like "daily 9:00", "weekly monday 8:30", "hourly"` };
160
+ }
161
+
162
+ const jobFile = params.jobFile ? path.resolve(params.jobFile) : null;
163
+ const command = params.command || (jobFile ? buildManagedScheduleCommand({
164
+ jobFile,
165
+ autoResume: params.autoResume !== false,
166
+ }) : null);
167
+
168
+ if (!command) {
169
+ return { success: false, error: 'Schedule requires command or jobFile' };
170
+ }
171
+
172
+ const schedule = {
173
+ id,
174
+ name: params.name || id,
175
+ description: params.description || '',
176
+ schedule: params.schedule,
177
+ command,
178
+ jobFile,
179
+ resumePolicy: jobFile ? (params.autoResume !== false ? 'auto_resume' : 'fresh_only') : null,
180
+ workingDirectory: params.workingDirectory || (jobFile ? path.dirname(jobFile) : process.cwd()),
181
+ calendarInterval,
182
+ createdAt: new Date().toISOString(),
183
+ };
184
+
185
+ // Save schedule metadata
186
+ const metaPath = path.join(SCHEDULES_DIR, `${id}.json`);
187
+ fs.writeFileSync(metaPath, JSON.stringify(schedule, null, 2), 'utf8');
188
+
189
+ // Generate and install LaunchAgent
190
+ if (process.platform === 'darwin') {
191
+ const plistContent = generatePlist(schedule);
192
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${PLIST_PREFIX}.${id}.plist`);
193
+ const logDir = path.join(os.homedir(), '.thumbgate', 'logs');
194
+ if (!fs.existsSync(logDir)) fs.mkdirSync(logDir, { recursive: true });
195
+ fs.mkdirSync(path.dirname(plistPath), { recursive: true });
196
+
197
+ fs.writeFileSync(plistPath, plistContent, 'utf8');
198
+ try {
199
+ execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: 'pipe' });
200
+ } catch { /* not loaded */ }
201
+ try {
202
+ execSync(`launchctl load "${plistPath}"`, { stdio: 'pipe' });
203
+ } catch (e) {
204
+ return { success: false, error: `Failed to load LaunchAgent: ${e.message}`, schedule };
205
+ }
206
+
207
+ return { success: true, schedule, plistPath, message: `Schedule "${id}" created and loaded` };
208
+ }
209
+
210
+ // Linux keeps the schedule metadata so operators can install it via user crontab tooling.
211
+ return { success: true, schedule, message: `Schedule "${id}" saved for Linux crontab installation` };
212
+ }
213
+
214
+ function listSchedules() {
215
+ ensureDir(SCHEDULES_DIR);
216
+ const files = fs.readdirSync(SCHEDULES_DIR).filter(f => f.endsWith('.json'));
217
+ return files.map(f => {
218
+ try {
219
+ return JSON.parse(fs.readFileSync(path.join(SCHEDULES_DIR, f), 'utf8'));
220
+ } catch {
221
+ return { id: f.replace('.json', ''), error: 'corrupt' };
222
+ }
223
+ });
224
+ }
225
+
226
+ function deleteSchedule(id) {
227
+ const metaPath = path.join(SCHEDULES_DIR, `${id}.json`);
228
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${PLIST_PREFIX}.${id}.plist`);
229
+
230
+ try {
231
+ execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: 'pipe' });
232
+ } catch { /* not loaded */ }
233
+
234
+ if (fs.existsSync(plistPath)) fs.unlinkSync(plistPath);
235
+ if (fs.existsSync(metaPath)) fs.unlinkSync(metaPath);
236
+
237
+ return { success: true, message: `Schedule "${id}" deleted` };
238
+ }
239
+
240
+ module.exports = {
241
+ createSchedule,
242
+ listSchedules,
243
+ deleteSchedule,
244
+ escapePlistString,
245
+ generatePlist,
246
+ parseCronSpec,
247
+ buildManagedScheduleCommand,
248
+ buildAgenticDataPipelineSchedule,
249
+ };
@@ -2,6 +2,10 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const {
6
+ buildDiagnosticBuyerUrl,
7
+ buildSprintBuyerUrl,
8
+ } = require('./buyer-paths');
5
9
 
6
10
  const ROOT = path.join(__dirname, '..');
7
11
  const DEFAULT_OUTPUT_DIR = path.join(ROOT, 'docs', 'seo-gsd');
@@ -12,8 +16,8 @@ const PRODUCT = {
12
16
  repoUrl: 'https://github.com/IgorGanapolsky/ThumbGate',
13
17
  homepageUrl: 'https://thumbgate.ai',
14
18
  verificationUrl: 'https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md',
15
- sprintDiagnosticPaymentUrl: 'https://buy.stripe.com/00w14neyUcXA5pL5e33sI0e',
16
- workflowSprintPaymentUrl: 'https://buy.stripe.com/fZu9AT76saPsg4pbCr3sI0f',
19
+ sprintDiagnosticPaymentUrl: buildDiagnosticBuyerUrl({ source: 'seo_gsd' }),
20
+ workflowSprintPaymentUrl: buildSprintBuyerUrl({ source: 'seo_gsd' }),
17
21
  compatibility: ['Claude Code', 'Cursor', 'Codex', 'Gemini', 'Amp', 'OpenCode'],
18
22
  proofPoints: [
19
23
  'thumbs-up/down feedback loop',
@@ -562,7 +566,7 @@ const PRETOOLUSE_HOOK_GUIDE_SPEC = Object.freeze({
562
566
  ],
563
567
  [
564
568
  '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. Pro adds personal recall, exports, dashboard proof, and managed adapter coverage. Enterprise adds hosted sharing for teams that want to share rules across seats.',
569
+ '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 service work is qualified after intake; hosted rule sharing and a hosted org dashboard are not generally available.',
566
570
  ],
567
571
  ],
568
572
  relatedPaths: ['/guides/mcp-tool-governance', '/guides/ai-agent-pre-action-approval-gates', '/guides/ai-coding-agent-zero-trust'],
@@ -1972,7 +1976,7 @@ const PAGE_BLUEPRINTS = [
1972
1976
  faq: [
1973
1977
  {
1974
1978
  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 (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.',
1979
+ 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 service work is qualified after intake, and shared hosted lessons are not generally available.',
1976
1980
  },
1977
1981
  {
1978
1982
  question: 'Is ThumbGate just a packaged version of disler/claude-code-hooks-mastery?',
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+
8
+ const DEFAULT_SECRET_PATHS = Object.freeze([
9
+ path.join(os.homedir(), '.thumbgate_secrets', 'stripe_live_key.txt'),
10
+ path.join(os.homedir(), '.resume_secrets', 'stripe_live_key.txt'),
11
+ ]);
12
+
13
+ function readSecretFile(filePath) {
14
+ try {
15
+ if (!filePath || !fs.existsSync(filePath)) return null;
16
+ return fs.readFileSync(filePath, 'utf8').trim() || null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ function resolveStripeSecretKey({ env = process.env, secretPaths = DEFAULT_SECRET_PATHS } = {}) {
23
+ if (String(env.STRIPE_SECRET_KEY || '').trim()) {
24
+ return { secretKey: env.STRIPE_SECRET_KEY.trim(), source: 'env' };
25
+ }
26
+ for (const filePath of secretPaths) {
27
+ const secretKey = readSecretFile(filePath);
28
+ if (secretKey) return { secretKey, source: 'managed_file' };
29
+ }
30
+ return { secretKey: null, source: null };
31
+ }
32
+
33
+ module.exports = {
34
+ DEFAULT_SECRET_PATHS,
35
+ readSecretFile,
36
+ resolveStripeSecretKey,
37
+ };
@@ -0,0 +1,363 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ const {
8
+ DEFAULT_SECRET_PATHS,
9
+ resolveStripeSecretKey,
10
+ } = require('./stripe-credentials');
11
+ const {
12
+ STRIPE_REVENUE_CATALOG_VERSION,
13
+ DEFAULT_STRIPE_REVENUE_CATALOG,
14
+ validateStripeRevenueCatalog,
15
+ matchStripeRevenueCatalogPrice,
16
+ } = require('./stripe-revenue-catalog');
17
+
18
+ const SCHEMA_VERSION = 1;
19
+ const DEFAULT_STRIPE_PUBLIC_PAYMENT_RAILS = Object.freeze([
20
+ Object.freeze({
21
+ offerId: 'pro_monthly',
22
+ paymentLinkId: 'plink_1Tpu8xGGBpd520QY7VoUniLI',
23
+ url: 'https://buy.stripe.com/8x2dR91M84r4cSd9uj3sI3f',
24
+ priceId: 'price_1THQY7GGBpd520QYHoS7RG0J',
25
+ expectedActive: true,
26
+ }),
27
+ Object.freeze({
28
+ offerId: 'workflow_hardening_diagnostic',
29
+ paymentLinkId: 'plink_1TsO6lGGBpd520QYsFToXuRC',
30
+ url: 'https://buy.stripe.com/9B69ATbmI4r4aK5eOD3sI3k',
31
+ priceId: 'price_1TsO6kGGBpd520QYbbEgThb3',
32
+ expectedActive: true,
33
+ }),
34
+ ]);
35
+
36
+ function parseArgs(argv = []) {
37
+ const options = { json: argv.includes('--json') };
38
+ for (let index = 0; index < argv.length; index += 1) {
39
+ const arg = argv[index];
40
+ if (arg === '--json') continue;
41
+ if (arg === '--out' && argv[index + 1]) {
42
+ options.out = argv[index + 1];
43
+ index += 1;
44
+ }
45
+ }
46
+ return options;
47
+ }
48
+
49
+ function loadStripe(requireFn = require) {
50
+ return requireFn('stripe');
51
+ }
52
+
53
+ function canonicalPaymentLinkUrl(value) {
54
+ try {
55
+ const url = new URL(String(value || '').trim());
56
+ if (url.protocol !== 'https:' || url.hostname !== 'buy.stripe.com'
57
+ || url.username || url.href.includes('@') || url.port) return null;
58
+ return `${url.origin}${url.pathname}`;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function validatePublicPaymentRails(rails = DEFAULT_STRIPE_PUBLIC_PAYMENT_RAILS, catalog = DEFAULT_STRIPE_REVENUE_CATALOG) {
65
+ if (!Array.isArray(rails) || rails.length === 0) {
66
+ return { ok: false, gap: 'Stripe public payment rails must contain at least one reviewed link.' };
67
+ }
68
+ const priceIds = new Set(catalog.map((entry) => entry.priceId));
69
+ const offerIds = new Set(catalog.map((entry) => entry.offerId));
70
+ const seenLinks = new Set();
71
+ for (const rail of rails) {
72
+ const linkId = String(rail?.paymentLinkId || '').trim();
73
+ const priceId = String(rail?.priceId || '').trim();
74
+ const offerId = String(rail?.offerId || '').trim();
75
+ if (!/^plink_[A-Za-z0-9_]+$/.test(linkId)
76
+ || !canonicalPaymentLinkUrl(rail?.url)
77
+ || !priceIds.has(priceId)
78
+ || !offerIds.has(offerId)
79
+ || typeof rail?.expectedActive !== 'boolean') {
80
+ return { ok: false, gap: `Invalid Stripe public payment rail for ${offerId || 'unknown offer'}.` };
81
+ }
82
+ const catalogEntry = catalog.find((entry) => entry.offerId === offerId);
83
+ if (!catalogEntry || catalogEntry.priceId !== priceId) {
84
+ return { ok: false, gap: `Stripe public payment rail ${linkId} does not bind to its catalog offer.` };
85
+ }
86
+ if (seenLinks.has(linkId)) {
87
+ return { ok: false, gap: `Duplicate Stripe public payment link: ${linkId}.` };
88
+ }
89
+ seenLinks.add(linkId);
90
+ }
91
+ return { ok: true, gap: null };
92
+ }
93
+
94
+ function emptySummary(catalog = [], rails = []) {
95
+ return {
96
+ expectedOfferCount: catalog.length,
97
+ verifiedOfferCount: 0,
98
+ priceDriftCount: catalog.length,
99
+ expectedPublicPaymentRailCount: rails.length,
100
+ verifiedPublicPaymentRailCount: 0,
101
+ paymentRailDriftCount: rails.length,
102
+ };
103
+ }
104
+
105
+ async function buildStripeRevenueCatalogAudit({
106
+ stripe,
107
+ catalog = DEFAULT_STRIPE_REVENUE_CATALOG,
108
+ publicPaymentRails = DEFAULT_STRIPE_PUBLIC_PAYMENT_RAILS,
109
+ generatedAt = new Date().toISOString(),
110
+ requireLiveMode = true,
111
+ } = {}) {
112
+ const catalogValidation = validateStripeRevenueCatalog(catalog);
113
+ const railValidation = catalogValidation.ok
114
+ ? validatePublicPaymentRails(publicPaymentRails, catalog)
115
+ : { ok: false, gap: 'Stripe public payment rails cannot be audited against an invalid catalog.' };
116
+ const gaps = [catalogValidation.gap, railValidation.gap].filter(Boolean);
117
+ if (!stripe?.prices?.retrieve || !stripe?.paymentLinks?.retrieve) {
118
+ gaps.push('Stripe client does not expose read-only price and Payment Link retrieval endpoints.');
119
+ }
120
+ if (gaps.length) {
121
+ return {
122
+ schemaVersion: SCHEMA_VERSION,
123
+ catalogVersion: STRIPE_REVENUE_CATALOG_VERSION,
124
+ generatedAt: new Date(generatedAt).toISOString(),
125
+ verified: false,
126
+ gaps,
127
+ summary: emptySummary(catalog, publicPaymentRails),
128
+ offers: [],
129
+ publicPaymentRails: [],
130
+ };
131
+ }
132
+
133
+ const offers = await Promise.all(catalog.map(async (entry) => {
134
+ let price;
135
+ try {
136
+ price = await stripe.prices.retrieve(entry.priceId, { expand: ['product'] });
137
+ } catch {
138
+ return {
139
+ offerId: entry.offerId,
140
+ priceId: entry.priceId,
141
+ verified: false,
142
+ gap: 'provider_price_retrieval_failed',
143
+ };
144
+ }
145
+ const match = matchStripeRevenueCatalogPrice(price, catalog);
146
+ const activeMatches = price.active === entry.expectedPriceActive;
147
+ const productActiveMatches = typeof price.product === 'object'
148
+ && price.product?.active === entry.expectedProductActive;
149
+ const liveModeMatches = !requireLiveMode || price.livemode === true;
150
+ return {
151
+ offerId: entry.offerId,
152
+ priceId: entry.priceId,
153
+ productId: entry.productId,
154
+ status: entry.status,
155
+ expectedPriceActive: entry.expectedPriceActive,
156
+ observedPriceActive: price.active === true,
157
+ expectedProductActive: entry.expectedProductActive,
158
+ observedProductActive: typeof price.product === 'object' ? price.product?.active === true : null,
159
+ liveMode: price.livemode === true,
160
+ exactTermsMatch: match.matched,
161
+ verified: match.matched && activeMatches && productActiveMatches && liveModeMatches,
162
+ gap: !match.matched
163
+ ? match.reason
164
+ : !activeMatches
165
+ ? 'price_active_state_mismatch'
166
+ : !productActiveMatches
167
+ ? 'product_active_state_mismatch'
168
+ : !liveModeMatches ? 'price_not_live_mode' : null,
169
+ };
170
+ }));
171
+
172
+ const publicRails = await Promise.all(publicPaymentRails.map(async (expected) => {
173
+ let link;
174
+ try {
175
+ link = await stripe.paymentLinks.retrieve(expected.paymentLinkId, {
176
+ expand: ['line_items.data.price.product'],
177
+ });
178
+ } catch {
179
+ return {
180
+ offerId: expected.offerId,
181
+ paymentLinkId: expected.paymentLinkId,
182
+ verified: false,
183
+ gap: 'provider_payment_link_retrieval_failed',
184
+ };
185
+ }
186
+ const lineItems = Array.isArray(link?.line_items?.data) ? link.line_items.data : [];
187
+ const priceMatches = lineItems.map((item) => matchStripeRevenueCatalogPrice(item?.price, catalog));
188
+ const exactSingleOffer = priceMatches.length === 1
189
+ && priceMatches[0].matched
190
+ && priceMatches[0].offerId === expected.offerId
191
+ && priceMatches[0].observed?.priceId === expected.priceId;
192
+ const urlMatches = canonicalPaymentLinkUrl(link?.url) === canonicalPaymentLinkUrl(expected.url);
193
+ const activeMatches = link?.active === expected.expectedActive;
194
+ const liveModeMatches = !requireLiveMode || link?.livemode === true;
195
+ const idMatches = link?.id === expected.paymentLinkId;
196
+ const verified = idMatches && urlMatches && activeMatches && liveModeMatches && exactSingleOffer;
197
+ return {
198
+ offerId: expected.offerId,
199
+ paymentLinkId: expected.paymentLinkId,
200
+ priceId: expected.priceId,
201
+ expectedActive: expected.expectedActive,
202
+ observedActive: link?.active === true,
203
+ liveMode: link?.livemode === true,
204
+ exactUrlMatch: urlMatches,
205
+ exactSingleOffer,
206
+ verified,
207
+ gap: !idMatches
208
+ ? 'payment_link_id_mismatch'
209
+ : !urlMatches
210
+ ? 'payment_link_url_mismatch'
211
+ : !activeMatches
212
+ ? 'payment_link_active_state_mismatch'
213
+ : !liveModeMatches
214
+ ? 'payment_link_not_live_mode'
215
+ : !exactSingleOffer ? 'payment_link_offer_mismatch' : null,
216
+ };
217
+ }));
218
+
219
+ for (const offer of offers) {
220
+ if (!offer.verified) gaps.push(`offer:${offer.offerId}:${offer.gap}`);
221
+ }
222
+ for (const rail of publicRails) {
223
+ if (!rail.verified) gaps.push(`payment_rail:${rail.offerId}:${rail.gap}`);
224
+ }
225
+ const verifiedOfferCount = offers.filter((offer) => offer.verified).length;
226
+ const verifiedPublicPaymentRailCount = publicRails.filter((rail) => rail.verified).length;
227
+ return {
228
+ schemaVersion: SCHEMA_VERSION,
229
+ catalogVersion: STRIPE_REVENUE_CATALOG_VERSION,
230
+ generatedAt: new Date(generatedAt).toISOString(),
231
+ verified: gaps.length === 0,
232
+ gaps,
233
+ summary: {
234
+ expectedOfferCount: catalog.length,
235
+ verifiedOfferCount,
236
+ priceDriftCount: catalog.length - verifiedOfferCount,
237
+ expectedPublicPaymentRailCount: publicPaymentRails.length,
238
+ verifiedPublicPaymentRailCount,
239
+ paymentRailDriftCount: publicPaymentRails.length - verifiedPublicPaymentRailCount,
240
+ },
241
+ offers,
242
+ publicPaymentRails: publicRails,
243
+ };
244
+ }
245
+
246
+ async function runAudit({
247
+ stripeClient = null,
248
+ stripeFactory = null,
249
+ secretKey = undefined,
250
+ env = process.env,
251
+ secretPaths = DEFAULT_SECRET_PATHS,
252
+ catalog = DEFAULT_STRIPE_REVENUE_CATALOG,
253
+ publicPaymentRails = DEFAULT_STRIPE_PUBLIC_PAYMENT_RAILS,
254
+ generatedAt = new Date().toISOString(),
255
+ requireLiveMode = true,
256
+ } = {}) {
257
+ let credentialSource = stripeClient ? 'injected_client' : null;
258
+ if (!stripeClient && secretKey === undefined) {
259
+ const resolved = resolveStripeSecretKey({ env, secretPaths });
260
+ secretKey = resolved.secretKey;
261
+ credentialSource = resolved.source;
262
+ } else if (!stripeClient && secretKey) {
263
+ credentialSource = 'injected_secret';
264
+ }
265
+ if (!stripeClient && !secretKey) {
266
+ return {
267
+ configured: false,
268
+ credentialSource: null,
269
+ schemaVersion: SCHEMA_VERSION,
270
+ catalogVersion: STRIPE_REVENUE_CATALOG_VERSION,
271
+ generatedAt: new Date(generatedAt).toISOString(),
272
+ verified: false,
273
+ gaps: ['No Stripe credential found in STRIPE_SECRET_KEY or managed local key files.'],
274
+ summary: emptySummary(catalog, publicPaymentRails),
275
+ offers: [],
276
+ publicPaymentRails: [],
277
+ };
278
+ }
279
+ let stripe = stripeClient;
280
+ if (!stripe) {
281
+ try {
282
+ const factory = stripeFactory || loadStripe();
283
+ stripe = factory(secretKey);
284
+ } catch {
285
+ return {
286
+ configured: false,
287
+ credentialSource,
288
+ schemaVersion: SCHEMA_VERSION,
289
+ catalogVersion: STRIPE_REVENUE_CATALOG_VERSION,
290
+ generatedAt: new Date(generatedAt).toISOString(),
291
+ verified: false,
292
+ gaps: ['Stripe SDK could not be initialized.'],
293
+ summary: emptySummary(catalog, publicPaymentRails),
294
+ offers: [],
295
+ publicPaymentRails: [],
296
+ };
297
+ }
298
+ }
299
+ const report = await buildStripeRevenueCatalogAudit({
300
+ stripe,
301
+ catalog,
302
+ publicPaymentRails,
303
+ generatedAt,
304
+ requireLiveMode,
305
+ });
306
+ return { configured: true, credentialSource, ...report };
307
+ }
308
+
309
+ function renderMarkdown(report) {
310
+ const lines = [
311
+ '# Stripe Revenue Catalog Audit',
312
+ '',
313
+ `Generated: ${report.generatedAt}`,
314
+ `Status: ${report.verified ? 'VERIFIED' : 'UNVERIFIED'}`,
315
+ `Catalog version: ${report.catalogVersion}`,
316
+ `Exact live offers: ${report.summary.verifiedOfferCount}/${report.summary.expectedOfferCount}`,
317
+ `Exact public payment rails: ${report.summary.verifiedPublicPaymentRailCount}/${report.summary.expectedPublicPaymentRailCount}`,
318
+ ];
319
+ if (report.gaps.length) {
320
+ lines.push('', '## Evidence gaps', '');
321
+ for (const gap of report.gaps) lines.push(`- ${gap}`);
322
+ }
323
+ return `${lines.join('\n')}\n`;
324
+ }
325
+
326
+ function writeJson(filePath, value) {
327
+ if (!filePath) return null;
328
+ const resolved = path.resolve(filePath);
329
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
330
+ fs.writeFileSync(resolved, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
331
+ return resolved;
332
+ }
333
+
334
+ async function main(argv = process.argv.slice(2)) {
335
+ const options = parseArgs(argv);
336
+ const report = await runAudit();
337
+ const outputPath = writeJson(options.out, report);
338
+ if (options.json) {
339
+ process.stdout.write(`${JSON.stringify({ ...report, outputPath }, null, 2)}\n`);
340
+ } else {
341
+ process.stdout.write(renderMarkdown(report));
342
+ }
343
+ if (!report.verified) process.exitCode = 2;
344
+ }
345
+
346
+ if (path.resolve(process.argv[1] || '') === path.resolve(__filename)) {
347
+ main().catch(() => {
348
+ process.stderr.write('stripe-revenue-catalog-audit FAILED\n');
349
+ process.exit(1);
350
+ });
351
+ }
352
+
353
+ module.exports = {
354
+ SCHEMA_VERSION,
355
+ DEFAULT_STRIPE_PUBLIC_PAYMENT_RAILS,
356
+ parseArgs,
357
+ canonicalPaymentLinkUrl,
358
+ validatePublicPaymentRails,
359
+ buildStripeRevenueCatalogAudit,
360
+ runAudit,
361
+ renderMarkdown,
362
+ writeJson,
363
+ };