rlhf-feedback-loop 0.6.11 → 0.6.13
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/CHANGELOG.md +10 -0
- package/README.md +116 -74
- package/adapters/README.md +3 -3
- package/adapters/amp/skills/rlhf-feedback/SKILL.md +2 -0
- package/adapters/chatgpt/INSTALL.md +7 -4
- package/adapters/chatgpt/openapi.yaml +6 -3
- package/adapters/claude/.mcp.json +3 -3
- package/adapters/codex/config.toml +3 -3
- package/adapters/gemini/function-declarations.json +2 -2
- package/adapters/mcp/server-stdio.js +19 -5
- package/bin/cli.js +302 -32
- package/openapi/openapi.yaml +6 -3
- package/package.json +22 -9
- package/scripts/a2ui-engine.js +73 -0
- package/scripts/adk-consolidator.js +126 -32
- package/scripts/billing.js +192 -685
- package/scripts/context-engine.js +81 -0
- package/scripts/export-kto-pairs.js +310 -0
- package/scripts/feedback-ingest-watcher.js +290 -0
- package/scripts/feedback-loop.js +154 -9
- package/scripts/feedback-quality.js +139 -0
- package/scripts/feedback-schema.js +31 -5
- package/scripts/feedback-to-memory.js +13 -1
- package/scripts/generate-paperbanana-diagrams.sh +1 -1
- package/scripts/hook-auto-capture.sh +6 -0
- package/scripts/hook-stop-self-score.sh +51 -0
- package/scripts/install-mcp.js +168 -0
- package/scripts/jsonl-watcher.js +155 -0
- package/scripts/local-model-profile.js +207 -0
- package/scripts/pr-manager.js +112 -0
- package/scripts/prove-adapters.js +137 -15
- package/scripts/prove-automation.js +41 -8
- package/scripts/prove-lancedb.js +1 -1
- package/scripts/prove-local-intelligence.js +244 -0
- package/scripts/prove-workflow-contract.js +116 -0
- package/scripts/reminder-engine.js +132 -0
- package/scripts/risk-scorer.js +458 -0
- package/scripts/rlaif-self-audit.js +7 -1
- package/scripts/status-dashboard.js +155 -0
- package/scripts/test-coverage.js +1 -1
- package/scripts/validate-workflow-contract.js +287 -0
- package/scripts/vector-store.js +115 -17
- package/src/api/server.js +372 -25
package/scripts/billing.js
CHANGED
|
@@ -1,16 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* billing.js — Stripe billing integration using
|
|
4
|
-
*
|
|
5
|
-
* Functions:
|
|
6
|
-
* createCheckoutSession() — Creates Stripe Checkout session for Cloud Pro
|
|
7
|
-
* provisionApiKey(customerId) — Generates unique API key, stores in api-keys.json
|
|
8
|
-
* validateApiKey(key) — Checks key exists and is active
|
|
9
|
-
* recordUsage(key) — Increments usage counter for the key
|
|
10
|
-
* handleWebhook(event) — Processes checkout.session.completed + subscription.deleted
|
|
11
|
-
*
|
|
12
|
-
* Local mode: When STRIPE_SECRET_KEY is not set, all Stripe calls are no-ops.
|
|
13
|
-
* Keys stored in: .claude/memory/feedback/api-keys.json (gitignored)
|
|
3
|
+
* billing.js — Stripe billing integration using official Stripe SDK.
|
|
14
4
|
*/
|
|
15
5
|
|
|
16
6
|
'use strict';
|
|
@@ -18,27 +8,40 @@
|
|
|
18
8
|
const fs = require('fs');
|
|
19
9
|
const path = require('path');
|
|
20
10
|
const crypto = require('crypto');
|
|
11
|
+
const Stripe = require('stripe');
|
|
21
12
|
|
|
22
13
|
// ---------------------------------------------------------------------------
|
|
23
14
|
// Config
|
|
24
15
|
// ---------------------------------------------------------------------------
|
|
25
16
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
)
|
|
17
|
+
const CONFIG = {
|
|
18
|
+
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY || '',
|
|
19
|
+
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET || '',
|
|
20
|
+
GITHUB_MARKETPLACE_WEBHOOK_SECRET: process.env.GITHUB_MARKETPLACE_WEBHOOK_SECRET || '',
|
|
21
|
+
STRIPE_PRICE_ID: process.env.STRIPE_PRICE_ID || 'price_1RNdUBGGBpd520QYG1A9SWF4',
|
|
22
|
+
get API_KEYS_PATH() {
|
|
23
|
+
return process.env._TEST_API_KEYS_PATH || path.resolve(__dirname, '../.claude/memory/feedback/api-keys.json');
|
|
24
|
+
},
|
|
25
|
+
get FUNNEL_LEDGER_PATH() {
|
|
26
|
+
return process.env._TEST_FUNNEL_LEDGER_PATH || process.env.RLHF_FUNNEL_LEDGER_PATH || path.resolve(__dirname, '../.claude/memory/feedback/funnel-events.jsonl');
|
|
27
|
+
},
|
|
28
|
+
get LOCAL_CHECKOUT_SESSIONS_PATH() {
|
|
29
|
+
return process.env._TEST_LOCAL_CHECKOUT_SESSIONS_PATH || path.resolve(__dirname, '../.claude/memory/feedback/local-checkout-sessions.json');
|
|
30
|
+
}
|
|
31
|
+
};
|
|
35
32
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
)
|
|
33
|
+
let _stripeClient = null;
|
|
34
|
+
function getStripeClient() {
|
|
35
|
+
if (!_stripeClient) {
|
|
36
|
+
if (!CONFIG.STRIPE_SECRET_KEY) {
|
|
37
|
+
throw new Error('STRIPE_SECRET_KEY is missing. Stripe client cannot be initialized.');
|
|
38
|
+
}
|
|
39
|
+
_stripeClient = new Stripe(CONFIG.STRIPE_SECRET_KEY);
|
|
40
|
+
}
|
|
41
|
+
return _stripeClient;
|
|
42
|
+
}
|
|
40
43
|
|
|
41
|
-
const LOCAL_MODE = !STRIPE_SECRET_KEY;
|
|
44
|
+
const LOCAL_MODE = () => !CONFIG.STRIPE_SECRET_KEY;
|
|
42
45
|
|
|
43
46
|
// ---------------------------------------------------------------------------
|
|
44
47
|
// Internal helpers
|
|
@@ -52,774 +55,278 @@ function ensureParentDir(filePath) {
|
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
function sanitizeMetadata(metadata) {
|
|
55
|
-
if (!metadata || typeof metadata !== 'object'
|
|
56
|
-
|
|
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
|
-
}
|
|
58
|
+
if (!metadata || typeof metadata !== 'object') return {};
|
|
59
|
+
return { ...metadata };
|
|
82
60
|
}
|
|
83
61
|
|
|
84
62
|
function appendFunnelEvent({ stage, event, installId = null, evidence, metadata = {} } = {}) {
|
|
85
|
-
if (!stage || !event) {
|
|
86
|
-
|
|
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
|
-
|
|
63
|
+
if (!stage || !event) return { written: false, reason: 'missing_stage_or_event' };
|
|
64
|
+
const payload = { timestamp: new Date().toISOString(), stage, event, evidence: evidence || event, installId: installId || null, metadata: sanitizeMetadata(metadata) };
|
|
98
65
|
try {
|
|
99
|
-
|
|
100
|
-
|
|
66
|
+
const target = CONFIG.FUNNEL_LEDGER_PATH;
|
|
67
|
+
ensureParentDir(target);
|
|
68
|
+
fs.appendFileSync(target, `${JSON.stringify(payload)}\n`, 'utf-8');
|
|
101
69
|
return { written: true, payload };
|
|
102
70
|
} catch (err) {
|
|
103
|
-
return {
|
|
104
|
-
written: false,
|
|
105
|
-
reason: 'write_failed',
|
|
106
|
-
error: err && err.message ? err.message : 'unknown_error',
|
|
107
|
-
};
|
|
71
|
+
return { written: false, reason: 'write_failed', error: err.message };
|
|
108
72
|
}
|
|
109
73
|
}
|
|
110
74
|
|
|
111
75
|
function loadFunnelLedger() {
|
|
112
76
|
try {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
}
|
|
77
|
+
const target = CONFIG.FUNNEL_LEDGER_PATH;
|
|
78
|
+
if (!fs.existsSync(target)) return [];
|
|
79
|
+
return fs.readFileSync(target, 'utf-8').split('\n').map(l => l.trim()).filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
80
|
+
} catch { return []; }
|
|
81
|
+
}
|
|
134
82
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
83
|
+
function loadLocalCheckoutSessions() {
|
|
84
|
+
try {
|
|
85
|
+
const target = CONFIG.LOCAL_CHECKOUT_SESSIONS_PATH;
|
|
86
|
+
if (!fs.existsSync(target)) return { sessions: {} };
|
|
87
|
+
const parsed = JSON.parse(fs.readFileSync(target, 'utf-8'));
|
|
88
|
+
return (parsed && typeof parsed.sessions === 'object') ? parsed : { sessions: {} };
|
|
89
|
+
} catch { return { sessions: {} }; }
|
|
139
90
|
}
|
|
140
91
|
|
|
141
|
-
function
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
return Number((numerator / denominator).toFixed(4));
|
|
92
|
+
function saveLocalCheckoutSessions(store) {
|
|
93
|
+
const target = CONFIG.LOCAL_CHECKOUT_SESSIONS_PATH;
|
|
94
|
+
ensureParentDir(target);
|
|
95
|
+
fs.writeFileSync(target, JSON.stringify(store, null, 2), 'utf-8');
|
|
146
96
|
}
|
|
147
97
|
|
|
148
98
|
function getFunnelAnalytics() {
|
|
149
99
|
const events = loadFunnelLedger();
|
|
150
|
-
const stageCounts = {
|
|
151
|
-
acquisition: 0,
|
|
152
|
-
activation: 0,
|
|
153
|
-
paid: 0,
|
|
154
|
-
};
|
|
100
|
+
const stageCounts = { acquisition: 0, activation: 0, paid: 0 };
|
|
155
101
|
const eventCounts = {};
|
|
156
|
-
|
|
157
102
|
for (const entry of events) {
|
|
158
|
-
if (entry &&
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const eventName = typeof entry.event === 'string' ? entry.event : 'unknown';
|
|
164
|
-
const key = `${entry.stage}:${eventName}`;
|
|
103
|
+
if (entry && stageCounts.hasOwnProperty(entry.stage)) {
|
|
104
|
+
stageCounts[entry.stage]++;
|
|
105
|
+
const key = `${entry.stage}:${entry.event || 'unknown'}`;
|
|
165
106
|
eventCounts[key] = (eventCounts[key] || 0) + 1;
|
|
166
107
|
}
|
|
167
108
|
}
|
|
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
|
-
};
|
|
109
|
+
const safeRate = (num, den) => den ? Number((num / den).toFixed(4)) : 0;
|
|
110
|
+
return { totalEvents: events.length, stageCounts, eventCounts, conversionRates: { acquisitionToActivation: safeRate(stageCounts.activation, stageCounts.acquisition), activationToPaid: safeRate(stageCounts.paid, stageCounts.activation), acquisitionToPaid: safeRate(stageCounts.paid, stageCounts.acquisition) } };
|
|
179
111
|
}
|
|
180
112
|
|
|
181
|
-
// ---------------------------------------------------------------------------
|
|
182
|
-
// Key store helpers
|
|
183
|
-
// ---------------------------------------------------------------------------
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Load the API key store from disk.
|
|
187
|
-
* Returns { keys: { [key]: { customerId, active, usageCount, createdAt } } }
|
|
188
|
-
*/
|
|
189
113
|
function loadKeyStore() {
|
|
190
114
|
try {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (!parsed || typeof parsed.keys !== 'object') {
|
|
197
|
-
return { keys: {} };
|
|
198
|
-
}
|
|
199
|
-
return parsed;
|
|
200
|
-
} catch {
|
|
201
|
-
return { keys: {} };
|
|
202
|
-
}
|
|
115
|
+
const target = CONFIG.API_KEYS_PATH;
|
|
116
|
+
if (!fs.existsSync(target)) return { keys: {} };
|
|
117
|
+
const parsed = JSON.parse(fs.readFileSync(target, 'utf-8'));
|
|
118
|
+
return (parsed && typeof parsed.keys === 'object') ? parsed : { keys: {} };
|
|
119
|
+
} catch { return { keys: {} }; }
|
|
203
120
|
}
|
|
204
121
|
|
|
205
|
-
/**
|
|
206
|
-
* Persist the key store to disk. Creates parent directory if needed.
|
|
207
|
-
*/
|
|
208
122
|
function saveKeyStore(store) {
|
|
209
|
-
|
|
210
|
-
|
|
123
|
+
const target = CONFIG.API_KEYS_PATH;
|
|
124
|
+
ensureParentDir(target);
|
|
125
|
+
fs.writeFileSync(target, JSON.stringify(store, null, 2), 'utf-8');
|
|
211
126
|
}
|
|
212
127
|
|
|
213
128
|
// ---------------------------------------------------------------------------
|
|
214
|
-
//
|
|
129
|
+
// Core Exports
|
|
215
130
|
// ---------------------------------------------------------------------------
|
|
216
131
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
* Returns parsed JSON response.
|
|
220
|
-
* Throws on non-2xx responses with the Stripe error message.
|
|
221
|
-
*/
|
|
222
|
-
async function stripeRequest(method, endpoint, params = {}) {
|
|
223
|
-
if (LOCAL_MODE) {
|
|
224
|
-
throw new Error('STRIPE_SECRET_KEY not configured — local mode active');
|
|
225
|
-
}
|
|
132
|
+
async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, installId, metadata = {} } = {}) {
|
|
133
|
+
const checkoutMetadata = { ...metadata, installId: installId || 'unknown' };
|
|
226
134
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const fullUrl = method === 'GET' && Object.keys(params).length > 0
|
|
236
|
-
? `${url}?${new URLSearchParams(flattenParams(params)).toString()}`
|
|
237
|
-
: url;
|
|
238
|
-
|
|
239
|
-
const headers = {
|
|
240
|
-
'Authorization': `Bearer ${STRIPE_SECRET_KEY}`,
|
|
241
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
242
|
-
'Stripe-Version': '2023-10-16',
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
// Use fetch if available (Node 18+), otherwise fall back to https module
|
|
246
|
-
if (typeof fetch !== 'undefined') {
|
|
247
|
-
const response = await fetch(fullUrl, {
|
|
248
|
-
method,
|
|
249
|
-
headers,
|
|
250
|
-
body,
|
|
251
|
-
});
|
|
252
|
-
const json = await response.json();
|
|
253
|
-
if (!response.ok) {
|
|
254
|
-
const msg = (json.error && json.error.message) || `Stripe error ${response.status}`;
|
|
255
|
-
const err = new Error(msg);
|
|
256
|
-
err.stripeError = json.error;
|
|
257
|
-
err.statusCode = response.status;
|
|
258
|
-
throw err;
|
|
259
|
-
}
|
|
260
|
-
return json;
|
|
135
|
+
if (LOCAL_MODE()) {
|
|
136
|
+
const localSessionId = `test_session_${crypto.randomBytes(8).toString('hex')}`;
|
|
137
|
+
const store = loadLocalCheckoutSessions();
|
|
138
|
+
store.sessions[localSessionId] = { id: localSessionId, customer: `local_cus_${crypto.randomBytes(4).toString('hex')}`, metadata: checkoutMetadata, payment_status: 'paid', status: 'complete' };
|
|
139
|
+
saveLocalCheckoutSessions(store);
|
|
140
|
+
|
|
141
|
+
appendFunnelEvent({ stage: 'acquisition', event: 'checkout_session_created', installId, evidence: 'local_mode_manual', metadata: checkoutMetadata });
|
|
142
|
+
return { sessionId: localSessionId, url: null, localMode: true, metadata: checkoutMetadata };
|
|
261
143
|
}
|
|
262
144
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
headers: { ...headers },
|
|
272
|
-
};
|
|
273
|
-
if (body) {
|
|
274
|
-
options.headers['Content-Length'] = Buffer.byteLength(body);
|
|
275
|
-
}
|
|
276
|
-
const req = https.request(options, (res) => {
|
|
277
|
-
const chunks = [];
|
|
278
|
-
res.on('data', (c) => chunks.push(c));
|
|
279
|
-
res.on('end', () => {
|
|
280
|
-
try {
|
|
281
|
-
const json = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
|
|
282
|
-
if (res.statusCode >= 400) {
|
|
283
|
-
const msg = (json.error && json.error.message) || `Stripe error ${res.statusCode}`;
|
|
284
|
-
const err = new Error(msg);
|
|
285
|
-
err.stripeError = json.error;
|
|
286
|
-
err.statusCode = res.statusCode;
|
|
287
|
-
reject(err);
|
|
288
|
-
} else {
|
|
289
|
-
resolve(json);
|
|
290
|
-
}
|
|
291
|
-
} catch (e) {
|
|
292
|
-
reject(e);
|
|
293
|
-
}
|
|
294
|
-
});
|
|
295
|
-
});
|
|
296
|
-
req.on('error', reject);
|
|
297
|
-
if (body) req.write(body);
|
|
298
|
-
req.end();
|
|
145
|
+
const stripe = getStripeClient();
|
|
146
|
+
const session = await stripe.checkout.sessions.create({
|
|
147
|
+
success_url: successUrl,
|
|
148
|
+
cancel_url: cancelUrl,
|
|
149
|
+
customer_email: customerEmail,
|
|
150
|
+
mode: 'subscription',
|
|
151
|
+
line_items: [{ price: CONFIG.STRIPE_PRICE_ID, quantity: 1 }],
|
|
152
|
+
metadata: checkoutMetadata,
|
|
299
153
|
});
|
|
154
|
+
|
|
155
|
+
appendFunnelEvent({ stage: 'acquisition', event: 'checkout_session_created', installId, evidence: session.id, metadata: checkoutMetadata });
|
|
156
|
+
return { sessionId: session.id, url: session.url, localMode: false, metadata: checkoutMetadata };
|
|
300
157
|
}
|
|
301
158
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
for (const [k, v] of Object.entries(obj)) {
|
|
310
|
-
const key = prefix ? `${prefix}[${k}]` : k;
|
|
311
|
-
if (Array.isArray(v)) {
|
|
312
|
-
v.forEach((item, i) => {
|
|
313
|
-
if (item !== null && typeof item === 'object') {
|
|
314
|
-
Object.assign(result, flattenParams(item, `${key}[${i}]`));
|
|
315
|
-
} else {
|
|
316
|
-
result[`${key}[${i}]`] = String(item);
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
} else if (v !== null && typeof v === 'object') {
|
|
320
|
-
Object.assign(result, flattenParams(v, key));
|
|
321
|
-
} else if (v !== undefined && v !== null) {
|
|
322
|
-
result[key] = String(v);
|
|
323
|
-
}
|
|
159
|
+
async function getCheckoutSessionStatus(sessionId) {
|
|
160
|
+
if (LOCAL_MODE()) {
|
|
161
|
+
const store = loadLocalCheckoutSessions();
|
|
162
|
+
const session = store.sessions[sessionId];
|
|
163
|
+
if (!session) return { found: false };
|
|
164
|
+
const provisioned = provisionApiKey(session.customer, { installId: session.metadata?.installId, source: 'local_checkout_lookup' });
|
|
165
|
+
return { found: true, localMode: true, sessionId, paid: true, paymentStatus: 'paid', status: 'complete', customerId: session.customer, installId: session.metadata?.installId, apiKey: provisioned.key };
|
|
324
166
|
}
|
|
325
|
-
return result;
|
|
326
|
-
}
|
|
327
167
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
168
|
+
try {
|
|
169
|
+
const stripe = getStripeClient();
|
|
170
|
+
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
|
171
|
+
const isPaid = session.payment_status === 'paid' || session.payment_status === 'no_payment_required';
|
|
331
172
|
|
|
332
|
-
|
|
333
|
-
* Create a Stripe Checkout Session for Cloud Pro.
|
|
334
|
-
*
|
|
335
|
-
* @param {object} opts
|
|
336
|
-
* @param {string} opts.successUrl - Redirect URL on payment success
|
|
337
|
-
* @param {string} opts.cancelUrl - Redirect URL on cancel
|
|
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
|
|
341
|
-
* @returns {Promise<{sessionId: string, url: string}>} in live mode
|
|
342
|
-
* or {sessionId: 'local_<uuid>', url: null} in local mode
|
|
343
|
-
*/
|
|
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
|
-
});
|
|
173
|
+
if (!isPaid) return { found: true, localMode: false, sessionId, paid: false, paymentStatus: session.payment_status, status: session.status };
|
|
350
174
|
|
|
351
|
-
|
|
352
|
-
const
|
|
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
|
-
});
|
|
175
|
+
const installId = session.metadata?.installId || null;
|
|
176
|
+
const provisioned = provisionApiKey(session.customer, { installId, source: 'stripe_checkout_session_lookup' });
|
|
365
177
|
|
|
366
178
|
return {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
179
|
+
found: true,
|
|
180
|
+
localMode: false,
|
|
181
|
+
sessionId,
|
|
182
|
+
paid: true,
|
|
183
|
+
paymentStatus: session.payment_status,
|
|
184
|
+
customerId: session.customer,
|
|
185
|
+
customerEmail: session.customer_details?.email || '',
|
|
186
|
+
installId,
|
|
187
|
+
apiKey: provisioned.key,
|
|
371
188
|
};
|
|
189
|
+
} catch {
|
|
190
|
+
return { found: false };
|
|
372
191
|
}
|
|
373
|
-
|
|
374
|
-
if (!STRIPE_PRICE_ID) {
|
|
375
|
-
throw new Error('STRIPE_PRICE_ID not configured');
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
const params = {
|
|
379
|
-
mode: 'subscription',
|
|
380
|
-
line_items: [
|
|
381
|
-
{ price: STRIPE_PRICE_ID, quantity: 1 },
|
|
382
|
-
],
|
|
383
|
-
success_url: successUrl || 'https://example.com/success?session_id={CHECKOUT_SESSION_ID}',
|
|
384
|
-
cancel_url: cancelUrl || 'https://example.com/cancel',
|
|
385
|
-
};
|
|
386
|
-
|
|
387
|
-
if (customerEmail) {
|
|
388
|
-
params.customer_email = customerEmail;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
if (Object.keys(checkoutMetadata).length > 0) {
|
|
392
|
-
params.metadata = checkoutMetadata;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
const session = await stripeRequest('POST', '/checkout/sessions', params);
|
|
396
|
-
|
|
397
|
-
appendFunnelEvent({
|
|
398
|
-
stage: 'acquisition',
|
|
399
|
-
event: 'checkout_session_created',
|
|
400
|
-
evidence: 'checkout_session_created',
|
|
401
|
-
installId: resolvedInstallId,
|
|
402
|
-
metadata: {
|
|
403
|
-
provider: 'stripe',
|
|
404
|
-
mode: 'live',
|
|
405
|
-
sessionId: session.id,
|
|
406
|
-
customerEmail: customerEmail || '',
|
|
407
|
-
},
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
return {
|
|
411
|
-
sessionId: session.id,
|
|
412
|
-
url: session.url,
|
|
413
|
-
metadata: checkoutMetadata,
|
|
414
|
-
};
|
|
415
192
|
}
|
|
416
193
|
|
|
417
|
-
/**
|
|
418
|
-
* Provision a unique API key for a customer.
|
|
419
|
-
* Stores { customerId, active: true, usageCount: 0, createdAt } in api-keys.json.
|
|
420
|
-
*
|
|
421
|
-
* @param {string} customerId - Stripe customer ID (e.g. cus_xxx)
|
|
422
|
-
* @param {object} [opts]
|
|
423
|
-
* @param {string} [opts.installId]
|
|
424
|
-
* @param {string} [opts.source]
|
|
425
|
-
* @returns {{ key: string, customerId: string, createdAt: string }}
|
|
426
|
-
*/
|
|
427
194
|
function provisionApiKey(customerId, opts = {}) {
|
|
428
|
-
if (!customerId || typeof customerId !== 'string')
|
|
429
|
-
throw new Error('customerId is required');
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
const installId = opts.installId || null;
|
|
433
|
-
const source = opts.source || 'provision';
|
|
434
|
-
|
|
195
|
+
if (!customerId || typeof customerId !== 'string') throw new Error('customerId is required');
|
|
435
196
|
const store = loadKeyStore();
|
|
436
|
-
|
|
437
|
-
// Check if this customer already has an active key — reuse it
|
|
438
|
-
const existing = Object.entries(store.keys).find(
|
|
439
|
-
([, meta]) => meta.customerId === customerId && meta.active
|
|
440
|
-
);
|
|
197
|
+
const existing = Object.entries(store.keys).find(([, m]) => m.customerId === customerId && m.active);
|
|
441
198
|
|
|
442
199
|
if (existing) {
|
|
443
|
-
if (installId && !existing[1].installId) {
|
|
444
|
-
|
|
445
|
-
saveKeyStore(store);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
return {
|
|
449
|
-
key: existing[0],
|
|
450
|
-
customerId,
|
|
451
|
-
createdAt: existing[1].createdAt,
|
|
452
|
-
installId: existing[1].installId || null,
|
|
453
|
-
reused: true,
|
|
454
|
-
};
|
|
200
|
+
if (opts.installId && !existing[1].installId) { existing[1].installId = opts.installId; saveKeyStore(store); }
|
|
201
|
+
return { key: existing[0], customerId, createdAt: existing[1].createdAt, installId: existing[1].installId || null, reused: true };
|
|
455
202
|
}
|
|
456
203
|
|
|
457
|
-
// Generate cryptographically random key: rlhf_<32 hex chars>
|
|
458
204
|
const key = `rlhf_${crypto.randomBytes(16).toString('hex')}`;
|
|
459
205
|
const createdAt = new Date().toISOString();
|
|
206
|
+
store.keys[key] = { customerId, active: true, usageCount: 0, createdAt, installId: opts.installId || null, source: opts.source || 'provision' };
|
|
207
|
+
saveKeyStore(store);
|
|
208
|
+
return { key, customerId, createdAt, installId: opts.installId || null };
|
|
209
|
+
}
|
|
460
210
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
installId,
|
|
467
|
-
source,
|
|
468
|
-
};
|
|
211
|
+
function rotateApiKey(oldKey) {
|
|
212
|
+
if (!oldKey) return { rotated: false, reason: 'missing_old_key' };
|
|
213
|
+
const store = loadKeyStore();
|
|
214
|
+
const meta = store.keys[oldKey];
|
|
215
|
+
if (!meta || !meta.active) return { rotated: false, reason: 'key_not_active' };
|
|
469
216
|
|
|
217
|
+
meta.active = false;
|
|
218
|
+
meta.disabledAt = new Date().toISOString();
|
|
219
|
+
const newKey = `rlhf_${crypto.randomBytes(16).toString('hex')}`;
|
|
220
|
+
store.keys[newKey] = { customerId: meta.customerId, active: true, usageCount: 0, createdAt: new Date().toISOString(), installId: meta.installId, source: 'rotation', replacedKey: oldKey };
|
|
470
221
|
saveKeyStore(store);
|
|
471
|
-
|
|
472
|
-
return { key, customerId, createdAt, installId };
|
|
222
|
+
return { rotated: true, key: newKey, oldKey };
|
|
473
223
|
}
|
|
474
224
|
|
|
475
|
-
/**
|
|
476
|
-
* Validate an API key.
|
|
477
|
-
*
|
|
478
|
-
* @param {string} key - API key to validate
|
|
479
|
-
* @returns {{ valid: boolean, customerId?: string, usageCount?: number }}
|
|
480
|
-
*/
|
|
481
225
|
function validateApiKey(key) {
|
|
482
|
-
if (!key
|
|
483
|
-
return { valid: false };
|
|
484
|
-
}
|
|
485
|
-
|
|
226
|
+
if (!key) return { valid: false };
|
|
486
227
|
const store = loadKeyStore();
|
|
487
228
|
const meta = store.keys[key];
|
|
488
|
-
|
|
489
|
-
if (!meta) {
|
|
490
|
-
return { valid: false };
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
if (!meta.active) {
|
|
494
|
-
return { valid: false, reason: 'key_disabled' };
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
return {
|
|
498
|
-
valid: true,
|
|
499
|
-
customerId: meta.customerId,
|
|
500
|
-
usageCount: meta.usageCount,
|
|
501
|
-
installId: meta.installId || null,
|
|
502
|
-
};
|
|
229
|
+
return (meta && meta.active) ? { valid: true, metadata: meta } : { valid: false };
|
|
503
230
|
}
|
|
504
231
|
|
|
505
|
-
/**
|
|
506
|
-
* Record one usage event for an API key.
|
|
507
|
-
* Increments usageCount in the key store.
|
|
508
|
-
* Emits an activation event at first usage transition 0 -> 1.
|
|
509
|
-
*
|
|
510
|
-
* @param {string} key - API key to record usage for
|
|
511
|
-
* @returns {{ recorded: boolean, usageCount?: number }}
|
|
512
|
-
*/
|
|
513
232
|
function recordUsage(key) {
|
|
514
|
-
if (!key || typeof key !== 'string') {
|
|
515
|
-
return { recorded: false };
|
|
516
|
-
}
|
|
517
|
-
|
|
518
233
|
const store = loadKeyStore();
|
|
519
234
|
const meta = store.keys[key];
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
meta.usageCount = previousUsage + 1;
|
|
527
|
-
saveKeyStore(store);
|
|
528
|
-
|
|
529
|
-
if (previousUsage === 0) {
|
|
530
|
-
appendFunnelEvent({
|
|
531
|
-
stage: 'activation',
|
|
532
|
-
event: 'api_key_first_usage',
|
|
533
|
-
evidence: 'api_key_first_usage',
|
|
534
|
-
installId: meta.installId || null,
|
|
535
|
-
metadata: {
|
|
536
|
-
customerId: meta.customerId,
|
|
537
|
-
usageCount: '1',
|
|
538
|
-
},
|
|
539
|
-
});
|
|
235
|
+
if (meta && meta.active) {
|
|
236
|
+
const oldVal = meta.usageCount || 0;
|
|
237
|
+
meta.usageCount = oldVal + 1;
|
|
238
|
+
if (oldVal === 0) appendFunnelEvent({ stage: 'activation', event: 'api_key_first_usage', installId: meta.installId, evidence: key, metadata: { customerId: meta.customerId } });
|
|
239
|
+
saveKeyStore(store);
|
|
240
|
+
return { recorded: true, usageCount: meta.usageCount };
|
|
540
241
|
}
|
|
541
|
-
|
|
542
|
-
return { recorded: true, usageCount: meta.usageCount };
|
|
242
|
+
return { recorded: false };
|
|
543
243
|
}
|
|
544
244
|
|
|
545
245
|
/**
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
* @param {string} customerId - Stripe customer ID
|
|
549
|
-
* @returns {{ disabledCount: number }}
|
|
246
|
+
* Report usage to Stripe for metered billing.
|
|
550
247
|
*/
|
|
551
|
-
function
|
|
552
|
-
if (
|
|
553
|
-
|
|
248
|
+
async function reportUsageToStripe(subscriptionItemId, quantity = 1) {
|
|
249
|
+
if (LOCAL_MODE()) return { reported: false, reason: 'local_mode' };
|
|
250
|
+
try {
|
|
251
|
+
const stripe = getStripeClient();
|
|
252
|
+
const record = await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
|
|
253
|
+
quantity,
|
|
254
|
+
timestamp: 'now',
|
|
255
|
+
action: 'increment'
|
|
256
|
+
});
|
|
257
|
+
return { reported: true, record };
|
|
258
|
+
} catch (err) {
|
|
259
|
+
return { reported: false, error: err.message };
|
|
554
260
|
}
|
|
261
|
+
}
|
|
555
262
|
|
|
263
|
+
function disableCustomerKeys(customerId) {
|
|
556
264
|
const store = loadKeyStore();
|
|
557
265
|
let disabledCount = 0;
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
if (meta.customerId === customerId && meta.active) {
|
|
561
|
-
meta.active = false;
|
|
562
|
-
disabledCount++;
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
if (disabledCount > 0) {
|
|
567
|
-
saveKeyStore(store);
|
|
266
|
+
for (const [key, meta] of Object.entries(store.keys)) {
|
|
267
|
+
if (meta.customerId === customerId && meta.active) { meta.active = false; meta.disabledAt = new Date().toISOString(); disabledCount++; }
|
|
568
268
|
}
|
|
569
|
-
|
|
269
|
+
if (disabledCount > 0) saveKeyStore(store);
|
|
570
270
|
return { disabledCount };
|
|
571
271
|
}
|
|
572
272
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
* @returns {{ handled: boolean, action?: string, result?: object }}
|
|
582
|
-
*/
|
|
583
|
-
function handleWebhook(event) {
|
|
584
|
-
if (!event || !event.type) {
|
|
585
|
-
return { handled: false, reason: 'missing_event_type' };
|
|
273
|
+
async function handleWebhook(rawBody, signature) {
|
|
274
|
+
if (LOCAL_MODE()) return { handled: false, reason: 'local_mode' };
|
|
275
|
+
let event;
|
|
276
|
+
try {
|
|
277
|
+
const stripe = getStripeClient();
|
|
278
|
+
event = stripe.webhooks.constructEvent(rawBody, signature, CONFIG.STRIPE_WEBHOOK_SECRET);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
return { handled: false, reason: 'invalid_signature', error: err.message };
|
|
586
281
|
}
|
|
587
282
|
|
|
588
283
|
switch (event.type) {
|
|
589
284
|
case 'checkout.session.completed': {
|
|
590
|
-
const session = event.data
|
|
591
|
-
if (!session) {
|
|
592
|
-
return { handled: false, reason: 'missing_session_data' };
|
|
593
|
-
}
|
|
285
|
+
const session = event.data.object;
|
|
594
286
|
const customerId = session.customer;
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
const installId = session.metadata && typeof session.metadata.installId === 'string'
|
|
600
|
-
? session.metadata.installId
|
|
601
|
-
: null;
|
|
602
|
-
|
|
603
|
-
const result = provisionApiKey(customerId, {
|
|
604
|
-
installId,
|
|
605
|
-
source: 'stripe_checkout_session_completed',
|
|
606
|
-
});
|
|
607
|
-
|
|
608
|
-
appendFunnelEvent({
|
|
609
|
-
stage: 'paid',
|
|
610
|
-
event: 'stripe_checkout_session_completed',
|
|
611
|
-
evidence: 'stripe_checkout_session_completed',
|
|
612
|
-
installId,
|
|
613
|
-
metadata: {
|
|
614
|
-
provider: 'stripe',
|
|
615
|
-
customerId,
|
|
616
|
-
checkoutSessionId: session.id || '',
|
|
617
|
-
},
|
|
618
|
-
});
|
|
619
|
-
|
|
620
|
-
return {
|
|
621
|
-
handled: true,
|
|
622
|
-
action: 'provisioned_api_key',
|
|
623
|
-
result,
|
|
624
|
-
};
|
|
287
|
+
const installId = session.metadata?.installId;
|
|
288
|
+
const result = provisionApiKey(customerId, { installId, source: 'stripe_webhook_checkout_completed' });
|
|
289
|
+
appendFunnelEvent({ stage: 'paid', event: 'stripe_checkout_completed', installId, evidence: session.id, metadata: { customerId, subscriptionId: session.subscription } });
|
|
290
|
+
return { handled: true, action: 'provisioned_api_key', result };
|
|
625
291
|
}
|
|
626
|
-
|
|
627
292
|
case 'customer.subscription.deleted': {
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
return { handled: false, reason: 'missing_subscription_data' };
|
|
631
|
-
}
|
|
632
|
-
const customerId = subscription.customer;
|
|
633
|
-
if (!customerId) {
|
|
634
|
-
return { handled: false, reason: 'missing_customer_id' };
|
|
635
|
-
}
|
|
636
|
-
const result = disableCustomerKeys(customerId);
|
|
637
|
-
return {
|
|
638
|
-
handled: true,
|
|
639
|
-
action: 'disabled_customer_keys',
|
|
640
|
-
result,
|
|
641
|
-
};
|
|
293
|
+
const sub = event.data.object;
|
|
294
|
+
return { handled: true, action: 'disabled_customer_keys', result: disableCustomerKeys(sub.customer) };
|
|
642
295
|
}
|
|
643
|
-
|
|
644
|
-
default:
|
|
645
|
-
return { handled: false, reason: `unhandled_event_type:${event.type}` };
|
|
296
|
+
default: return { handled: false, reason: `unhandled_event_type:${event.type}` };
|
|
646
297
|
}
|
|
647
298
|
}
|
|
648
299
|
|
|
649
|
-
/**
|
|
650
|
-
* Verify a Stripe webhook signature.
|
|
651
|
-
* Returns true if valid, false if STRIPE_WEBHOOK_SECRET is not set (local mode).
|
|
652
|
-
*
|
|
653
|
-
* @param {string|Buffer} rawBody - Raw request body bytes
|
|
654
|
-
* @param {string} signature - Value of stripe-signature header
|
|
655
|
-
* @returns {boolean}
|
|
656
|
-
*/
|
|
657
|
-
function verifyWebhookSignature(rawBody, signature) {
|
|
658
|
-
if (!STRIPE_WEBHOOK_SECRET) {
|
|
659
|
-
// Local mode — skip signature verification
|
|
660
|
-
return true;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
if (!signature || !rawBody) {
|
|
664
|
-
return false;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
// Stripe signature format: t=<timestamp>,v1=<hmac>,...
|
|
668
|
-
const parts = {};
|
|
669
|
-
for (const part of signature.split(',')) {
|
|
670
|
-
const [k, v] = part.split('=');
|
|
671
|
-
if (k && v) {
|
|
672
|
-
parts[k] = v;
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
if (!parts.t || !parts.v1) {
|
|
677
|
-
return false;
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
// Timestamp tolerance: +/- 5 minutes (300 seconds)
|
|
681
|
-
const timestamp = parseInt(parts.t, 10);
|
|
682
|
-
const now = Math.floor(Date.now() / 1000);
|
|
683
|
-
if (isNaN(timestamp) || Math.abs(now - timestamp) > 300) {
|
|
684
|
-
return false;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
const payload = `${parts.t}.${typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8')}`;
|
|
688
|
-
const expected = crypto
|
|
689
|
-
.createHmac('sha256', STRIPE_WEBHOOK_SECRET)
|
|
690
|
-
.update(payload, 'utf-8')
|
|
691
|
-
.digest('hex');
|
|
692
|
-
|
|
693
|
-
try {
|
|
694
|
-
return crypto.timingSafeEqual(
|
|
695
|
-
Buffer.from(expected, 'hex'),
|
|
696
|
-
Buffer.from(parts.v1, 'hex')
|
|
697
|
-
);
|
|
698
|
-
} catch {
|
|
699
|
-
return false;
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
/**
|
|
704
|
-
* Verify a GitHub Marketplace webhook signature.
|
|
705
|
-
* Returns true if valid, false if GITHUB_MARKETPLACE_WEBHOOK_SECRET is not set (local mode).
|
|
706
|
-
*
|
|
707
|
-
* @param {string|Buffer} rawBody - Raw request body bytes
|
|
708
|
-
* @param {string} signature - Value of x-hub-signature-256 header
|
|
709
|
-
* @returns {boolean}
|
|
710
|
-
*/
|
|
711
300
|
function verifyGithubWebhookSignature(rawBody, signature) {
|
|
712
|
-
if (!GITHUB_MARKETPLACE_WEBHOOK_SECRET)
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
if (!signature || !rawBody) {
|
|
718
|
-
return false;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
const hmac = crypto.createHmac('sha256', GITHUB_MARKETPLACE_WEBHOOK_SECRET);
|
|
722
|
-
const digest = Buffer.from(`sha256=${hmac.update(rawBody).digest('hex')}`, 'utf8');
|
|
301
|
+
if (!CONFIG.GITHUB_MARKETPLACE_WEBHOOK_SECRET) return true;
|
|
302
|
+
if (!signature || !rawBody) return false;
|
|
303
|
+
const expected = crypto.createHmac('sha256', CONFIG.GITHUB_MARKETPLACE_WEBHOOK_SECRET).update(rawBody).digest('hex');
|
|
304
|
+
const digest = Buffer.from(`sha256=${expected}`, 'utf8');
|
|
723
305
|
const checksum = Buffer.from(signature, 'utf8');
|
|
724
|
-
|
|
725
306
|
return checksum.length === digest.length && crypto.timingSafeEqual(digest, checksum);
|
|
726
307
|
}
|
|
727
308
|
|
|
728
|
-
/**
|
|
729
|
-
* Handle a GitHub Marketplace webhook event.
|
|
730
|
-
*
|
|
731
|
-
* Supported actions:
|
|
732
|
-
* purchased — provision API key for the new customer
|
|
733
|
-
* changed — plan update (upgrade/downgrade)
|
|
734
|
-
* cancelled — disable all keys for that customer
|
|
735
|
-
*
|
|
736
|
-
* @param {object} event - Parsed GitHub Marketplace event object
|
|
737
|
-
* @returns {{ handled: boolean, action?: string, result?: object }}
|
|
738
|
-
*/
|
|
739
309
|
function handleGithubWebhook(event) {
|
|
740
|
-
if (!event
|
|
741
|
-
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
const { action, marketplace_purchase: marketplacePurchase } = event;
|
|
745
|
-
if (!action || !marketplacePurchase) {
|
|
746
|
-
return { handled: false, reason: 'missing_payload_data' };
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
const account = marketplacePurchase.account;
|
|
750
|
-
if (!account || !account.id || !account.type) {
|
|
751
|
-
return { handled: false, reason: 'missing_account_id' };
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
// Map GitHub account to customerId: github_<user|organization>_<id>
|
|
755
|
-
const customerId = `github_${String(account.type).toLowerCase()}_${account.id}`;
|
|
756
|
-
|
|
310
|
+
if (!event) return { handled: false, reason: 'missing_payload_data' };
|
|
311
|
+
const { action, marketplace_purchase: mp } = event;
|
|
312
|
+
if (!action || !mp || !mp.account?.id) return { handled: false, reason: 'missing_payload_data' };
|
|
313
|
+
const customerId = `github_${String(mp.account.type).toLowerCase()}_${mp.account.id}`;
|
|
757
314
|
switch (action) {
|
|
758
315
|
case 'purchased': {
|
|
759
316
|
const result = provisionApiKey(customerId, { source: 'github_marketplace_purchased' });
|
|
760
|
-
appendFunnelEvent({
|
|
761
|
-
|
|
762
|
-
event: 'github_marketplace_purchased',
|
|
763
|
-
evidence: 'github_marketplace_purchased',
|
|
764
|
-
metadata: {
|
|
765
|
-
provider: 'github',
|
|
766
|
-
customerId,
|
|
767
|
-
accountId: String(account.id),
|
|
768
|
-
accountType: String(account.type),
|
|
769
|
-
},
|
|
770
|
-
});
|
|
771
|
-
return {
|
|
772
|
-
handled: true,
|
|
773
|
-
action: 'provisioned_api_key',
|
|
774
|
-
result,
|
|
775
|
-
};
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
case 'cancelled': {
|
|
779
|
-
const result = disableCustomerKeys(customerId);
|
|
780
|
-
return {
|
|
781
|
-
handled: true,
|
|
782
|
-
action: 'disabled_customer_keys',
|
|
783
|
-
result,
|
|
784
|
-
};
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
case 'changed': {
|
|
788
|
-
// Keep API access active on plan changes.
|
|
789
|
-
const result = provisionApiKey(customerId, { source: 'github_marketplace_changed' });
|
|
790
|
-
return {
|
|
791
|
-
handled: true,
|
|
792
|
-
action: 'plan_changed',
|
|
793
|
-
result,
|
|
794
|
-
};
|
|
317
|
+
appendFunnelEvent({ stage: 'paid', event: 'github_marketplace_purchased', evidence: 'github_marketplace_purchased', metadata: { provider: 'github', customerId, accountId: String(mp.account.id), accountType: String(mp.account.type) } });
|
|
318
|
+
return { handled: true, action: 'provisioned_api_key', result };
|
|
795
319
|
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
320
|
+
case 'cancelled': return { handled: true, action: 'disabled_customer_keys', result: disableCustomerKeys(customerId) };
|
|
321
|
+
case 'changed': return { handled: true, action: 'plan_changed', result: provisionApiKey(customerId, { source: 'github_marketplace_changed' }) };
|
|
322
|
+
default: return { handled: false, reason: `unhandled_action:${action}` };
|
|
799
323
|
}
|
|
800
324
|
}
|
|
801
325
|
|
|
802
|
-
// ---------------------------------------------------------------------------
|
|
803
|
-
// Module exports
|
|
804
|
-
// ---------------------------------------------------------------------------
|
|
805
|
-
|
|
806
326
|
module.exports = {
|
|
807
|
-
createCheckoutSession,
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
handleWebhook,
|
|
813
|
-
verifyWebhookSignature,
|
|
814
|
-
verifyGithubWebhookSignature,
|
|
815
|
-
handleGithubWebhook,
|
|
816
|
-
loadKeyStore,
|
|
817
|
-
appendFunnelEvent,
|
|
818
|
-
loadFunnelLedger,
|
|
819
|
-
getFunnelAnalytics,
|
|
820
|
-
readInstallIdFromConfig,
|
|
821
|
-
// Expose for testing
|
|
822
|
-
_API_KEYS_PATH: API_KEYS_PATH,
|
|
823
|
-
_FUNNEL_LEDGER_PATH: FUNNEL_LEDGER_PATH,
|
|
824
|
-
_LOCAL_MODE: () => LOCAL_MODE,
|
|
327
|
+
createCheckoutSession, getCheckoutSessionStatus, provisionApiKey, rotateApiKey, validateApiKey, recordUsage, reportUsageToStripe, disableCustomerKeys, handleWebhook, verifyGithubWebhookSignature, handleGithubWebhook, loadKeyStore, appendFunnelEvent, loadFunnelLedger, getFunnelAnalytics,
|
|
328
|
+
_API_KEYS_PATH: () => CONFIG.API_KEYS_PATH,
|
|
329
|
+
_FUNNEL_LEDGER_PATH: () => CONFIG.FUNNEL_LEDGER_PATH,
|
|
330
|
+
_LOCAL_CHECKOUT_SESSIONS_PATH: () => CONFIG.LOCAL_CHECKOUT_SESSIONS_PATH,
|
|
331
|
+
_LOCAL_MODE: () => LOCAL_MODE(),
|
|
825
332
|
};
|