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/README.md +45 -31
- package/adapters/chatgpt/openapi.yaml +124 -2
- package/adapters/mcp/server-stdio.js +74 -25
- package/bin/cli.js +34 -3
- package/openapi/openapi.yaml +124 -2
- package/package.json +14 -9
- package/scripts/billing.js +349 -89
- package/scripts/prove-adapters.js +135 -5
- package/scripts/prove-subway-upgrades.js +28 -1
- package/src/api/server.js +58 -24
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rlhf-feedback-loop",
|
|
3
|
-
"version": "0.6.
|
|
4
|
-
"description": "
|
|
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.
|
|
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
|
}
|
package/scripts/billing.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
690
|
+
return crypto.timingSafeEqual(
|
|
691
|
+
Buffer.from(expected, 'hex'),
|
|
692
|
+
Buffer.from(parts.v1, 'hex')
|
|
693
|
+
);
|
|
451
694
|
} catch {
|
|
452
|
-
|
|
453
|
-
}
|
|
695
|
+
return false;
|
|
454
696
|
}
|
|
697
|
+
}
|
|
455
698
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
467
|
-
|
|
709
|
+
// Local mode — skip signature verification
|
|
710
|
+
return true;
|
|
468
711
|
}
|
|
469
712
|
|
|
470
713
|
if (!signature || !rawBody) {
|
|
471
|
-
|
|
714
|
+
return false;
|
|
472
715
|
}
|
|
473
716
|
|
|
474
717
|
const hmac = crypto.createHmac('sha256', GITHUB_MARKETPLACE_WEBHOOK_SECRET);
|
|
475
|
-
const digest = Buffer.from(
|
|
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
|
-
|
|
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 =
|
|
500
|
-
if (!account || !account.id) {
|
|
501
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
}
|
|
793
|
+
default:
|
|
794
|
+
return { handled: false, reason: `unhandled_action:${action}` };
|
|
540
795
|
}
|
|
796
|
+
}
|
|
541
797
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
798
|
+
// ---------------------------------------------------------------------------
|
|
799
|
+
// Module exports
|
|
800
|
+
// ---------------------------------------------------------------------------
|
|
545
801
|
|
|
546
|
-
|
|
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
|
+
};
|