rlhf-feedback-loop 0.6.8 → 0.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rlhf-feedback-loop",
3
- "version": "0.6.8",
4
- "description": "Production RLHF & DPO data pipeline for AI agents. Optimize agentic reliability with Feedback-Driven Development (FDD). Capture human preference signals, generate automated guardrails, and export DPO training pairs. Compatible with Claude, GPT-4, Gemini, and multi-agent systems.",
3
+ "version": "0.6.9",
4
+ "description": "RLHF-ready human feedback capture and DPO data pipeline for AI agents. Optimize agentic reliability with Feedback-Driven Development (FDD): capture preference signals, enforce guardrails, and export training pairs for downstream optimization.",
5
5
  "homepage": "https://github.com/IgorGanapolsky/rlhf-feedback-loop#readme",
6
6
  "repository": {
7
7
  "type": "git",
@@ -34,7 +34,7 @@
34
34
  "test:loop": "node scripts/feedback-loop.js --test",
35
35
  "test:dpo": "node scripts/export-dpo-pairs.js --test",
36
36
  "test:api": "node --test tests/api-server.test.js tests/api-auth-config.test.js tests/mcp-server.test.js tests/adapters.test.js tests/openapi-parity.test.js tests/budget-guard.test.js tests/contextfs.test.js tests/mcp-policy.test.js tests/subagent-profiles.test.js tests/intent-router.test.js tests/rubric-engine.test.js tests/self-healing-check.test.js tests/self-heal.test.js tests/feedback-schema.test.js tests/thompson-sampling.test.js tests/feedback-sequences.test.js tests/diversity-tracking.test.js tests/vector-store.test.js tests/feedback-attribution.test.js tests/hybrid-feedback-context.test.js tests/loop-closure.test.js tests/code-reasoning.test.js tests/feedback-loop.test.js tests/feedback-inbox-read.test.js tests/feedback-to-memory.test.js",
37
- "test:proof": "node --test tests/prove-adapters.test.js tests/prove-automation.test.js tests/prove-attribution.test.js tests/prove-lancedb.test.js tests/prove-data-quality.test.js tests/prove-intelligence.test.js tests/prove-loop-closure.test.js tests/prove-subway-upgrades.test.js tests/prove-training-export.test.js",
37
+ "test:proof": "node --test --test-concurrency=1 tests/prove-adapters.test.js tests/prove-automation.test.js tests/prove-attribution.test.js tests/prove-lancedb.test.js tests/prove-data-quality.test.js tests/prove-intelligence.test.js tests/prove-loop-closure.test.js tests/prove-subway-upgrades.test.js tests/prove-training-export.test.js",
38
38
  "test:rlaif": "node --test tests/rlaif-self-audit.test.js tests/dpo-optimizer.test.js tests/meta-policy.test.js",
39
39
  "test:attribution": "node --test tests/feedback-attribution.test.js tests/hybrid-feedback-context.test.js",
40
40
  "test:quality": "node --test tests/validate-feedback.test.js",
@@ -107,13 +107,18 @@
107
107
  "developer-tools"
108
108
  ],
109
109
  "license": "MIT",
110
+ "funding": {
111
+ "type": "individual",
112
+ "url": "https://buy.stripe.com/bJe14neyU4r4f0leOD3sI02"
113
+ },
114
+ "engines": {
115
+ "node": ">=18.18.0"
116
+ },
110
117
  "dependencies": {
118
+ "@huggingface/transformers": "^3.8.1",
119
+ "@lancedb/lancedb": "^0.26.2",
111
120
  "apache-arrow": "^18.1.0",
112
- "stripe": "^20.4.0"
121
+ "stripe": "^20.4.1"
113
122
  },
114
- "mcpName": "io.github.IgorGanapolsky/rlhf-feedback-loop",
115
- "optionalDependencies": {
116
- "@lancedb/lancedb": "^0.26.2",
117
- "@huggingface/transformers": "^3.8.1"
118
- }
123
+ "mcpName": "io.github.IgorGanapolsky/rlhf-feedback-loop"
119
124
  }
@@ -3,7 +3,7 @@
3
3
  * billing.js — Stripe billing integration using raw fetch (no stripe npm package).
4
4
  *
5
5
  * Functions:
6
- * createCheckoutSession() — Creates Stripe Checkout session for $49/mo Cloud Pro
6
+ * createCheckoutSession() — Creates Stripe Checkout session for Cloud Pro
7
7
  * provisionApiKey(customerId) — Generates unique API key, stores in api-keys.json
8
8
  * validateApiKey(key) — Checks key exists and is active
9
9
  * recordUsage(key) — Increments usage counter for the key
@@ -27,13 +27,157 @@ const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || '';
27
27
  const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || '';
28
28
  const GITHUB_MARKETPLACE_WEBHOOK_SECRET = process.env.GITHUB_MARKETPLACE_WEBHOOK_SECRET || '';
29
29
  const STRIPE_PRICE_ID = process.env.STRIPE_PRICE_ID || 'price_cloud_pro_49_monthly';
30
+
30
31
  const API_KEYS_PATH = process.env._TEST_API_KEYS_PATH || path.resolve(
31
32
  __dirname,
32
33
  '../.claude/memory/feedback/api-keys.json'
33
34
  );
34
35
 
36
+ const FUNNEL_LEDGER_PATH = process.env._TEST_FUNNEL_LEDGER_PATH || process.env.RLHF_FUNNEL_LEDGER_PATH || path.resolve(
37
+ __dirname,
38
+ '../.claude/memory/feedback/funnel-events.jsonl'
39
+ );
40
+
35
41
  const LOCAL_MODE = !STRIPE_SECRET_KEY;
36
42
 
43
+ // ---------------------------------------------------------------------------
44
+ // Internal helpers
45
+ // ---------------------------------------------------------------------------
46
+
47
+ function ensureParentDir(filePath) {
48
+ const dir = path.dirname(filePath);
49
+ if (!fs.existsSync(dir)) {
50
+ fs.mkdirSync(dir, { recursive: true });
51
+ }
52
+ }
53
+
54
+ function sanitizeMetadata(metadata) {
55
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
56
+ return {};
57
+ }
58
+
59
+ const normalized = {};
60
+ for (const [k, v] of Object.entries(metadata)) {
61
+ if (v == null) continue;
62
+ normalized[String(k)] = String(v);
63
+ }
64
+ return normalized;
65
+ }
66
+
67
+ function readInstallIdFromConfig({ cwd = process.cwd(), configPath } = {}) {
68
+ const resolvedPath = configPath || path.resolve(cwd, '.rlhf/config.json');
69
+ try {
70
+ if (!fs.existsSync(resolvedPath)) {
71
+ return null;
72
+ }
73
+ const parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf-8'));
74
+ if (!parsed || typeof parsed.installId !== 'string') {
75
+ return null;
76
+ }
77
+ const installId = parsed.installId.trim();
78
+ return installId || null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ function appendFunnelEvent({ stage, event, installId = null, evidence, metadata = {} } = {}) {
85
+ if (!stage || !event) {
86
+ return { written: false, reason: 'missing_stage_or_event' };
87
+ }
88
+
89
+ const payload = {
90
+ timestamp: new Date().toISOString(),
91
+ stage,
92
+ event,
93
+ evidence: evidence || event,
94
+ installId: installId || null,
95
+ metadata: sanitizeMetadata(metadata),
96
+ };
97
+
98
+ try {
99
+ ensureParentDir(FUNNEL_LEDGER_PATH);
100
+ fs.appendFileSync(FUNNEL_LEDGER_PATH, `${JSON.stringify(payload)}\n`, 'utf-8');
101
+ return { written: true, payload };
102
+ } catch (err) {
103
+ return {
104
+ written: false,
105
+ reason: 'write_failed',
106
+ error: err && err.message ? err.message : 'unknown_error',
107
+ };
108
+ }
109
+ }
110
+
111
+ function loadFunnelLedger() {
112
+ try {
113
+ if (!fs.existsSync(FUNNEL_LEDGER_PATH)) {
114
+ return [];
115
+ }
116
+
117
+ const lines = fs
118
+ .readFileSync(FUNNEL_LEDGER_PATH, 'utf-8')
119
+ .split('\n')
120
+ .map((line) => line.trim())
121
+ .filter(Boolean);
122
+
123
+ const events = [];
124
+ for (const line of lines) {
125
+ try {
126
+ const parsed = JSON.parse(line);
127
+ if (parsed && typeof parsed === 'object') {
128
+ events.push(parsed);
129
+ }
130
+ } catch {
131
+ // Ignore malformed lines to preserve append-only behavior.
132
+ }
133
+ }
134
+
135
+ return events;
136
+ } catch {
137
+ return [];
138
+ }
139
+ }
140
+
141
+ function safeRate(numerator, denominator) {
142
+ if (!denominator) {
143
+ return 0;
144
+ }
145
+ return Number((numerator / denominator).toFixed(4));
146
+ }
147
+
148
+ function getFunnelAnalytics() {
149
+ const events = loadFunnelLedger();
150
+ const stageCounts = {
151
+ acquisition: 0,
152
+ activation: 0,
153
+ paid: 0,
154
+ };
155
+ const eventCounts = {};
156
+
157
+ for (const entry of events) {
158
+ if (entry && typeof entry.stage === 'string') {
159
+ if (Object.prototype.hasOwnProperty.call(stageCounts, entry.stage)) {
160
+ stageCounts[entry.stage] += 1;
161
+ }
162
+
163
+ const eventName = typeof entry.event === 'string' ? entry.event : 'unknown';
164
+ const key = `${entry.stage}:${eventName}`;
165
+ eventCounts[key] = (eventCounts[key] || 0) + 1;
166
+ }
167
+ }
168
+
169
+ return {
170
+ totalEvents: events.length,
171
+ stageCounts,
172
+ eventCounts,
173
+ conversionRates: {
174
+ acquisitionToActivation: safeRate(stageCounts.activation, stageCounts.acquisition),
175
+ activationToPaid: safeRate(stageCounts.paid, stageCounts.activation),
176
+ acquisitionToPaid: safeRate(stageCounts.paid, stageCounts.acquisition),
177
+ },
178
+ };
179
+ }
180
+
37
181
  // ---------------------------------------------------------------------------
38
182
  // Key store helpers
39
183
  // ---------------------------------------------------------------------------
@@ -62,10 +206,7 @@ function loadKeyStore() {
62
206
  * Persist the key store to disk. Creates parent directory if needed.
63
207
  */
64
208
  function saveKeyStore(store) {
65
- const dir = path.dirname(API_KEYS_PATH);
66
- if (!fs.existsSync(dir)) {
67
- fs.mkdirSync(dir, { recursive: true });
68
- }
209
+ ensureParentDir(API_KEYS_PATH);
69
210
  fs.writeFileSync(API_KEYS_PATH, JSON.stringify(store, null, 2), 'utf-8');
70
211
  }
71
212
 
@@ -189,21 +330,44 @@ function flattenParams(obj, prefix = '') {
189
330
  // ---------------------------------------------------------------------------
190
331
 
191
332
  /**
192
- * Create a Stripe Checkout Session for $49/mo Cloud Pro.
333
+ * Create a Stripe Checkout Session for Cloud Pro.
193
334
  *
194
335
  * @param {object} opts
195
336
  * @param {string} opts.successUrl - Redirect URL on payment success
196
337
  * @param {string} opts.cancelUrl - Redirect URL on cancel
197
338
  * @param {string} [opts.customerEmail] - Pre-fill customer email
339
+ * @param {string} [opts.installId] - Correlation install id
340
+ * @param {object} [opts.metadata] - Additional checkout metadata
198
341
  * @returns {Promise<{sessionId: string, url: string}>} in live mode
199
342
  * or {sessionId: 'local_<uuid>', url: null} in local mode
200
343
  */
201
- async function createCheckoutSession({ successUrl, cancelUrl, customerEmail } = {}) {
344
+ async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, installId, metadata } = {}) {
345
+ const resolvedInstallId = installId || readInstallIdFromConfig() || null;
346
+ const checkoutMetadata = sanitizeMetadata({
347
+ ...(metadata || {}),
348
+ ...(resolvedInstallId ? { installId: resolvedInstallId } : {}),
349
+ });
350
+
202
351
  if (LOCAL_MODE) {
352
+ const localSessionId = `local_${crypto.randomUUID()}`;
353
+ appendFunnelEvent({
354
+ stage: 'acquisition',
355
+ event: 'checkout_session_created',
356
+ evidence: 'checkout_session_created',
357
+ installId: resolvedInstallId,
358
+ metadata: {
359
+ provider: 'stripe',
360
+ mode: 'local',
361
+ sessionId: localSessionId,
362
+ customerEmail: customerEmail || '',
363
+ },
364
+ });
365
+
203
366
  return {
204
- sessionId: `local_${crypto.randomUUID()}`,
367
+ sessionId: localSessionId,
205
368
  url: null,
206
369
  localMode: true,
370
+ metadata: checkoutMetadata,
207
371
  };
208
372
  }
209
373
 
@@ -220,36 +384,68 @@ async function createCheckoutSession({ successUrl, cancelUrl, customerEmail } =
220
384
  params.customer_email = customerEmail;
221
385
  }
222
386
 
387
+ if (Object.keys(checkoutMetadata).length > 0) {
388
+ params.metadata = checkoutMetadata;
389
+ }
390
+
223
391
  const session = await stripeRequest('POST', '/checkout/sessions', params);
392
+
393
+ appendFunnelEvent({
394
+ stage: 'acquisition',
395
+ event: 'checkout_session_created',
396
+ evidence: 'checkout_session_created',
397
+ installId: resolvedInstallId,
398
+ metadata: {
399
+ provider: 'stripe',
400
+ mode: 'live',
401
+ sessionId: session.id,
402
+ customerEmail: customerEmail || '',
403
+ },
404
+ });
405
+
224
406
  return {
225
407
  sessionId: session.id,
226
408
  url: session.url,
409
+ metadata: checkoutMetadata,
227
410
  };
228
411
  }
229
412
 
230
413
  /**
231
- * Provision a unique API key for a Stripe customer.
414
+ * Provision a unique API key for a customer.
232
415
  * Stores { customerId, active: true, usageCount: 0, createdAt } in api-keys.json.
233
416
  *
234
417
  * @param {string} customerId - Stripe customer ID (e.g. cus_xxx)
418
+ * @param {object} [opts]
419
+ * @param {string} [opts.installId]
420
+ * @param {string} [opts.source]
235
421
  * @returns {{ key: string, customerId: string, createdAt: string }}
236
422
  */
237
- function provisionApiKey(customerId) {
423
+ function provisionApiKey(customerId, opts = {}) {
238
424
  if (!customerId || typeof customerId !== 'string') {
239
425
  throw new Error('customerId is required');
240
426
  }
241
427
 
428
+ const installId = opts.installId || null;
429
+ const source = opts.source || 'provision';
430
+
242
431
  const store = loadKeyStore();
243
432
 
244
433
  // Check if this customer already has an active key — reuse it
245
434
  const existing = Object.entries(store.keys).find(
246
435
  ([, meta]) => meta.customerId === customerId && meta.active
247
436
  );
437
+
248
438
  if (existing) {
439
+ if (installId && !existing[1].installId) {
440
+ existing[1].installId = installId;
441
+ saveKeyStore(store);
442
+ }
443
+
249
444
  return {
250
445
  key: existing[0],
251
446
  customerId,
252
447
  createdAt: existing[1].createdAt,
448
+ installId: existing[1].installId || null,
253
449
  reused: true,
254
450
  };
255
451
  }
@@ -263,11 +459,13 @@ function provisionApiKey(customerId) {
263
459
  active: true,
264
460
  usageCount: 0,
265
461
  createdAt,
462
+ installId,
463
+ source,
266
464
  };
267
465
 
268
466
  saveKeyStore(store);
269
467
 
270
- return { key, customerId, createdAt };
468
+ return { key, customerId, createdAt, installId };
271
469
  }
272
470
 
273
471
  /**
@@ -296,12 +494,14 @@ function validateApiKey(key) {
296
494
  valid: true,
297
495
  customerId: meta.customerId,
298
496
  usageCount: meta.usageCount,
497
+ installId: meta.installId || null,
299
498
  };
300
499
  }
301
500
 
302
501
  /**
303
502
  * Record one usage event for an API key.
304
503
  * Increments usageCount in the key store.
504
+ * Emits an activation event at first usage transition 0 -> 1.
305
505
  *
306
506
  * @param {string} key - API key to record usage for
307
507
  * @returns {{ recorded: boolean, usageCount?: number }}
@@ -318,9 +518,23 @@ function recordUsage(key) {
318
518
  return { recorded: false };
319
519
  }
320
520
 
321
- meta.usageCount = (meta.usageCount || 0) + 1;
521
+ const previousUsage = Number(meta.usageCount || 0);
522
+ meta.usageCount = previousUsage + 1;
322
523
  saveKeyStore(store);
323
524
 
525
+ if (previousUsage === 0) {
526
+ appendFunnelEvent({
527
+ stage: 'activation',
528
+ event: 'api_key_first_usage',
529
+ evidence: 'api_key_first_usage',
530
+ installId: meta.installId || null,
531
+ metadata: {
532
+ customerId: meta.customerId,
533
+ usageCount: '1',
534
+ },
535
+ });
536
+ }
537
+
324
538
  return { recorded: true, usageCount: meta.usageCount };
325
539
  }
326
540
 
@@ -377,7 +591,28 @@ function handleWebhook(event) {
377
591
  if (!customerId) {
378
592
  return { handled: false, reason: 'missing_customer_id' };
379
593
  }
380
- const result = provisionApiKey(customerId);
594
+
595
+ const installId = session.metadata && typeof session.metadata.installId === 'string'
596
+ ? session.metadata.installId
597
+ : null;
598
+
599
+ const result = provisionApiKey(customerId, {
600
+ installId,
601
+ source: 'stripe_checkout_session_completed',
602
+ });
603
+
604
+ appendFunnelEvent({
605
+ stage: 'paid',
606
+ event: 'stripe_checkout_session_completed',
607
+ evidence: 'stripe_checkout_session_completed',
608
+ installId,
609
+ metadata: {
610
+ provider: 'stripe',
611
+ customerId,
612
+ checkoutSessionId: session.id || '',
613
+ },
614
+ });
615
+
381
616
  return {
382
617
  handled: true,
383
618
  action: 'provisioned_api_key',
@@ -429,121 +664,142 @@ function verifyWebhookSignature(rawBody, signature) {
429
664
  const parts = {};
430
665
  for (const part of signature.split(',')) {
431
666
  const [k, v] = part.split('=');
432
- if (k && v) parts[k] = v;
667
+ if (k && v) {
668
+ parts[k] = v;
669
+ }
433
670
  }
434
671
 
435
672
  if (!parts.t || !parts.v1) {
436
673
  return false;
437
674
  }
438
675
 
676
+ // Timestamp tolerance: +/- 5 minutes (300 seconds)
677
+ const timestamp = parseInt(parts.t, 10);
678
+ const now = Math.floor(Date.now() / 1000);
679
+ if (isNaN(timestamp) || Math.abs(now - timestamp) > 300) {
680
+ return false;
681
+ }
682
+
439
683
  const payload = `${parts.t}.${typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8')}`;
440
684
  const expected = crypto
441
685
  .createHmac('sha256', STRIPE_WEBHOOK_SECRET)
442
686
  .update(payload, 'utf-8')
443
687
  .digest('hex');
444
688
 
445
- // Constant-time comparison
446
689
  try {
447
- return crypto.timingSafeEqual(
448
- Buffer.from(expected, 'hex'),
449
- Buffer.from(parts.v1, 'hex')
450
- );
690
+ return crypto.timingSafeEqual(
691
+ Buffer.from(expected, 'hex'),
692
+ Buffer.from(parts.v1, 'hex')
693
+ );
451
694
  } catch {
452
- return false;
453
- }
695
+ return false;
454
696
  }
697
+ }
455
698
 
456
- /**
457
- * Verify a GitHub Marketplace webhook signature.
458
- * Returns true if valid, false if GITHUB_MARKETPLACE_WEBHOOK_SECRET is not set (local mode).
459
- *
460
- * @param {string|Buffer} rawBody - Raw request body bytes
461
- * @param {string} signature - Value of x-hub-signature-256 header
462
- * @returns {boolean}
463
- */
464
- function verifyGithubWebhookSignature(rawBody, signature) {
699
+ /**
700
+ * Verify a GitHub Marketplace webhook signature.
701
+ * Returns true if valid, false if GITHUB_MARKETPLACE_WEBHOOK_SECRET is not set (local mode).
702
+ *
703
+ * @param {string|Buffer} rawBody - Raw request body bytes
704
+ * @param {string} signature - Value of x-hub-signature-256 header
705
+ * @returns {boolean}
706
+ */
707
+ function verifyGithubWebhookSignature(rawBody, signature) {
465
708
  if (!GITHUB_MARKETPLACE_WEBHOOK_SECRET) {
466
- // Local mode — skip signature verification
467
- return true;
709
+ // Local mode — skip signature verification
710
+ return true;
468
711
  }
469
712
 
470
713
  if (!signature || !rawBody) {
471
- return false;
714
+ return false;
472
715
  }
473
716
 
474
717
  const hmac = crypto.createHmac('sha256', GITHUB_MARKETPLACE_WEBHOOK_SECRET);
475
- const digest = Buffer.from('sha256=' + hmac.update(rawBody).digest('hex'), 'utf8');
718
+ const digest = Buffer.from(`sha256=${hmac.update(rawBody).digest('hex')}`, 'utf8');
476
719
  const checksum = Buffer.from(signature, 'utf8');
477
720
 
478
- // Constant-time comparison
479
721
  return checksum.length === digest.length && crypto.timingSafeEqual(digest, checksum);
722
+ }
723
+
724
+ /**
725
+ * Handle a GitHub Marketplace webhook event.
726
+ *
727
+ * Supported actions:
728
+ * purchased — provision API key for the new customer
729
+ * changed — plan update (upgrade/downgrade)
730
+ * cancelled — disable all keys for that customer
731
+ *
732
+ * @param {object} event - Parsed GitHub Marketplace event object
733
+ * @returns {{ handled: boolean, action?: string, result?: object }}
734
+ */
735
+ function handleGithubWebhook(event) {
736
+ if (!event || typeof event !== 'object') {
737
+ return { handled: false, reason: 'missing_payload_data' };
480
738
  }
481
739
 
482
- /**
483
- * Handle a GitHub Marketplace webhook event.
484
- *
485
- * Supported actions:
486
- * purchased — provision API key for the new customer
487
- * changed — plan update (upgrade/downgrade)
488
- * cancelled — disable all keys for that customer
489
- *
490
- * @param {object} event - Parsed GitHub Marketplace event object
491
- * @returns {{ handled: boolean, action?: string, result?: object }}
492
- */
493
- function handleGithubWebhook(event) {
494
- const { action, marketplace_purchase } = event;
495
- if (!action || !marketplace_purchase) {
496
- return { handled: false, reason: 'missing_payload_data' };
740
+ const { action, marketplace_purchase: marketplacePurchase } = event;
741
+ if (!action || !marketplacePurchase) {
742
+ return { handled: false, reason: 'missing_payload_data' };
497
743
  }
498
744
 
499
- const account = marketplace_purchase.account;
500
- if (!account || !account.id) {
501
- return { handled: false, reason: 'missing_account_id' };
745
+ const account = marketplacePurchase.account;
746
+ if (!account || !account.id || !account.type) {
747
+ return { handled: false, reason: 'missing_account_id' };
502
748
  }
503
749
 
504
750
  // Map GitHub account to customerId: github_<user|organization>_<id>
505
- const customerId = `github_${account.type.toLowerCase()}_${account.id}`;
751
+ const customerId = `github_${String(account.type).toLowerCase()}_${account.id}`;
506
752
 
507
753
  switch (action) {
508
- case 'purchased': {
509
- const result = provisionApiKey(customerId);
510
- return {
511
- handled: true,
512
- action: 'provisioned_api_key',
513
- result,
514
- };
515
- }
754
+ case 'purchased': {
755
+ const result = provisionApiKey(customerId, { source: 'github_marketplace_purchased' });
756
+ appendFunnelEvent({
757
+ stage: 'paid',
758
+ event: 'github_marketplace_purchased',
759
+ evidence: 'github_marketplace_purchased',
760
+ metadata: {
761
+ provider: 'github',
762
+ customerId,
763
+ accountId: String(account.id),
764
+ accountType: String(account.type),
765
+ },
766
+ });
767
+ return {
768
+ handled: true,
769
+ action: 'provisioned_api_key',
770
+ result,
771
+ };
772
+ }
516
773
 
517
- case 'cancelled': {
518
- const result = disableCustomerKeys(customerId);
519
- return {
520
- handled: true,
521
- action: 'disabled_customer_keys',
522
- result,
523
- };
524
- }
774
+ case 'cancelled': {
775
+ const result = disableCustomerKeys(customerId);
776
+ return {
777
+ handled: true,
778
+ action: 'disabled_customer_keys',
779
+ result,
780
+ };
781
+ }
525
782
 
526
- case 'changed': {
527
- // In this simple model, we just ensure a key exists and is active.
528
- // Upgrades/downgrades don't change basic API access unless we had tiered features.
529
- const result = provisionApiKey(customerId);
530
- return {
531
- handled: true,
532
- action: 'plan_changed',
533
- result,
534
- };
535
- }
783
+ case 'changed': {
784
+ // Keep API access active on plan changes.
785
+ const result = provisionApiKey(customerId, { source: 'github_marketplace_changed' });
786
+ return {
787
+ handled: true,
788
+ action: 'plan_changed',
789
+ result,
790
+ };
791
+ }
536
792
 
537
- default:
538
- return { handled: false, reason: `unhandled_action:${action}` };
539
- }
793
+ default:
794
+ return { handled: false, reason: `unhandled_action:${action}` };
540
795
  }
796
+ }
541
797
 
542
- // ---------------------------------------------------------------------------
543
- // Module exports
544
- // ---------------------------------------------------------------------------
798
+ // ---------------------------------------------------------------------------
799
+ // Module exports
800
+ // ---------------------------------------------------------------------------
545
801
 
546
- module.exports = {
802
+ module.exports = {
547
803
  createCheckoutSession,
548
804
  provisionApiKey,
549
805
  validateApiKey,
@@ -554,8 +810,12 @@ function verifyWebhookSignature(rawBody, signature) {
554
810
  verifyGithubWebhookSignature,
555
811
  handleGithubWebhook,
556
812
  loadKeyStore,
813
+ appendFunnelEvent,
814
+ loadFunnelLedger,
815
+ getFunnelAnalytics,
816
+ readInstallIdFromConfig,
557
817
  // Expose for testing
558
818
  _API_KEYS_PATH: API_KEYS_PATH,
819
+ _FUNNEL_LEDGER_PATH: FUNNEL_LEDGER_PATH,
559
820
  _LOCAL_MODE: () => LOCAL_MODE,
560
- };
561
-
821
+ };