thumbgate 1.16.22 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,7 +25,7 @@
25
25
  "alternateName": "thumbgate",
26
26
  "applicationCategory": "DeveloperApplication",
27
27
  "operatingSystem": "Cross-platform, Node.js >=18.18.0",
28
- "softwareVersion": "1.16.22",
28
+ "softwareVersion": "1.17.0",
29
29
  "url": "https://thumbgate-production.up.railway.app/numbers",
30
30
  "dateModified": "2026-05-07",
31
31
  "creator": {
@@ -202,7 +202,7 @@
202
202
  <main class="container">
203
203
  <h1>The Numbers</h1>
204
204
  <p class="subtitle">Generated first-party operational snapshot from the ThumbGate runtime. This is not customer traction, install volume, revenue, or proof that a configured gate has fired.</p>
205
- <div class="freshness">Updated: 2026-05-07 · Version 1.16.22</div>
205
+ <div class="freshness">Updated: 2026-05-07 · Version 1.17.0</div>
206
206
  <div class="truth-note"><strong>Read this first:</strong> configured checks are inventory. Recorded blocks and warnings are usage evidence. This snapshot currently reports 0 recorded hard-block event(s) and 0 recorded warning event(s).</div>
207
207
 
208
208
  <h2>Gate enforcement</h2>
@@ -1149,7 +1149,8 @@ function loadResolvedRevenueEvents(options = {}) {
1149
1149
  const derived = deriveRevenueEventFromPaidProviderEvent(entry);
1150
1150
  if (!derived) continue;
1151
1151
  if (hasRevenueEventMatch(resolved, derived)) continue;
1152
- resolved.push(derived);
1152
+ const priced = resolveGithubMarketplaceRevenueEntry(derived, { annotate: false }).entry;
1153
+ resolved.push(priced);
1153
1154
  }
1154
1155
 
1155
1156
  return mergeRevenueEvents(resolved, extraRevenueEvents);
@@ -1161,6 +1162,9 @@ function repairGithubMarketplaceRevenueLedger(options = {}) {
1161
1162
  const rows = loadJsonlFile(ledgerPath);
1162
1163
  const resolvedAt = new Date().toISOString();
1163
1164
  const repairs = [];
1165
+
1166
+ // Pass 1: in-place repair of rows already in revenue-events.jsonl that have
1167
+ // unknown amounts but resolvable plan metadata.
1164
1168
  const updatedRows = rows.map((entry) => {
1165
1169
  const result = resolveGithubMarketplaceRevenueEntry(entry, {
1166
1170
  annotate: true,
@@ -1178,21 +1182,76 @@ function repairGithubMarketplaceRevenueLedger(options = {}) {
1178
1182
  currency: normalizeCurrency(result.entry.currency),
1179
1183
  recurringInterval: normalizeText(result.entry.recurringInterval),
1180
1184
  pricingSource: normalizeText(metadata.githubMarketplaceAmountSource),
1185
+ source: 'in_place',
1181
1186
  });
1182
1187
  return result.entry;
1183
1188
  });
1184
1189
 
1190
+ // Pass 2: append rows for funnel-derived paid github_marketplace events that
1191
+ // never landed in the revenue ledger. The webhook handler at handleGithubWebhook
1192
+ // skips appendRevenueEvent when hasRevenueEventMatch is true, so duplicates from
1193
+ // a re-run are already prevented; the only way an order gets here is if the
1194
+ // revenue write was skipped at webhook time (e.g. funnel pre-existed, planPricing
1195
+ // had unknown amount at the time, or the row was created via a different path).
1196
+ const funnelRecords = loadFunnelLedger().filter(
1197
+ (e) =>
1198
+ e &&
1199
+ e.stage === 'paid' &&
1200
+ normalizeText((e.metadata && e.metadata.provider) || e.provider) === 'github_marketplace'
1201
+ );
1202
+
1203
+ for (const funnelEntry of funnelRecords) {
1204
+ const derived = deriveRevenueEventFromPaidProviderEvent(funnelEntry);
1205
+ if (!derived) continue;
1206
+ if (hasRevenueEventMatch(updatedRows, derived)) continue;
1207
+
1208
+ const resolved = resolveGithubMarketplaceRevenueEntry(derived, {
1209
+ annotate: true,
1210
+ resolvedAt,
1211
+ });
1212
+ // Skip funnel-derived rows that still have unknown amounts after resolving —
1213
+ // we don't want to permanently bake amountKnown:false rows when there's no
1214
+ // pricing data to attach. Better to leave them off-disk and keep the
1215
+ // read-time merge in loadResolvedRevenueEvents covering them.
1216
+ if (!resolved.changed || !resolved.entry.amountKnown) continue;
1217
+
1218
+ const persisted = {
1219
+ ...resolved.entry,
1220
+ metadata: {
1221
+ ...sanitizeMetadata(resolved.entry.metadata),
1222
+ recoveredFromFunnelLedger: true,
1223
+ funnelRecordedAt: normalizeText(funnelEntry.timestamp) || null,
1224
+ },
1225
+ };
1226
+ updatedRows.push(persisted);
1227
+
1228
+ const metadata = sanitizeMetadata(persisted.metadata);
1229
+ repairs.push({
1230
+ orderId: normalizeText(persisted.orderId),
1231
+ customerId: normalizeText(persisted.customerId),
1232
+ planId: normalizeText(metadata.planId ?? persisted.planId),
1233
+ amountCents: normalizeInteger(persisted.amountCents),
1234
+ currency: normalizeCurrency(persisted.currency),
1235
+ recurringInterval: normalizeText(persisted.recurringInterval),
1236
+ pricingSource: normalizeText(metadata.githubMarketplaceAmountSource),
1237
+ source: 'funnel_derived',
1238
+ });
1239
+ }
1240
+
1185
1241
  const writeResult = write && repairs.length > 0
1186
1242
  ? writeJsonlRecords(ledgerPath, updatedRows)
1187
- : { written: false, rowCount: rows.length };
1243
+ : { written: false, rowCount: updatedRows.length };
1188
1244
 
1189
1245
  return {
1190
1246
  ledgerPath,
1191
1247
  write,
1192
1248
  wrote: Boolean(writeResult.written),
1193
1249
  scanned: rows.length,
1250
+ funnelScanned: funnelRecords.length,
1194
1251
  repaired: repairs.length,
1195
- unchanged: rows.length - repairs.length,
1252
+ repairedInPlace: repairs.filter((r) => r.source === 'in_place').length,
1253
+ repairedFromFunnel: repairs.filter((r) => r.source === 'funnel_derived').length,
1254
+ unchanged: rows.length - repairs.filter((r) => r.source === 'in_place').length,
1196
1255
  repairs,
1197
1256
  writeResult,
1198
1257
  };
@@ -12,28 +12,28 @@ const {
12
12
  const USAGE_FILE = path.join(process.env.HOME || '/tmp', '.thumbgate', 'usage-limits.json');
13
13
 
14
14
  // ──────────────────────────────────────────────────────────
15
- // NEW: Lifetime caps on free tier users hit the wall fast
16
- // and must upgrade to keep using core features.
15
+ // Free tier: generous on captures (habit formation) and rules
16
+ // (5 active gates), gated on Pro-only features (recall, search,
17
+ // exports). Dashboard, exports, and unlimited rules drive Pro.
17
18
  // ──────────────────────────────────────────────────────────
18
19
  const FREE_TIER_LIMITS = {
19
- capture_feedback: { daily: Infinity, lifetime: 3, label: 'feedback captures' },
20
- prevention_rules: { daily: Infinity, lifetime: 1, label: 'prevention rules generated' },
21
- recall: { daily: 0, lifetime: 0, label: 'recall queries (Pro only)' },
22
- search_lessons: { daily: 0, lifetime: 0, label: 'lesson searches (Pro only)' },
23
- search_thumbgate: { daily: 0, lifetime: 0, label: 'ThumbGate searches (Pro only)' },
24
- commerce_recall: { daily: 0, lifetime: 0, label: 'commerce recalls (Pro only)' },
25
- export_dpo: { daily: 0, lifetime: 0, label: 'DPO exports (Pro only)' },
26
- export_databricks: { daily: 0, lifetime: 0, label: 'Databricks exports (Pro only)' },
27
- construct_context_pack: { daily: Infinity, lifetime: 3, label: 'context packs' },
20
+ capture_feedback: { daily: Infinity, lifetime: Infinity, label: 'feedback captures' },
21
+ prevention_rules: { daily: Infinity, lifetime: Infinity, label: 'prevention rules generated' },
22
+ recall: { daily: 0, lifetime: 0, label: 'recall queries (Pro only)' },
23
+ search_lessons: { daily: 0, lifetime: 0, label: 'lesson searches (Pro only)' },
24
+ search_thumbgate: { daily: 0, lifetime: 0, label: 'ThumbGate searches (Pro only)' },
25
+ commerce_recall: { daily: 0, lifetime: 0, label: 'commerce recalls (Pro only)' },
26
+ export_dpo: { daily: 0, lifetime: 0, label: 'DPO exports (Pro only)' },
27
+ export_databricks: { daily: 0, lifetime: 0, label: 'Databricks exports (Pro only)' },
28
+ construct_context_pack: { daily: Infinity, lifetime: Infinity, label: 'context packs' },
28
29
  };
29
30
 
30
- const FREE_TIER_MAX_GATES = 1; // Down from 5 one auto-promoted gate, then paywall
31
+ const FREE_TIER_MAX_GATES = 5; // 5 active prevention rules on free; Pro is unlimited
31
32
 
32
- const UPGRADE_MESSAGE = `Pro: ${PRO_PRICE_LABEL} — unlimited captures, recall, prevention rules, and dashboard: ${PRO_MONTHLY_PAYMENT_LINK}\n Team: ${TEAM_PRICE_LABEL} after workflow qualification.`;
33
+ const UPGRADE_MESSAGE = `Pro: ${PRO_PRICE_LABEL} — unlimited rules, recall, lesson search, dashboard, and exports: ${PRO_MONTHLY_PAYMENT_LINK}\n Team: ${TEAM_PRICE_LABEL} after workflow qualification.`;
33
34
 
34
35
  const PAYWALL_MESSAGES = {
35
- capture_feedback: 'You\'ve used all 3 free feedback captures. Your agent is still making mistakes upgrade to Pro to capture every one and build real prevention rules.',
36
- prevention_rules: 'Free tier includes 1 prevention rule. Your agents need more protection — upgrade to Pro for unlimited rules.',
36
+ prevention_rules: 'Free tier includes 5 active prevention rules. Promote more or unlock unlimited rules with Pro.',
37
37
  recall: 'Recall is a Pro feature. Your past feedback is stored locally — upgrade to search and reuse it.',
38
38
  search_lessons: 'Lesson search is a Pro feature. Upgrade to find patterns in your agent\'s mistakes.',
39
39
  default: 'This feature requires Pro. Start Pro — card required; billed today.',
package/src/api/server.js CHANGED
@@ -16,6 +16,8 @@ const POSTHOG_STATIC_PATH_PREFIX = '/static/';
16
16
  const FIRST_FAILURE_RULE_CHECKOUT_URL = 'https://buy.stripe.com/4gM6oHgH2bTw4lH6i73sI0z';
17
17
  const QUICK_READ_CHECKOUT_URL = 'https://buy.stripe.com/aFa8wPgH29Lo4lH35V3sI0w';
18
18
  const WORKFLOW_TEARDOWN_CHECKOUT_URL = 'https://buy.stripe.com/7sYfZhgH29LodWhdKz3sI0v';
19
+ const SPRINT_DIAGNOSTIC_CHECKOUT_URL = 'https://buy.stripe.com/3cI7sLgH25v8dWh5e33sI0o';
20
+ const WORKFLOW_SPRINT_CHECKOUT_URL = 'https://buy.stripe.com/8x25kDcqMaPs9G15e33sI0p';
19
21
 
20
22
  function getPosthogProxyPath(pathname) {
21
23
  return pathname.slice('/ingest'.length) || '/';
@@ -210,6 +212,7 @@ const NUMBERS_PAGE_PATH = path.resolve(__dirname, '../../public/numbers.html');
210
212
  const LEARN_DIR = path.resolve(__dirname, '../../public/learn');
211
213
  const GUIDES_DIR = path.resolve(__dirname, '../../public/guides');
212
214
  const COMPARE_DIR = path.resolve(__dirname, '../../public/compare');
215
+ const USE_CASES_DIR = path.resolve(__dirname, '../../public/use-cases');
213
216
  const PUBLIC_DIR = path.resolve(__dirname, '../../public');
214
217
  const PUBLIC_ASSETS_DIR = path.resolve(__dirname, '../../public/assets');
215
218
  const BUYER_INTENT_SCRIPT_PATH = path.resolve(__dirname, '../../public/js/buyer-intent.js');
@@ -1587,6 +1590,12 @@ function normalizeTrackedLinkSlug(value) {
1587
1590
  return String(value || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '');
1588
1591
  }
1589
1592
 
1593
+ function normalizePublicPageSlug(value) {
1594
+ return String(value || '')
1595
+ .replace(/\.html$/i, '')
1596
+ .replace(/[^a-z0-9-]/g, '');
1597
+ }
1598
+
1590
1599
  function getTrackedLinkTarget(slug) {
1591
1600
  const normalizedSlug = normalizeTrackedLinkSlug(slug);
1592
1601
  return TRACKED_LINK_TARGETS[normalizedSlug]
@@ -1939,8 +1948,8 @@ function loadPublicMarketingTemplateHtml(templatePath, runtimeConfig, pageContex
1939
1948
  '__CHECKOUT_FALLBACK_URL__': runtimeConfig.checkoutFallbackUrl,
1940
1949
  '__PRO_PRICE_DOLLARS__': runtimeConfig.proPriceDollars,
1941
1950
  '__PRO_PRICE_LABEL__': runtimeConfig.proPriceLabel,
1942
- '__SPRINT_DIAGNOSTIC_CHECKOUT_URL__': runtimeConfig.sprintDiagnosticCheckoutUrl || '',
1943
- '__WORKFLOW_SPRINT_CHECKOUT_URL__': runtimeConfig.workflowSprintCheckoutUrl || '',
1951
+ '__SPRINT_DIAGNOSTIC_CHECKOUT_URL__': runtimeConfig.sprintDiagnosticCheckoutUrl || SPRINT_DIAGNOSTIC_CHECKOUT_URL,
1952
+ '__WORKFLOW_SPRINT_CHECKOUT_URL__': runtimeConfig.workflowSprintCheckoutUrl || WORKFLOW_SPRINT_CHECKOUT_URL,
1944
1953
  '__SPRINT_DIAGNOSTIC_PRICE_DOLLARS__': runtimeConfig.sprintDiagnosticPriceDollars || 499,
1945
1954
  '__WORKFLOW_SPRINT_PRICE_DOLLARS__': runtimeConfig.workflowSprintPriceDollars || 1500,
1946
1955
  '__GA_MEASUREMENT_ID__': runtimeConfig.gaMeasurementId || '',
@@ -4193,6 +4202,15 @@ async function addContext(){
4193
4202
  return;
4194
4203
  }
4195
4204
 
4205
+ if (isGetLikeRequest && pathname === '/lessons/') {
4206
+ res.writeHead(302, {
4207
+ Location: '/lessons',
4208
+ 'Cache-Control': 'no-store',
4209
+ });
4210
+ res.end();
4211
+ return;
4212
+ }
4213
+
4196
4214
  if (isGetLikeRequest && pathname === '/lessons') {
4197
4215
  try {
4198
4216
  const html = loadLessonsPageHtml(req, expectedApiKey);
@@ -4245,7 +4263,7 @@ async function addContext(){
4245
4263
  return;
4246
4264
  }
4247
4265
 
4248
- if (isGetLikeRequest && pathname === '/guide') {
4266
+ if (isGetLikeRequest && (pathname === '/guide' || pathname === '/guide.html')) {
4249
4267
  try {
4250
4268
  const html = fs.readFileSync(GUIDE_PAGE_PATH, 'utf-8');
4251
4269
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
@@ -4255,7 +4273,7 @@ async function addContext(){
4255
4273
  return;
4256
4274
  }
4257
4275
 
4258
- if (isGetLikeRequest && pathname === '/codex-plugin') {
4276
+ if (isGetLikeRequest && (pathname === '/codex-plugin' || pathname === '/codex-plugin.html')) {
4259
4277
  try {
4260
4278
  const html = fs.readFileSync(CODEX_PLUGIN_PAGE_PATH, 'utf-8');
4261
4279
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
@@ -4265,7 +4283,7 @@ async function addContext(){
4265
4283
  return;
4266
4284
  }
4267
4285
 
4268
- if (isGetLikeRequest && pathname === '/compare') {
4286
+ if (isGetLikeRequest && (pathname === '/compare' || pathname === '/compare.html')) {
4269
4287
  try {
4270
4288
  const html = fs.readFileSync(COMPARE_PAGE_PATH, 'utf-8');
4271
4289
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
@@ -4275,7 +4293,7 @@ async function addContext(){
4275
4293
  return;
4276
4294
  }
4277
4295
 
4278
- if (isGetLikeRequest && pathname === '/blog') {
4296
+ if (isGetLikeRequest && (pathname === '/blog' || pathname === '/blog.html')) {
4279
4297
  try {
4280
4298
  const blogPath = path.resolve(__dirname, '../../public/blog.html');
4281
4299
  const html = fs.readFileSync(blogPath, 'utf-8');
@@ -4286,7 +4304,7 @@ async function addContext(){
4286
4304
  return;
4287
4305
  }
4288
4306
 
4289
- if (isGetLikeRequest && pathname === '/learn') {
4307
+ if (isGetLikeRequest && (pathname === '/learn' || pathname === '/learn.html')) {
4290
4308
  try {
4291
4309
  const html = fs.readFileSync(LEARN_PAGE_PATH, 'utf-8');
4292
4310
  sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
@@ -4296,6 +4314,24 @@ async function addContext(){
4296
4314
  return;
4297
4315
  }
4298
4316
 
4317
+ if (isGetLikeRequest && (pathname === '/guides' || pathname === '/guides/' || pathname === '/guides.html')) {
4318
+ res.writeHead(302, {
4319
+ Location: '/learn',
4320
+ 'Cache-Control': 'no-store',
4321
+ });
4322
+ res.end();
4323
+ return;
4324
+ }
4325
+
4326
+ if (isGetLikeRequest && (pathname === '/services' || pathname === '/services.html')) {
4327
+ res.writeHead(302, {
4328
+ Location: '/#workflow-sprint-intake',
4329
+ 'Cache-Control': 'no-store',
4330
+ });
4331
+ res.end();
4332
+ return;
4333
+ }
4334
+
4299
4335
  if (isGetLikeRequest && (pathname === '/numbers' || pathname === '/numbers.html')) {
4300
4336
  // Route through servePublicMarketingPage so landing_page_view telemetry
4301
4337
  // + funnel-events.jsonl `discovery/landing_view` get captured with UTM
@@ -4331,7 +4367,7 @@ async function addContext(){
4331
4367
 
4332
4368
  if (isGetLikeRequest && pathname.startsWith('/learn/')) {
4333
4369
  try {
4334
- const slug = pathname.replace('/learn/', '').replace(/[^a-z0-9-]/g, '');
4370
+ const slug = normalizePublicPageSlug(pathname.replace('/learn/', ''));
4335
4371
  const articlePath = path.join(LEARN_DIR, `${slug}.html`);
4336
4372
  if (!articlePath.startsWith(LEARN_DIR)) {
4337
4373
  sendJson(res, 403, { error: 'Forbidden' });
@@ -4347,7 +4383,7 @@ async function addContext(){
4347
4383
 
4348
4384
  if (isGetLikeRequest && pathname.startsWith('/guides/')) {
4349
4385
  try {
4350
- const slug = pathname.replace('/guides/', '').replace(/[^a-z0-9-]/g, '');
4386
+ const slug = normalizePublicPageSlug(pathname.replace('/guides/', ''));
4351
4387
  const guidePath = path.join(GUIDES_DIR, `${slug}.html`);
4352
4388
  if (!guidePath.startsWith(GUIDES_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
4353
4389
  const html = fs.readFileSync(guidePath, 'utf-8');
@@ -4358,7 +4394,7 @@ async function addContext(){
4358
4394
 
4359
4395
  if (isGetLikeRequest && pathname.startsWith('/compare/') && pathname !== '/compare') {
4360
4396
  try {
4361
- const slug = pathname.replace('/compare/', '').replace(/[^a-z0-9-]/g, '');
4397
+ const slug = normalizePublicPageSlug(pathname.replace('/compare/', ''));
4362
4398
  const comparePath = path.join(COMPARE_DIR, `${slug}.html`);
4363
4399
  if (!comparePath.startsWith(COMPARE_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
4364
4400
  const html = fs.readFileSync(comparePath, 'utf-8');
@@ -4367,6 +4403,17 @@ async function addContext(){
4367
4403
  return;
4368
4404
  }
4369
4405
 
4406
+ if (isGetLikeRequest && pathname.startsWith('/use-cases/')) {
4407
+ try {
4408
+ const slug = normalizePublicPageSlug(pathname.replace('/use-cases/', ''));
4409
+ const useCasePath = path.join(USE_CASES_DIR, `${slug}.html`);
4410
+ if (!useCasePath.startsWith(USE_CASES_DIR)) { sendJson(res, 403, { error: 'Forbidden' }); return; }
4411
+ const html = fs.readFileSync(useCasePath, 'utf-8');
4412
+ sendHtml(res, 200, html, {}, { headOnly: isHeadRequest });
4413
+ } catch { sendJson(res, 404, { error: 'Use case not found' }); }
4414
+ return;
4415
+ }
4416
+
4370
4417
  if (isGetLikeRequest && pathname.startsWith('/assets/')) {
4371
4418
  const rel = pathname.slice('/assets/'.length);
4372
4419
  const resolved = path.resolve(PUBLIC_ASSETS_DIR, rel);
@@ -4500,24 +4547,28 @@ async function addContext(){
4500
4547
  ctaPlacement: 'checkout_interstitial',
4501
4548
  planId: 'workflow_teardown',
4502
4549
  });
4503
- const diagnosticCheckoutHref = hostedConfig.sprintDiagnosticCheckoutUrl
4504
- ? buildCheckoutIntentHref(hostedConfig.sprintDiagnosticCheckoutUrl, analyticsMetadata, {
4550
+ const diagnosticCheckoutHref = buildCheckoutIntentHref(
4551
+ hostedConfig.sprintDiagnosticCheckoutUrl || SPRINT_DIAGNOSTIC_CHECKOUT_URL,
4552
+ analyticsMetadata,
4553
+ {
4505
4554
  utmMedium: 'checkout_interstitial_paid_path',
4506
4555
  utmCampaign: analyticsMetadata.utmCampaign || 'checkout_interstitial_diagnostic',
4507
4556
  ctaId: 'checkout_interstitial_sprint_diagnostic_checkout',
4508
4557
  ctaPlacement: 'checkout_interstitial',
4509
4558
  planId: 'sprint_diagnostic',
4510
- })
4511
- : '';
4512
- const sprintCheckoutHref = hostedConfig.workflowSprintCheckoutUrl
4513
- ? buildCheckoutIntentHref(hostedConfig.workflowSprintCheckoutUrl, analyticsMetadata, {
4559
+ }
4560
+ );
4561
+ const sprintCheckoutHref = buildCheckoutIntentHref(
4562
+ hostedConfig.workflowSprintCheckoutUrl || WORKFLOW_SPRINT_CHECKOUT_URL,
4563
+ analyticsMetadata,
4564
+ {
4514
4565
  utmMedium: 'checkout_interstitial_paid_path',
4515
4566
  utmCampaign: analyticsMetadata.utmCampaign || 'checkout_interstitial_workflow_sprint',
4516
4567
  ctaId: 'checkout_interstitial_workflow_sprint_checkout',
4517
4568
  ctaPlacement: 'checkout_interstitial',
4518
4569
  planId: 'workflow_sprint',
4519
- })
4520
- : '';
4570
+ }
4571
+ );
4521
4572
  const html = renderCheckoutIntentPage({
4522
4573
  confirmHref: buildCheckoutConfirmHref(parsed),
4523
4574
  firstRuleCheckoutHref,