thumbgate 1.28.4 → 1.29.1

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 (65) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/llms.txt +18 -10
  3. package/.well-known/mcp/server-card.json +1 -1
  4. package/README.md +5 -2
  5. package/adapters/claude/.mcp.json +2 -2
  6. package/adapters/forge/forge.yaml +3 -3
  7. package/adapters/mcp/server-stdio.js +1 -1
  8. package/adapters/opencode/opencode.json +1 -1
  9. package/bin/cli.js +8 -8
  10. package/bin/postinstall.js +4 -13
  11. package/config/github-about.json +5 -4
  12. package/config/post-deploy-marketing-pages.json +6 -6
  13. package/docs/integrations/grafana/README.md +109 -0
  14. package/docs/integrations/grafana/thumbgate-revenue-evidence-dashboard.json +1930 -0
  15. package/openapi/openapi.yaml +240 -5
  16. package/package.json +59 -19
  17. package/public/agent-manager.html +10 -11
  18. package/public/agents-cost-savings.html +2 -2
  19. package/public/assets/brand/thumbgate-logo-transparent.svg +6 -11
  20. package/public/assets/brand/thumbgate-mark-inline-v3.svg +11 -10
  21. package/public/assets/brand/thumbgate-mark.svg +10 -11
  22. package/public/blog/inside-your-boundary.html +114 -0
  23. package/public/blog/process-over-outcome-gates.html +119 -0
  24. package/public/blog.html +296 -402
  25. package/public/brand/thumbgate-mark.svg +5 -9
  26. package/public/codex-enterprise.html +2 -2
  27. package/public/compare.html +12 -3
  28. package/public/diagnostic.html +79 -29
  29. package/public/guide.html +4 -4
  30. package/public/index.html +1088 -2098
  31. package/public/install.html +3 -3
  32. package/public/js/buyer-intent.js +33 -18
  33. package/public/numbers.html +2 -2
  34. package/public/pricing.html +268 -408
  35. package/public/pro.html +4 -4
  36. package/scripts/billing.js +456 -126
  37. package/scripts/buyer-paths.js +102 -0
  38. package/scripts/cli-feedback.js +2 -2
  39. package/scripts/commercial-offer.js +18 -10
  40. package/scripts/external-customer-audit.js +881 -0
  41. package/scripts/feedback-loop.js +26 -0
  42. package/scripts/gates-engine.js +123 -1
  43. package/scripts/grafana-revenue-evidence.js +856 -0
  44. package/scripts/jsonl-window.js +89 -0
  45. package/scripts/lesson-embedding-index.js +3 -7
  46. package/scripts/meta-agent-loop.js +20 -2
  47. package/scripts/observability-env.js +139 -0
  48. package/scripts/observability-setup.js +55 -0
  49. package/scripts/plausible-domain-config.js +4 -0
  50. package/scripts/provider-live-evidence.js +1290 -0
  51. package/scripts/provider-payment-reconciler.js +442 -0
  52. package/scripts/provider-revenue-evidence.js +249 -0
  53. package/scripts/rate-limiter.js +1 -5
  54. package/scripts/revenue-action-eligibility.js +414 -0
  55. package/scripts/revenue-evidence-remediation.js +694 -0
  56. package/scripts/revenue-offer-system.js +709 -0
  57. package/scripts/sales-pipeline.js +1117 -0
  58. package/scripts/seo-gsd.js +8 -4
  59. package/scripts/stripe-credentials.js +37 -0
  60. package/scripts/stripe-revenue-catalog-audit.js +363 -0
  61. package/scripts/stripe-revenue-catalog.js +164 -0
  62. package/scripts/telemetry-analytics.js +23 -3
  63. package/scripts/vector-store.js +83 -7
  64. package/scripts/workflow-intake-queue.js +483 -0
  65. package/src/api/server.js +521 -114
package/src/api/server.js CHANGED
@@ -11,6 +11,11 @@ const {
11
11
  loadOptionalModule,
12
12
  } = require('../../scripts/private-core-boundary');
13
13
  const { redactSecrets, redactSecretsDeep } = require('../../scripts/secret-redaction');
14
+ const {
15
+ buildIntakeClosePacket,
16
+ buildIntakeDiscoveryPacket,
17
+ buildIntakeQualificationCard,
18
+ } = require('../../scripts/revenue-offer-system');
14
19
 
15
20
  const POSTHOG_API_PATHS = new Set(['/capture', '/batch', '/decide', '/e', '/engage']);
16
21
  const POSTHOG_INGEST_HOST = 'us.i.posthog.com';
@@ -22,9 +27,6 @@ const POSTHOG_STATIC_PATH_PREFIX = '/static/';
22
27
  // bootstrap workflow to regenerate; the new URLs surface in the workflow
23
28
  // summary log.
24
29
  const PRO_CHECKOUT_URL = 'https://buy.stripe.com/8x2dR91M84r4cSd9uj3sI3f';
25
- const FIRST_FAILURE_RULE_CHECKOUT_URL = 'https://buy.stripe.com/7sY6oHaiEbTw6tP5e33sI3e';
26
- const QUICK_READ_CHECKOUT_URL = 'https://buy.stripe.com/5kQ7sL76s1eSaK55e33sI2H';
27
- const WORKFLOW_TEARDOWN_CHECKOUT_URL = 'https://buy.stripe.com/8x214n2Qc4r44lHayn3sI2I';
28
30
  // Verified live amounts (2026-07-13): diagnostic Stripe page contains $499;
29
31
  // sprint code-default Stripe slug previously pointed at a $499 diagnostic product.
30
32
  // Production sprint rail is PayPal at $1500 — pin that as the code fallback so a
@@ -129,11 +131,13 @@ const {
129
131
  rotateApiKey,
130
132
  handleWebhook,
131
133
  verifyWebhookSignature,
132
- verifyGithubWebhookSignature,
134
+ recordGithubMarketplaceWebhookDelivery,
135
+ recordPayPalWebhookDelivery,
133
136
  handleGithubWebhook,
134
137
  getFunnelAnalytics,
135
138
  getBillingSummary,
136
139
  getBillingSummaryLive,
140
+ getBillingSummariesLive,
137
141
  } = require('../../scripts/billing');
138
142
  const {
139
143
  DEFAULT_PUBLIC_APP_ORIGIN,
@@ -259,12 +263,14 @@ const LEARN_DIR = path.resolve(__dirname, '../../public/learn');
259
263
  const GUIDES_DIR = path.resolve(__dirname, '../../public/guides');
260
264
  const COMPARE_DIR = path.resolve(__dirname, '../../public/compare');
261
265
  const USE_CASES_DIR = path.resolve(__dirname, '../../public/use-cases');
266
+ const BLOG_DIR = path.resolve(__dirname, '../../public/blog');
262
267
  const PUBLIC_DIR = path.resolve(__dirname, '../../public');
263
268
  const PUBLIC_ASSETS_DIR = path.resolve(__dirname, '../../public/assets');
264
269
  const LEARN_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(LEARN_DIR);
265
270
  const GUIDE_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(GUIDES_DIR);
266
271
  const COMPARE_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(COMPARE_DIR);
267
272
  const USE_CASE_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(USE_CASES_DIR);
273
+ const BLOG_PAGE_PATHS_BY_SLUG = buildPublicHtmlFileMap(BLOG_DIR);
268
274
  const BUYER_INTENT_SCRIPT_PATH = path.resolve(__dirname, '../../public/js/buyer-intent.js');
269
275
  const STATIC_MIME_BY_EXT = Object.freeze({
270
276
  '.png': 'image/png',
@@ -482,34 +488,46 @@ const TRACKED_LINK_TARGETS = Object.freeze({
482
488
  allowCustomerEmail: true,
483
489
  },
484
490
  diagnostic: {
485
- configUrlKey: 'sprintDiagnosticCheckoutUrl',
486
- fallbackHref: SPRINT_DIAGNOSTIC_CHECKOUT_URL,
487
- external: true,
491
+ path: '/diagnostic',
488
492
  ctaId: 'go_diagnostic',
489
493
  ctaPlacement: 'link_router',
490
- eventType: 'cta_click',
494
+ eventType: 'diagnostic_intent_open',
491
495
  defaults: {
492
496
  utm_source: 'website',
493
497
  utm_medium: 'link_router',
494
498
  utm_campaign: 'sprint_diagnostic',
495
499
  plan_id: 'sprint_diagnostic',
496
500
  },
501
+ },
502
+ 'diagnostic-pay': {
503
+ configUrlKey: 'sprintDiagnosticCheckoutUrl',
504
+ fallbackHref: SPRINT_DIAGNOSTIC_CHECKOUT_URL,
505
+ external: true,
506
+ ctaId: 'go_diagnostic_pay',
507
+ ctaPlacement: 'diagnostic_confirmation',
508
+ eventType: 'diagnostic_checkout_confirmed',
509
+ requiresPost: true,
510
+ requiresBuyerEmail: true,
511
+ prefillStripeEmail: true,
512
+ defaults: {
513
+ utm_source: 'diagnostic_page',
514
+ utm_medium: 'diagnostic_confirmation',
515
+ utm_campaign: 'sprint_diagnostic',
516
+ plan_id: 'sprint_diagnostic',
517
+ },
497
518
  allowCustomerEmail: true,
498
519
  },
499
520
  sprint: {
500
- configUrlKey: 'workflowSprintCheckoutUrl',
501
- fallbackHref: WORKFLOW_SPRINT_CHECKOUT_URL,
502
- external: true,
521
+ path: '/diagnostic',
503
522
  ctaId: 'go_sprint',
504
523
  ctaPlacement: 'link_router',
505
- eventType: 'cta_click',
524
+ eventType: 'workflow_sprint_scope_open',
506
525
  defaults: {
507
526
  utm_source: 'website',
508
527
  utm_medium: 'link_router',
509
528
  utm_campaign: 'workflow_sprint',
510
529
  plan_id: 'workflow_sprint',
511
530
  },
512
- allowCustomerEmail: true,
513
531
  },
514
532
  trial: {
515
533
  path: '/guide',
@@ -1519,6 +1537,180 @@ function resolveBillingSummaryOptions(parsed) {
1519
1537
  });
1520
1538
  }
1521
1539
 
1540
+ const BILLING_SUMMARY_BATCH_WINDOWS = Object.freeze(['today', '30d', 'lifetime']);
1541
+ const WORKFLOW_INTAKE_QUEUE_DEFAULT_LIMIT = 50;
1542
+ const WORKFLOW_INTAKE_QUEUE_MAX_LIMIT = 100;
1543
+ const PRIVATE_OPERATOR_RESPONSE_HEADERS = Object.freeze({
1544
+ 'Cache-Control': 'private, no-store, max-age=0',
1545
+ Pragma: 'no-cache',
1546
+ Vary: 'Authorization',
1547
+ });
1548
+
1549
+ function resolveBillingSummaryBatchOptions(parsed) {
1550
+ return Object.fromEntries(BILLING_SUMMARY_BATCH_WINDOWS.map((window) => [
1551
+ window,
1552
+ resolveAnalyticsWindow({
1553
+ window,
1554
+ timeZone: parsed.searchParams.get('timezone'),
1555
+ now: parsed.searchParams.get('now'),
1556
+ }),
1557
+ ]));
1558
+ }
1559
+
1560
+ function wantsWorkflowIntakeQueue(parsed) {
1561
+ return ['1', 'true', 'yes'].includes(
1562
+ String(parsed.searchParams.get('include_intake_queue') || '').trim().toLowerCase()
1563
+ );
1564
+ }
1565
+
1566
+ function resolveWorkflowIntakeQueueOptions(parsed, workflowSprintIntake) {
1567
+ const rawLimit = Number(
1568
+ parsed.searchParams.get('intake_limit') ||
1569
+ parsed.searchParams.get('limit') ||
1570
+ WORKFLOW_INTAKE_QUEUE_DEFAULT_LIMIT
1571
+ );
1572
+ if (!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > WORKFLOW_INTAKE_QUEUE_MAX_LIMIT) {
1573
+ throw createHttpError(
1574
+ 400,
1575
+ `intake_limit must be an integer from 1 to ${WORKFLOW_INTAKE_QUEUE_MAX_LIMIT}`
1576
+ );
1577
+ }
1578
+
1579
+ const allowedStatuses = new Set(workflowSprintIntake.WORKFLOW_SPRINT_STATUS_FLOW || []);
1580
+ const rawStatuses = String(
1581
+ parsed.searchParams.get('intake_status') ||
1582
+ parsed.searchParams.get('status') ||
1583
+ 'new,qualified'
1584
+ )
1585
+ .split(',')
1586
+ .map((value) => value.trim().toLowerCase())
1587
+ .filter(Boolean);
1588
+ const allRequested = rawStatuses.length === 1 && rawStatuses[0] === 'all';
1589
+ const statuses = allRequested ? [] : [...new Set(rawStatuses)];
1590
+ const invalid = statuses.filter((status) => !allowedStatuses.has(status));
1591
+ if (invalid.length > 0 || (!allRequested && statuses.length === 0)) {
1592
+ throw createHttpError(
1593
+ 400,
1594
+ `intake_status must be all or a comma-separated subset of: ${[...allowedStatuses].join(', ')}`
1595
+ );
1596
+ }
1597
+
1598
+ return { limit: rawLimit, statuses };
1599
+ }
1600
+
1601
+ function projectWorkflowIntakeQueueLead(lead = {}, options = {}) {
1602
+ const contact = lead.contact || {};
1603
+ const qualification = lead.qualification || {};
1604
+ const attribution = lead.attribution || {};
1605
+ return {
1606
+ leadId: lead.leadId || null,
1607
+ submittedAt: lead.submittedAt || null,
1608
+ updatedAt: lead.updatedAt || null,
1609
+ status: lead.status || null,
1610
+ offer: lead.offer || null,
1611
+ contact: {
1612
+ name: contact.name || null,
1613
+ email: contact.email || null,
1614
+ company: contact.company || null,
1615
+ },
1616
+ qualification: {
1617
+ workflow: qualification.workflow || null,
1618
+ owner: qualification.owner || null,
1619
+ blocker: qualification.blocker || null,
1620
+ runtime: qualification.runtime || null,
1621
+ urgency: qualification.urgency || null,
1622
+ note: qualification.note || null,
1623
+ },
1624
+ attribution: {
1625
+ source: attribution.source || null,
1626
+ utmSource: attribution.utmSource || null,
1627
+ utmMedium: attribution.utmMedium || null,
1628
+ utmCampaign: attribution.utmCampaign || null,
1629
+ creator: attribution.creator || null,
1630
+ community: attribution.community || null,
1631
+ planId: attribution.planId || null,
1632
+ page: attribution.page || null,
1633
+ landingPath: attribution.landingPath || null,
1634
+ },
1635
+ nextOperatorStep: options.closePacket?.status === 'approval_ready_not_authorized'
1636
+ ? 'request_action_time_approval'
1637
+ : options.discoveryPacket?.status === 'approval_ready_not_authorized'
1638
+ ? 'request_action_time_approval_for_discovery'
1639
+ : lead.status === 'new' ? 'review_and_qualify' : 'prepare_scope_or_hold',
1640
+ qualificationCard: options.qualificationCard || buildIntakeQualificationCard(lead, options),
1641
+ discoveryPacket: options.discoveryPacket || buildIntakeDiscoveryPacket(lead, options),
1642
+ closePacket: options.closePacket || buildIntakeClosePacket(lead, options),
1643
+ };
1644
+ }
1645
+
1646
+ function buildWorkflowIntakeQueue(parsed, workflowSprintIntake, feedbackDir, options = {}) {
1647
+ const { limit, statuses } = resolveWorkflowIntakeQueueOptions(parsed, workflowSprintIntake);
1648
+ const leads = workflowSprintIntake.loadWorkflowSprintLeads(feedbackDir);
1649
+ const generatedAt = new Date().toISOString();
1650
+ const byStatus = {};
1651
+ for (const lead of leads) {
1652
+ const status = lead.status || 'unknown';
1653
+ byStatus[status] = (byStatus[status] || 0) + 1;
1654
+ }
1655
+ const eligible = leads
1656
+ .filter((lead) => statuses.length === 0 || statuses.includes(lead.status))
1657
+ .map((lead) => {
1658
+ const qualificationReviewVerified =
1659
+ typeof workflowSprintIntake.isEvidenceBasedQualificationReview === 'function' &&
1660
+ workflowSprintIntake.isEvidenceBasedQualificationReview(lead.qualificationReview);
1661
+ const cardOptions = {
1662
+ now: generatedAt,
1663
+ qualificationReviewVerified,
1664
+ publicOrigin: options.publicOrigin,
1665
+ };
1666
+ return {
1667
+ lead,
1668
+ qualificationCard: buildIntakeQualificationCard(lead, cardOptions),
1669
+ discoveryPacket: buildIntakeDiscoveryPacket(lead, cardOptions),
1670
+ closePacket: buildIntakeClosePacket(lead, cardOptions),
1671
+ };
1672
+ })
1673
+ .sort((a, b) =>
1674
+ b.qualificationCard.priorityScore - a.qualificationCard.priorityScore ||
1675
+ String(b.lead.submittedAt || '').localeCompare(String(a.lead.submittedAt || '')));
1676
+ const selected = eligible.slice(0, limit);
1677
+ const approvalReady = eligible.filter(
1678
+ (entry) => entry.closePacket.status === 'approval_ready_not_authorized'
1679
+ );
1680
+ const discoveryReady = eligible.filter(
1681
+ (entry) => entry.discoveryPacket.status === 'approval_ready_not_authorized'
1682
+ );
1683
+ const chronological = eligible
1684
+ .map((entry) => entry.lead)
1685
+ .sort((a, b) => String(b.submittedAt || '').localeCompare(String(a.submittedAt || '')));
1686
+ return {
1687
+ generatedAt,
1688
+ total: leads.length,
1689
+ eligibleTotal: eligible.length,
1690
+ returned: selected.length,
1691
+ approvalReadyTotal: approvalReady.length,
1692
+ discoveryReadyTotal: discoveryReady.length,
1693
+ primaryApprovalAction: approvalReady[0]?.closePacket || null,
1694
+ primaryDiscoveryAction: discoveryReady[0]?.discoveryPacket || null,
1695
+ byStatus,
1696
+ filters: {
1697
+ statuses: statuses.length === 0 ? ['all'] : statuses,
1698
+ limit,
1699
+ },
1700
+ latestSubmittedAt: chronological[0] ? chronological[0].submittedAt || null : null,
1701
+ oldestSubmittedAt: chronological.length ? chronological[chronological.length - 1].submittedAt || null : null,
1702
+ leads: selected.map((entry, index) => ({
1703
+ ...projectWorkflowIntakeQueueLead(entry.lead, {
1704
+ now: generatedAt,
1705
+ qualificationCard: entry.qualificationCard,
1706
+ discoveryPacket: entry.discoveryPacket,
1707
+ closePacket: entry.closePacket,
1708
+ }),
1709
+ priorityRank: index + 1,
1710
+ })),
1711
+ };
1712
+ }
1713
+
1522
1714
  function sendInvalidAnalyticsWindowProblem(res, title, err) {
1523
1715
  sendProblem(res, {
1524
1716
  type: PROBLEM_TYPES.INVALID_REQUEST,
@@ -1528,6 +1720,18 @@ function sendInvalidAnalyticsWindowProblem(res, title, err) {
1528
1720
  });
1529
1721
  }
1530
1722
 
1723
+ function sendWorkflowIntakeQueueProblem(res, err) {
1724
+ const status = Number(err && err.statusCode) || 400;
1725
+ sendProblem(res, {
1726
+ type: status >= 500 ? PROBLEM_TYPES.INTERNAL : PROBLEM_TYPES.INVALID_REQUEST,
1727
+ title: status >= 500
1728
+ ? 'Workflow intake queue unavailable'
1729
+ : 'Invalid workflow intake queue query',
1730
+ status,
1731
+ detail: err?.message ? err.message : 'Workflow intake queue request failed.',
1732
+ });
1733
+ }
1734
+
1531
1735
  function resolveBillingSummaryOptionsOrRespondProblem(res, parsed, invalidTitle) {
1532
1736
  try {
1533
1737
  return resolveBillingSummaryOptions(parsed);
@@ -2261,11 +2465,12 @@ a{display:block;text-decoration:none}a.secondary{border:1px solid #374151;color:
2261
2465
  <div class="brand"><span class="brand-mark"></span><span>ThumbGate</span></div>
2262
2466
  <h1>Start ThumbGate Pro</h1>
2263
2467
  <div class="price">$19<small>/mo</small></div>
2264
- <p>The npm package runs your gates locally. <strong>Pro</strong> removes solo caps and adds personal recall, proof, exports, and adapter maintenance. Shared hosted lessons and org dashboards are Enterprise.</p>
2265
- <form action="${PRO_CHECKOUT_URL}" method="GET" data-i="pro_checkout_confirmed">
2468
+ <p>The npm package runs your gates locally. <strong>Pro</strong> removes solo caps and adds personal recall, proof, exports, and adapter maintenance. Enterprise service work is qualified after intake; hosted team sync and a hosted org dashboard are not generally available.</p>
2469
+ <form action="/checkout/pro" method="POST" data-i="pro_checkout_confirmed">
2266
2470
  ${hiddenInputs}
2267
- <input type="email" name="prefilled_email" value="${escapeHtmlAttribute(prefilledEmail)}" placeholder="you@company.com" autocomplete="email">
2268
- <p class="email-note">Optional. Stripe can collect your email on the secure checkout page.</p>
2471
+ <input type="hidden" name="confirm" value="1">
2472
+ <input type="email" name="customer_email" value="${escapeHtmlAttribute(prefilledEmail)}" placeholder="you@company.com" autocomplete="email" required>
2473
+ <p class="email-note">Required so the checkout has attributable buyer intent and your receipt can be recovered.</p>
2269
2474
  <button type="submit" class="primary">Pay $19/mo with Stripe →</button>
2270
2475
  </form>
2271
2476
  <a class="secondary" data-i="workflow_sprint_intake" href="/#workflow-sprint-intake">Not sure yet? Send the workflow first</a>
@@ -2281,7 +2486,7 @@ ${hiddenInputs}
2281
2486
  </div>
2282
2487
  <div class="feedback-saved" id="feedback-saved">Feedback saved.</div>
2283
2488
  </div>
2284
- <div class="trust"><div class="trust-item">Personal recall and proof exports without free-tier caps</div><div class="trust-item">Adapter matrix kept current for Claude Code, Cursor, Codex, Gemini, Amp, Cline, OpenCode</div><div class="trust-item">Personal dashboard: gate stats, rule evidence, DPO export</div><div class="trust-item">Enterprise adds shared hosted lessons, org visibility, rollout support</div></div>
2489
+ <div class="trust"><div class="trust-item">Personal recall and proof exports without free-tier caps</div><div class="trust-item">Adapter matrix kept current for Claude Code, Cursor, Codex, Gemini, Amp, Cline, OpenCode</div><div class="trust-item">Personal dashboard: gate stats, rule evidence, DPO export</div><div class="trust-item">Enterprise service work is qualified after intake; hosted team sync and a hosted org dashboard are not generally available</div></div>
2285
2490
  <p class="back"><a href="/">← Back to thumbgate.ai</a></p>
2286
2491
  </main>
2287
2492
  <script>
@@ -2475,7 +2680,14 @@ function appendTrackedLinkQueryParams(destinationUrl, parsed, target) {
2475
2680
  }
2476
2681
  }
2477
2682
  if (target.allowCustomerEmail) {
2478
- appendQueryParam(destinationUrl, 'customer_email', params.get('customer_email'));
2683
+ const customerEmail = normalizeCheckoutCustomerEmail(params.get('customer_email'));
2684
+ if (customerEmail) {
2685
+ appendQueryParam(
2686
+ destinationUrl,
2687
+ target.prefillStripeEmail ? 'prefilled_email' : 'customer_email',
2688
+ customerEmail
2689
+ );
2690
+ }
2479
2691
  }
2480
2692
  if (!destinationUrl.searchParams.has('cta_id')) {
2481
2693
  appendQueryParam(destinationUrl, 'cta_id', target.ctaId);
@@ -2553,6 +2765,31 @@ function serveTrackedLinkRedirect({ req, res, parsed, hostedConfig, isHeadReques
2553
2765
  return;
2554
2766
  }
2555
2767
 
2768
+ if (target.requiresPost && req.method !== 'POST') {
2769
+ sendJson(res, 405, {
2770
+ error: 'Explicit checkout confirmation is required',
2771
+ next: '/diagnostic',
2772
+ }, {
2773
+ Allow: 'POST',
2774
+ 'Cache-Control': 'no-store',
2775
+ }, {
2776
+ headOnly: isHeadRequest,
2777
+ });
2778
+ return;
2779
+ }
2780
+ if (
2781
+ target.requiresBuyerEmail
2782
+ && !normalizeCheckoutCustomerEmail(parsed.searchParams.get('customer_email'))
2783
+ ) {
2784
+ sendJson(res, 400, {
2785
+ error: 'A valid buyer email is required before checkout',
2786
+ next: '/diagnostic',
2787
+ }, {
2788
+ 'Cache-Control': 'no-store',
2789
+ });
2790
+ return;
2791
+ }
2792
+
2556
2793
  const { FEEDBACK_DIR } = getFeedbackPaths();
2557
2794
  const journeyState = resolveJourneyState(req, parsed);
2558
2795
  const destinationUrl = buildTrackedLinkDestination(target, hostedConfig, parsed);
@@ -2573,7 +2810,7 @@ function serveTrackedLinkRedirect({ req, res, parsed, hostedConfig, isHeadReques
2573
2810
  );
2574
2811
  }
2575
2812
 
2576
- res.writeHead(302, {
2813
+ res.writeHead(req.method === 'POST' ? 303 : 302, {
2577
2814
  ...(journeyState.setCookieHeaders.length ? { 'Set-Cookie': journeyState.setCookieHeaders } : {}),
2578
2815
  'Cache-Control': 'no-store',
2579
2816
  'Referrer-Policy': 'strict-origin-when-cross-origin',
@@ -3599,6 +3836,7 @@ function renderSitemapXml(runtimeConfig) {
3599
3836
  const seoDirectories = [
3600
3837
  { dir: 'compare', route: '/compare', priority: '0.85' },
3601
3838
  { dir: 'guides', route: '/guides', priority: '0.85' },
3839
+ { dir: 'blog', route: '/blog', priority: '0.8' },
3602
3840
  ];
3603
3841
  for (const catalog of seoDirectories) {
3604
3842
  const files = fs.readdirSync(path.join(PUBLIC_DIR, catalog.dir)).sort((a, b) => a.localeCompare(b));
@@ -4113,30 +4351,16 @@ function renderCheckoutSuccessPage(runtimeConfig) {
4113
4351
  }
4114
4352
 
4115
4353
  function renderCheckoutCancelledPage(runtimeConfig) {
4116
- const firstFailureRuleCheckoutUrl = escapeHtmlAttribute(FIRST_FAILURE_RULE_CHECKOUT_URL);
4117
- const quickReadCheckoutUrl = escapeHtmlAttribute(QUICK_READ_CHECKOUT_URL);
4118
- const workflowTeardownCheckoutUrl = escapeHtmlAttribute(WORKFLOW_TEARDOWN_CHECKOUT_URL);
4119
- const diagnosticCheckoutUrl = runtimeConfig.sprintDiagnosticCheckoutUrl
4120
- ? escapeHtmlAttribute(runtimeConfig.sprintDiagnosticCheckoutUrl)
4121
- : '';
4122
- const workflowSprintCheckoutUrl = runtimeConfig.workflowSprintCheckoutUrl
4123
- ? escapeHtmlAttribute(runtimeConfig.workflowSprintCheckoutUrl)
4124
- : '';
4125
4354
  const sprintDiagnosticPriceDollars = runtimeConfig.sprintDiagnosticPriceDollars || 499;
4126
4355
  const workflowSprintPriceDollars = runtimeConfig.workflowSprintPriceDollars || 1500;
4127
- const workflowSprintIntakeUrl = `${escapeHtmlAttribute(runtimeConfig.appOrigin)}/#workflow-sprint-intake`;
4356
+ const diagnosticUrl = escapeHtmlAttribute(new URL('/diagnostic', runtimeConfig.appOrigin).toString());
4357
+ const workflowSprintUrl = escapeHtmlAttribute(new URL('/go/sprint', runtimeConfig.appOrigin).toString());
4358
+ const workflowSprintIntakeUrl = escapeHtmlAttribute(new URL('/#workflow-sprint-intake', runtimeConfig.appOrigin).toString());
4128
4359
  const recoveryOfferLinks = [
4129
- diagnosticCheckoutUrl
4130
- ? `<a href="${diagnosticCheckoutUrl}" data-recovery-offer="sprint_diagnostic" data-offer-price="${sprintDiagnosticPriceDollars}">Book $${sprintDiagnosticPriceDollars} diagnostic</a>`
4131
- : '',
4132
- workflowSprintCheckoutUrl
4133
- ? `<a href="${workflowSprintCheckoutUrl}" data-recovery-offer="workflow_sprint" data-offer-price="${workflowSprintPriceDollars}">Start $${workflowSprintPriceDollars} sprint</a>`
4134
- : '',
4135
- `<a href="${workflowTeardownCheckoutUrl}" data-recovery-offer="workflow_teardown" data-offer-price="99">Pay $99 teardown</a>`,
4136
- `<a href="${quickReadCheckoutUrl}" data-recovery-offer="quick_read" data-offer-price="19">Pay $19 quick read</a>`,
4137
- `<a href="${firstFailureRuleCheckoutUrl}" data-recovery-offer="first_failure_rule" data-offer-price="1">Pay $1 first rule</a>`,
4138
4360
  `<a id="send-workflow-first" href="${workflowSprintIntakeUrl}" data-recovery-offer="workflow_sprint_intake" data-offer-price="0">Send workflow first</a>`,
4139
- ].filter(Boolean).join('\n ');
4361
+ `<a href="${diagnosticUrl}" data-recovery-offer="sprint_diagnostic" data-offer-price="${sprintDiagnosticPriceDollars}">Review $${sprintDiagnosticPriceDollars} diagnostic</a>`,
4362
+ `<a href="${workflowSprintUrl}" data-recovery-offer="workflow_sprint" data-offer-price="${workflowSprintPriceDollars}">Scope $${workflowSprintPriceDollars} sprint</a>`,
4363
+ ].join('\n ');
4140
4364
  const recoveryOfferCard = recoveryOfferLinks
4141
4365
  ? `<div class="card recovery-card">
4142
4366
  <h2>Need help deciding?</h2>
@@ -4504,19 +4728,24 @@ function renderBrokerAuditIntakeResultPage(runtimeConfig, { title, detail, leadI
4504
4728
  function readBodyBuffer(req, maxBytes = 1024 * 1024) {
4505
4729
  return new Promise((resolve, reject) => {
4506
4730
  let total = 0;
4731
+ let oversize = false;
4507
4732
  const chunks = [];
4508
4733
 
4509
4734
  req.on('data', (chunk) => {
4510
4735
  total += chunk.length;
4511
4736
  if (total > maxBytes) {
4512
- reject(createHttpError(413, 'Request body too large'));
4513
- req.destroy();
4737
+ oversize = true;
4738
+ chunks.length = 0;
4514
4739
  return;
4515
4740
  }
4516
- chunks.push(chunk);
4741
+ if (!oversize) chunks.push(chunk);
4517
4742
  });
4518
4743
 
4519
4744
  req.on('end', () => {
4745
+ if (oversize) {
4746
+ reject(createHttpError(413, 'Request body too large'));
4747
+ return;
4748
+ }
4520
4749
  if (chunks.length === 0) {
4521
4750
  resolve(Buffer.alloc(0));
4522
4751
  return;
@@ -5320,11 +5549,38 @@ function createApiServer() {
5320
5549
 
5321
5550
  // Public endpoints — no auth required
5322
5551
  const trackedLinkMatch = pathname.match(/^\/go\/([^/]+)$/);
5323
- if (isGetLikeRequest && trackedLinkMatch) {
5552
+ if ((isGetLikeRequest || req.method === 'POST') && trackedLinkMatch) {
5553
+ let trackedParsed = parsed;
5554
+ if (req.method === 'POST') {
5555
+ const target = getTrackedLinkTarget(trackedLinkMatch[1]);
5556
+ if (!target || !target.requiresPost) {
5557
+ sendJson(res, target ? 405 : 404, target
5558
+ ? { error: 'POST is not supported for this tracked link' }
5559
+ : { error: 'Tracked link not found', allowed: Object.keys(TRACKED_LINK_TARGETS) }, {
5560
+ Allow: 'GET, HEAD',
5561
+ 'Cache-Control': 'no-store',
5562
+ });
5563
+ return;
5564
+ }
5565
+ const contentType = String(req.headers['content-type'] || '').toLowerCase();
5566
+ if (!contentType.includes('application/x-www-form-urlencoded')) {
5567
+ sendJson(res, 415, { error: 'Form-encoded checkout confirmation is required' }, {
5568
+ 'Cache-Control': 'no-store',
5569
+ });
5570
+ return;
5571
+ }
5572
+ const form = await parseFormBody(req, 16 * 1024);
5573
+ trackedParsed = new URL(parsed.toString());
5574
+ for (const [key, value] of Object.entries(form)) {
5575
+ if (key === 'customer_email' || TRACKED_LINK_QUERY_KEYS.includes(key)) {
5576
+ trackedParsed.searchParams.set(key, String(value || '').trim());
5577
+ }
5578
+ }
5579
+ }
5324
5580
  serveTrackedLinkRedirect({
5325
5581
  req,
5326
5582
  res,
5327
- parsed,
5583
+ parsed: trackedParsed,
5328
5584
  hostedConfig,
5329
5585
  isHeadRequest,
5330
5586
  slug: trackedLinkMatch[1],
@@ -5782,6 +6038,22 @@ async function addContext(){
5782
6038
  return;
5783
6039
  }
5784
6040
 
6041
+ if (isGetLikeRequest && pathname.startsWith('/blog/') && pathname !== '/blog/') {
6042
+ try {
6043
+ const blogPostPath = resolvePublicHtmlFile(BLOG_PAGE_PATHS_BY_SLUG, pathname.replace('/blog/', ''));
6044
+ if (!blogPostPath) {
6045
+ sendJson(res, 404, { error: 'Post not found' });
6046
+ return;
6047
+ }
6048
+ const requestHost = getSafePublicRequestHost(req);
6049
+ const html = normalizePublicMarketingHtml(fs.readFileSync(blogPostPath, 'utf-8'), hostedConfig, requestHost);
6050
+ sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
6051
+ } catch {
6052
+ sendJson(res, 404, { error: 'Post not found' });
6053
+ }
6054
+ return;
6055
+ }
6056
+
5785
6057
  if (isGetLikeRequest && (pathname === '/learn' || pathname === '/learn.html')) {
5786
6058
  try {
5787
6059
  servePublicMarketingPage({
@@ -6132,11 +6404,11 @@ async function addContext(){
6132
6404
  return;
6133
6405
  }
6134
6406
 
6135
- // HOTFIX 2026-06-04 — accept ANY method (GET/HEAD/POST) on /checkout/pro
6407
+ // HOTFIX 2026-06-04 — accept GET, HEAD, and POST on /checkout/pro
6136
6408
  // to prevent the API-key guard from 401'ing real prospective customers
6137
- // whose forms or fetch() calls land via POST. Audit: 69 emails submitted
6138
- // → 0 paid because POST hit the auth gate. Query params still drive the
6139
- // Stripe session creation; POST bodies are ignored harmlessly.
6409
+ // whose forms land via POST. Audit: 69 emails submitted → 0 paid because
6410
+ // POST hit the auth gate. Form bodies are now allowlisted into the same
6411
+ // attribution path as query parameters before checkout evaluation.
6140
6412
  if ((isGetLikeRequest || req.method === 'POST') && pathname === '/checkout/pro') {
6141
6413
  if (isHeadRequest) {
6142
6414
  sendHtml(res, 200, '', {}, {
@@ -6145,6 +6417,26 @@ async function addContext(){
6145
6417
  return;
6146
6418
  }
6147
6419
 
6420
+ if (req.method === 'POST') {
6421
+ const contentType = String(req.headers['content-type'] || '').toLowerCase();
6422
+ if (!contentType.includes('application/x-www-form-urlencoded')) {
6423
+ sendJson(res, 415, { error: 'Form-encoded checkout confirmation is required' }, {
6424
+ 'Cache-Control': 'no-store',
6425
+ });
6426
+ return;
6427
+ }
6428
+ const form = await parseFormBody(req, 16 * 1024);
6429
+ for (const [key, value] of Object.entries(form)) {
6430
+ if (
6431
+ key === 'confirm'
6432
+ || key === 'customer_email'
6433
+ || TRACKED_LINK_QUERY_KEYS.includes(key)
6434
+ ) {
6435
+ parsed.searchParams.set(key, String(value || '').trim());
6436
+ }
6437
+ }
6438
+ }
6439
+
6148
6440
  const { FEEDBACK_DIR } = getFeedbackPaths();
6149
6441
  const journeyState = resolveJourneyState(req, parsed);
6150
6442
  const bootstrapBody = buildCheckoutBootstrapBody(parsed, req, journeyState);
@@ -6163,33 +6455,26 @@ async function addContext(){
6163
6455
  // it and followed it, bypassing the bot deflection. A bot crawl never
6164
6456
  // reaches the payment form, so the session is guaranteed waste.
6165
6457
  //
6166
- // Fix: bot + confirm=1 (alone) deflects back to the interstitial. Two
6167
- // escape hatches preserve real-user flows: (a) POST always proceeds
6168
- // (form submission JS-less bots don't do), (b) a `customer_email`
6169
- // query param treats the request as confirmed even from a bot UA,
6170
- // because no real crawler appends customer_email to discovered URLs.
6458
+ // Fix: bot-classified traffic never reaches session creation, and a
6459
+ // browser must provide both explicit confirmation and a valid email.
6171
6460
  const rawCheckoutEmail = normalizeNullableText(bootstrapBody.customerEmail);
6172
6461
  const normalizedCheckoutEmail = normalizeCheckoutCustomerEmail(rawCheckoutEmail);
6173
6462
  const hasCustomerEmailHint = !!parsed?.searchParams?.has('customer_email');
6174
6463
  const hasValidCustomerEmailHint = !!normalizedCheckoutEmail;
6175
- const botShouldBypass = !botClassification.isBot || hasValidCustomerEmailHint;
6176
- // 2026-06-29 conversion audit: requiring email before Stripe gave us a
6177
- // recoverable abandoned-session theory, but the hosted funnel showed
6178
- // traffic with 0 checkout starts. Let confirmed human clicks reach
6179
- // Stripe; Stripe can collect email on the secure checkout page. Bots
6180
- // still need a valid email hint to bypass deflection.
6464
+ const botShouldBypass = !botClassification.isBot;
6465
+ // A valid email is the explicit human-intent proof for both GET
6466
+ // confirmation and form POST. This prevents browser-shaped monitors
6467
+ // from minting anonymous sessions while preserving a one-field path to
6468
+ // Stripe for real buyers.
6181
6469
  const isConfirmedCheckout = (
6182
6470
  (req.method === 'POST' && hasValidCustomerEmailHint)
6183
- || hasConfirmFlag
6471
+ || (hasConfirmFlag && hasValidCustomerEmailHint)
6184
6472
  ) && botShouldBypass;
6185
- // 2026-06-05 revenue bypass: env-gated direct-to-Stripe redirect.
6186
- // Live 30d billing showed 254 interstitial views → 1 Stripe click-through
6187
- // → 0 paid. When THUMBGATE_CHECKOUT_INTERSTITIAL_BYPASS=1 is set we
6188
- // route raw /checkout/pro GETs (no confirm=1, no POST) straight to the
6189
- // pro Stripe Payment Link, preserving UTM + attribution metadata via
6190
- // buildCheckoutFallbackUrl. Default-off; bot-deflection still applies
6191
- // (bot + no email hint still falls through to the existing interstitial).
6192
- const interstitialBypassEnabled = process.env.THUMBGATE_CHECKOUT_INTERSTITIAL_BYPASS !== '0';
6473
+ // Legacy env-gated bypass. It is default-off and still requires a valid
6474
+ // email plus a non-bot request; enabling it must never re-open anonymous
6475
+ // Stripe-session creation. Attribution is preserved via
6476
+ // buildCheckoutFallbackUrl.
6477
+ const interstitialBypassEnabled = process.env.THUMBGATE_CHECKOUT_INTERSTITIAL_BYPASS === '1';
6193
6478
  const interstitialSampleRate = normalizeCheckoutInterstitialSampleRate(
6194
6479
  process.env.THUMBGATE_CHECKOUT_INTERSTITIAL_SAMPLE_RATE
6195
6480
  );
@@ -6203,6 +6488,7 @@ async function addContext(){
6203
6488
  && interstitialBypassEnabled
6204
6489
  && req.method !== 'POST'
6205
6490
  && botShouldBypass
6491
+ && hasValidCustomerEmailHint
6206
6492
  && !interstitialSampled
6207
6493
  ) {
6208
6494
  // Always target the pro Stripe Payment Link directly. The
@@ -6258,19 +6544,10 @@ async function addContext(){
6258
6544
  planId: analyticsMetadata.planId,
6259
6545
  },
6260
6546
  }).catch(() => {});
6261
- // Render the interstitial for ALL non-confirmed GETs (bot or human),
6262
- // not just bot traffic. Rationale: a raw GET on /checkout/pro currently
6263
- // 302s straight to a fresh Stripe `cs_live_*` session, regardless of
6264
- // whether the visitor is a search crawler, a link-preview fetcher, or
6265
- // a confused human who doesn't yet know what they're paying for.
6266
- // That created the 50-zombie-sessions / 0-paid pattern the CEO flagged
6267
- // 2026-05-13. Every visitor now sees the $19/mo confirmation page first
6268
- // and must click "Pay $19/mo with Stripe →" (which sets confirm=1) to
6269
- // trigger the Stripe-session creation + redirect. Counter-risk: one
6270
- // extra click on the human path. Mitigated because the interstitial
6271
- // also serves as a value-preview ("5,200+ npm installs/mo, MIT open source,
6272
- // cancel anytime"), which typically lifts conversion more than the
6273
- // click-friction costs.
6547
+ // Render the intent form for every non-confirmed request. A raw GET can
6548
+ // come from a crawler, link preview, monitor, or visitor who has not yet
6549
+ // supplied buyer identity. The form requires email-backed confirmation
6550
+ // before any Stripe session is created.
6274
6551
  if (!isConfirmedCheckout) {
6275
6552
  const eventType = botClassification.isBot
6276
6553
  ? 'checkout_bot_deflected'
@@ -6316,16 +6593,6 @@ async function addContext(){
6316
6593
  return;
6317
6594
  }
6318
6595
 
6319
- if (!normalizedCheckoutEmail) {
6320
- appendBestEffortTelemetry(FEEDBACK_DIR, {
6321
- eventType: 'checkout_email_deferred_to_stripe',
6322
- clientType: 'web',
6323
- traceId,
6324
- page: '/checkout/pro',
6325
- planId: analyticsMetadata.planId,
6326
- reason: rawCheckoutEmail ? 'invalid_customer_email' : 'missing_customer_email',
6327
- }, req.headers, 'checkout_email_deferred_to_stripe');
6328
- }
6329
6596
  bootstrapBody.customerEmail = normalizedCheckoutEmail || undefined;
6330
6597
 
6331
6598
  appendBestEffortTelemetry(FEEDBACK_DIR, {
@@ -7172,9 +7439,28 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
7172
7439
  journeySummary: null,
7173
7440
  };
7174
7441
 
7442
+ // Bounded reverse scan — full-file read of multi-MB ledgers was timing
7443
+ // out operator dashboards and revenue:doctor probes.
7444
+ let readJsonlSinceTail;
7445
+ try {
7446
+ ({ readJsonlSinceTail } = require('../../scripts/jsonl-window'));
7447
+ } catch {
7448
+ readJsonlSinceTail = null;
7449
+ }
7450
+
7175
7451
  function readJsonlSince(p) {
7452
+ if (typeof readJsonlSinceTail === 'function') {
7453
+ const windowed = readJsonlSinceTail(p, {
7454
+ sinceMs: since,
7455
+ limit,
7456
+ maxBytes: 8 * 1024 * 1024,
7457
+ });
7458
+ return windowed;
7459
+ }
7176
7460
  try {
7177
- if (!fs.existsSync(p)) return [];
7461
+ if (!fs.existsSync(p)) {
7462
+ return { rows: [], totalAfterSince: 0, truncated: false };
7463
+ }
7178
7464
  const text = fs.readFileSync(p, 'utf8');
7179
7465
  const rows = [];
7180
7466
  for (const line of text.split('\n')) {
@@ -7187,16 +7473,22 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
7187
7473
  rows.push(obj);
7188
7474
  }
7189
7475
  }
7190
- return rows;
7191
- } catch { return []; }
7476
+ return {
7477
+ rows: rows.slice(-limit),
7478
+ totalAfterSince: rows.length,
7479
+ truncated: rows.length > limit,
7480
+ };
7481
+ } catch {
7482
+ return { rows: [], totalAfterSince: 0, truncated: false };
7483
+ }
7192
7484
  }
7193
7485
 
7194
7486
  if (wantTelemetry) {
7195
7487
  const tp = path.join(exportDir, 'telemetry-pings.jsonl');
7196
- const all = readJsonlSince(tp);
7197
- result.telemetry.totalAfterSince = all.length;
7198
- result.telemetry.rows = all.slice(-limit);
7199
- result.telemetry.truncated = all.length > limit;
7488
+ const windowed = readJsonlSince(tp);
7489
+ result.telemetry.totalAfterSince = windowed.totalAfterSince;
7490
+ result.telemetry.rows = windowed.rows;
7491
+ result.telemetry.truncated = windowed.truncated;
7200
7492
  }
7201
7493
 
7202
7494
  if (wantFunnel) {
@@ -7211,10 +7503,10 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
7211
7503
  } catch {
7212
7504
  funnelPath = path.join(exportDir, 'funnel-events.jsonl');
7213
7505
  }
7214
- const all = readJsonlSince(funnelPath);
7215
- result.funnel.totalAfterSince = all.length;
7216
- result.funnel.rows = all.slice(-limit);
7217
- result.funnel.truncated = all.length > limit;
7506
+ const windowed = readJsonlSince(funnelPath);
7507
+ result.funnel.totalAfterSince = windowed.totalAfterSince;
7508
+ result.funnel.rows = windowed.rows;
7509
+ result.funnel.truncated = windowed.truncated;
7218
7510
  }
7219
7511
 
7220
7512
  try {
@@ -7952,6 +8244,45 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
7952
8244
  return;
7953
8245
  }
7954
8246
 
8247
+ // PayPal webhook evidence. PayPal authenticates the delivery through its
8248
+ // verify-webhook-signature API before raw proof is persisted locally.
8249
+ if (req.method === 'POST' && pathname === '/v1/billing/paypal-webhook') {
8250
+ try {
8251
+ const rawBody = await readBodyBuffer(req);
8252
+ const delivery = await recordPayPalWebhookDelivery({
8253
+ rawBody,
8254
+ headers: req.headers,
8255
+ });
8256
+ if (!delivery.verified || !delivery.recorded) {
8257
+ sendProblem(res, {
8258
+ type: PROBLEM_TYPES.WEBHOOK_INVALID,
8259
+ title: 'Invalid PayPal webhook delivery',
8260
+ status: delivery.verified ? 503 : 400,
8261
+ detail: delivery.verified
8262
+ ? 'The PayPal-verified webhook could not be written to the evidence ledger.'
8263
+ : 'The PayPal transmission headers, event, or remote signature verification were invalid.',
8264
+ });
8265
+ return;
8266
+ }
8267
+ sendJson(res, 200, {
8268
+ verified: true,
8269
+ recorded: true,
8270
+ duplicate: delivery.duplicate === true,
8271
+ eventId: delivery.eventId,
8272
+ eventType: delivery.eventType,
8273
+ payloadSha256: delivery.payloadSha256,
8274
+ });
8275
+ } catch (err) {
8276
+ sendProblem(res, {
8277
+ type: !err.statusCode || err.statusCode >= 500 ? PROBLEM_TYPES.INTERNAL : PROBLEM_TYPES.BAD_REQUEST,
8278
+ title: !err.statusCode || err.statusCode >= 500 ? 'Internal Server Error' : 'Request Error',
8279
+ status: err.statusCode || 500,
8280
+ detail: err.message,
8281
+ });
8282
+ }
8283
+ return;
8284
+ }
8285
+
7955
8286
  // GitHub Marketplace webhook
7956
8287
  if (req.method === 'POST' && pathname === '/v1/billing/github-webhook') {
7957
8288
  try {
@@ -7963,12 +8294,20 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
7963
8294
  });
7964
8295
 
7965
8296
  const sig = req.headers['x-hub-signature-256'] || '';
7966
- if (!verifyGithubWebhookSignature(rawBody, sig)) {
8297
+ const delivery = recordGithubMarketplaceWebhookDelivery({
8298
+ rawBody,
8299
+ signature: sig,
8300
+ deliveryId: req.headers['x-github-delivery'] || '',
8301
+ eventName: req.headers['x-github-event'] || '',
8302
+ });
8303
+ if (!delivery.verified || !delivery.recorded) {
7967
8304
  sendProblem(res, {
7968
8305
  type: PROBLEM_TYPES.WEBHOOK_INVALID,
7969
- title: 'Invalid webhook signature',
7970
- status: 400,
7971
- detail: 'The webhook signature could not be verified.',
8306
+ title: 'Invalid GitHub webhook delivery',
8307
+ status: delivery.verified ? 503 : 400,
8308
+ detail: delivery.verified
8309
+ ? 'The verified webhook could not be written to the evidence ledger.'
8310
+ : 'The webhook signature, delivery headers, or payload could not be verified.',
7972
8311
  });
7973
8312
  return;
7974
8313
  }
@@ -7986,7 +8325,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
7986
8325
  return;
7987
8326
  }
7988
8327
 
7989
- const result = handleGithubWebhook(event);
8328
+ const result = handleGithubWebhook(event, delivery);
7990
8329
  sendJson(res, 200, result);
7991
8330
  } catch (err) {
7992
8331
  sendProblem(res, {
@@ -8108,7 +8447,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
8108
8447
  const isOperatorBillingRequest = Boolean(expectedOperatorKey)
8109
8448
  && _reqToken === expectedOperatorKey
8110
8449
  && req.method === 'GET'
8111
- && pathname === '/v1/billing/summary';
8450
+ && ['/v1/billing/summary', '/v1/intake/workflow-sprint/queue'].includes(pathname);
8112
8451
 
8113
8452
  if (!isOperatorBillingRequest && !isAuthorized(req, expectedApiKey)) {
8114
8453
  sendProblem(res, {
@@ -9406,6 +9745,33 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9406
9745
  return;
9407
9746
  }
9408
9747
 
9748
+ // GET /v1/intake/workflow-sprint/queue — authenticated, no-store operator close queue
9749
+ if (req.method === 'GET' && pathname === '/v1/intake/workflow-sprint/queue') {
9750
+ if (!isBillingSummaryAuthorized(req, expectedApiKey, expectedOperatorKey)) {
9751
+ sendProblem(res, {
9752
+ type: PROBLEM_TYPES.FORBIDDEN,
9753
+ title: 'Forbidden',
9754
+ status: 403,
9755
+ detail: 'Admin or operator API key required for this endpoint.',
9756
+ });
9757
+ return;
9758
+ }
9759
+ try {
9760
+ const { FEEDBACK_DIR } = getFeedbackPaths();
9761
+ const workflowSprintIntake = requirePrivateApiModule(
9762
+ 'workflowSprintIntake',
9763
+ 'Workflow sprint intake'
9764
+ );
9765
+ const queue = buildWorkflowIntakeQueue(parsed, workflowSprintIntake, FEEDBACK_DIR, {
9766
+ publicOrigin: hostedConfig.appOrigin,
9767
+ });
9768
+ sendJson(res, 200, queue, PRIVATE_OPERATOR_RESPONSE_HEADERS);
9769
+ } catch (err) {
9770
+ sendWorkflowIntakeQueueProblem(res, err);
9771
+ }
9772
+ return;
9773
+ }
9774
+
9409
9775
  // GET /v1/billing/summary — operator billing summary (admin key or operator key)
9410
9776
  if (req.method === 'GET' && pathname === '/v1/billing/summary') {
9411
9777
  if (!isBillingSummaryAuthorized(req, expectedApiKey, expectedOperatorKey)) {
@@ -9418,6 +9784,48 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9418
9784
  return;
9419
9785
  }
9420
9786
 
9787
+ const includeIntakeQueue = wantsWorkflowIntakeQueue(parsed);
9788
+ let intakeQueue = null;
9789
+ if (includeIntakeQueue) {
9790
+ try {
9791
+ const { FEEDBACK_DIR } = getFeedbackPaths();
9792
+ const workflowSprintIntake = requirePrivateApiModule(
9793
+ 'workflowSprintIntake',
9794
+ 'Workflow sprint intake'
9795
+ );
9796
+ intakeQueue = buildWorkflowIntakeQueue(parsed, workflowSprintIntake, FEEDBACK_DIR, {
9797
+ publicOrigin: hostedConfig.appOrigin,
9798
+ });
9799
+ } catch (err) {
9800
+ sendWorkflowIntakeQueueProblem(res, err);
9801
+ return;
9802
+ }
9803
+ }
9804
+
9805
+ const runtimePresence = buildHostedRuntimePresence(hostedConfig, {
9806
+ expectedApiKey,
9807
+ expectedOperatorKey,
9808
+ });
9809
+ const requestedWindow = String(parsed.searchParams.get('window') || '').trim().toLowerCase();
9810
+ if (requestedWindow === 'all') {
9811
+ let summaryOptionsByWindow;
9812
+ try {
9813
+ summaryOptionsByWindow = resolveBillingSummaryBatchOptions(parsed);
9814
+ } catch (err) {
9815
+ sendInvalidAnalyticsWindowProblem(res, 'Invalid billing summary query', err);
9816
+ return;
9817
+ }
9818
+ const summaries = await getBillingSummariesLive(summaryOptionsByWindow);
9819
+ sendJson(res, 200, {
9820
+ generatedAt: new Date().toISOString(),
9821
+ windows: BILLING_SUMMARY_BATCH_WINDOWS,
9822
+ summaries,
9823
+ runtimePresence,
9824
+ ...(intakeQueue ? { intakeQueue } : {}),
9825
+ }, PRIVATE_OPERATOR_RESPONSE_HEADERS);
9826
+ return;
9827
+ }
9828
+
9421
9829
  const summaryOptions = resolveBillingSummaryOptionsOrRespondProblem(
9422
9830
  res,
9423
9831
  parsed,
@@ -9430,11 +9838,9 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9430
9838
  const summary = await getBillingSummaryLive(summaryOptions);
9431
9839
  sendJson(res, 200, {
9432
9840
  ...summary,
9433
- runtimePresence: buildHostedRuntimePresence(hostedConfig, {
9434
- expectedApiKey,
9435
- expectedOperatorKey,
9436
- }),
9437
- });
9841
+ runtimePresence,
9842
+ ...(intakeQueue ? { intakeQueue } : {}),
9843
+ }, PRIVATE_OPERATOR_RESPONSE_HEADERS);
9438
9844
  return;
9439
9845
  }
9440
9846
 
@@ -9887,6 +10293,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9887
10293
  function startServer({ port, host } = {}) {
9888
10294
  const listenPort = Number(port ?? process.env.PORT ?? 8787);
9889
10295
  const listenHost = String(host ?? process.env.HOST ?? '0.0.0.0').trim() || '0.0.0.0';
10296
+ fs.mkdirSync(getFeedbackPaths().FEEDBACK_DIR, { recursive: true });
9890
10297
  const server = createApiServer();
9891
10298
  registerGracefulShutdown(server);
9892
10299
  return new Promise((resolve) => {