rlhf-feedback-loop 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +21 -0
  3. package/README.md +308 -0
  4. package/adapters/README.md +8 -0
  5. package/adapters/amp/skills/rlhf-feedback/SKILL.md +20 -0
  6. package/adapters/chatgpt/INSTALL.md +80 -0
  7. package/adapters/chatgpt/openapi.yaml +292 -0
  8. package/adapters/claude/.mcp.json +8 -0
  9. package/adapters/codex/config.toml +4 -0
  10. package/adapters/gemini/function-declarations.json +95 -0
  11. package/adapters/mcp/server-stdio.js +444 -0
  12. package/bin/cli.js +167 -0
  13. package/config/mcp-allowlists.json +29 -0
  14. package/config/policy-bundles/constrained-v1.json +53 -0
  15. package/config/policy-bundles/default-v1.json +80 -0
  16. package/config/rubrics/default-v1.json +52 -0
  17. package/config/subagent-profiles.json +32 -0
  18. package/openapi/openapi.yaml +292 -0
  19. package/package.json +91 -0
  20. package/plugins/amp-skill/INSTALL.md +52 -0
  21. package/plugins/amp-skill/SKILL.md +31 -0
  22. package/plugins/claude-skill/INSTALL.md +55 -0
  23. package/plugins/claude-skill/SKILL.md +46 -0
  24. package/plugins/codex-profile/AGENTS.md +20 -0
  25. package/plugins/codex-profile/INSTALL.md +57 -0
  26. package/plugins/gemini-extension/INSTALL.md +74 -0
  27. package/plugins/gemini-extension/gemini_prompt.txt +10 -0
  28. package/plugins/gemini-extension/tool_contract.json +28 -0
  29. package/scripts/billing.js +471 -0
  30. package/scripts/budget-guard.js +173 -0
  31. package/scripts/code-reasoning.js +307 -0
  32. package/scripts/context-engine.js +547 -0
  33. package/scripts/contextfs.js +513 -0
  34. package/scripts/contract-audit.js +198 -0
  35. package/scripts/dpo-optimizer.js +208 -0
  36. package/scripts/export-dpo-pairs.js +316 -0
  37. package/scripts/export-training.js +448 -0
  38. package/scripts/feedback-attribution.js +313 -0
  39. package/scripts/feedback-inbox-read.js +162 -0
  40. package/scripts/feedback-loop.js +838 -0
  41. package/scripts/feedback-schema.js +300 -0
  42. package/scripts/feedback-to-memory.js +165 -0
  43. package/scripts/feedback-to-rules.js +109 -0
  44. package/scripts/generate-paperbanana-diagrams.sh +99 -0
  45. package/scripts/hybrid-feedback-context.js +676 -0
  46. package/scripts/intent-router.js +164 -0
  47. package/scripts/mcp-policy.js +92 -0
  48. package/scripts/meta-policy.js +194 -0
  49. package/scripts/plan-gate.js +154 -0
  50. package/scripts/prove-adapters.js +364 -0
  51. package/scripts/prove-attribution.js +364 -0
  52. package/scripts/prove-automation.js +393 -0
  53. package/scripts/prove-data-quality.js +219 -0
  54. package/scripts/prove-intelligence.js +256 -0
  55. package/scripts/prove-lancedb.js +370 -0
  56. package/scripts/prove-loop-closure.js +255 -0
  57. package/scripts/prove-rlaif.js +404 -0
  58. package/scripts/prove-subway-upgrades.js +250 -0
  59. package/scripts/prove-training-export.js +324 -0
  60. package/scripts/prove-v2-milestone.js +273 -0
  61. package/scripts/prove-v3-milestone.js +381 -0
  62. package/scripts/rlaif-self-audit.js +123 -0
  63. package/scripts/rubric-engine.js +230 -0
  64. package/scripts/self-heal.js +127 -0
  65. package/scripts/self-healing-check.js +111 -0
  66. package/scripts/skill-quality-tracker.js +284 -0
  67. package/scripts/subagent-profiles.js +79 -0
  68. package/scripts/sync-gh-secrets-from-env.sh +29 -0
  69. package/scripts/thompson-sampling.js +331 -0
  70. package/scripts/train_from_feedback.py +914 -0
  71. package/scripts/validate-feedback.js +580 -0
  72. package/scripts/vector-store.js +100 -0
  73. package/src/api/server.js +497 -0
@@ -0,0 +1,46 @@
1
+ ---
2
+ name: rlhf-feedback
3
+ description: >
4
+ Capture thumbs up/down feedback into structured memories and prevention rules.
5
+ Use when user gives explicit quality signals about agent work (e.g. "that worked",
6
+ "that failed", "thumbs up/down"). Do NOT use for general questions, code generation,
7
+ file operations, or any task that is not explicit feedback on prior agent output.
8
+ triggers:
9
+ - thumbs up
10
+ - thumbs down
11
+ - that worked
12
+ - that failed
13
+ negative_triggers:
14
+ - generate code
15
+ - search files
16
+ - explain this
17
+ - run tests
18
+ ---
19
+
20
+ # RLHF Feedback Skill
21
+
22
+ When user provides feedback, execute:
23
+
24
+ ```bash
25
+ # negative
26
+ node .claude/scripts/feedback/capture-feedback.js \
27
+ --feedback=down \
28
+ --context="<what failed>" \
29
+ --what-went-wrong="<specific failure>" \
30
+ --what-to-change="<prevention action>" \
31
+ --tags="<domain>,regression"
32
+
33
+ # positive
34
+ node .claude/scripts/feedback/capture-feedback.js \
35
+ --feedback=up \
36
+ --context="<what succeeded>" \
37
+ --what-worked="<repeatable pattern>" \
38
+ --tags="<domain>,fix"
39
+ ```
40
+
41
+ At session start, run:
42
+
43
+ ```bash
44
+ npm run feedback:summary
45
+ npm run feedback:rules
46
+ ```
@@ -0,0 +1,20 @@
1
+ # Codex RLHF Add-on
2
+
3
+ ## Trigger
4
+ If user gives explicit positive/negative outcome feedback, capture it immediately.
5
+
6
+ ## Commands
7
+
8
+ ```bash
9
+ node .claude/scripts/feedback/capture-feedback.js --feedback=up --context="..." --tags="..."
10
+ node .claude/scripts/feedback/capture-feedback.js --feedback=down --context="..." --tags="..."
11
+ ```
12
+
13
+ ## Session Start
14
+
15
+ ```bash
16
+ npm run feedback:summary
17
+ npm run feedback:rules
18
+ ```
19
+
20
+ Use generated rules as hard guardrails to avoid repeated mistakes.
@@ -0,0 +1,57 @@
1
+ # Codex: RLHF MCP Plugin Install
2
+
3
+ Install the MCP plugin in under 60 seconds. Copy-paste the config block — no manual editing required.
4
+
5
+ ## One-Command Install
6
+
7
+ Add the MCP server block to your Codex config:
8
+
9
+ ```bash
10
+ cat adapters/codex/config.toml >> ~/.codex/config.toml
11
+ ```
12
+
13
+ Or create the config file if it does not exist:
14
+
15
+ ```bash
16
+ mkdir -p ~/.codex
17
+ cat adapters/codex/config.toml >> ~/.codex/config.toml
18
+ ```
19
+
20
+ ## What Gets Added
21
+
22
+ The following block is appended to `~/.codex/config.toml`:
23
+
24
+ ```toml
25
+ [mcp_servers.rlhf_feedback_loop]
26
+ command = "node"
27
+ args = ["adapters/mcp/server-stdio.js"]
28
+ ```
29
+
30
+ ## Verify
31
+
32
+ Start the MCP server manually to confirm it runs:
33
+
34
+ ```bash
35
+ node adapters/mcp/server-stdio.js
36
+ # Expected: MCP server listening on stdio
37
+ # Press Ctrl+C to stop
38
+ ```
39
+
40
+ Then restart Codex. The `rlhf_feedback_loop` MCP server will appear in the tool list.
41
+
42
+ ## Available Tools (via MCP)
43
+
44
+ - `capture_feedback` — POST `/v1/feedback/capture`
45
+ - `feedback_summary` — GET `/v1/feedback/summary`
46
+ - `prevention_rules` — POST `/v1/feedback/rules`
47
+ - `plan_intent` — POST `/v1/intents/plan`
48
+
49
+ ## Requirements
50
+
51
+ - Codex with MCP support
52
+ - Node.js 18+ in PATH
53
+ - Config file at `~/.codex/config.toml`
54
+
55
+ ## Uninstall
56
+
57
+ Remove the `[mcp_servers.rlhf_feedback_loop]` section from `~/.codex/config.toml`.
@@ -0,0 +1,74 @@
1
+ # Gemini: RLHF Function Declarations Install
2
+
3
+ Import the RLHF function declarations into your Gemini agent in under 60 seconds.
4
+
5
+ ## One-Command Install (Node.js)
6
+
7
+ ```bash
8
+ # Copy declarations to your project
9
+ cp adapters/gemini/function-declarations.json .gemini/rlhf-tools.json
10
+ ```
11
+
12
+ ## Import in Your Agent Code
13
+
14
+ ```javascript
15
+ const fs = require('fs');
16
+
17
+ // Load RLHF tool declarations
18
+ const rlhfTools = JSON.parse(
19
+ fs.readFileSync('adapters/gemini/function-declarations.json', 'utf8')
20
+ );
21
+
22
+ // Pass to Gemini SDK
23
+ const model = genAI.getGenerativeModel({
24
+ model: 'gemini-pro',
25
+ tools: [{ functionDeclarations: rlhfTools.tools }],
26
+ });
27
+ ```
28
+
29
+ ## Available Functions
30
+
31
+ | Function | Description |
32
+ |---|---|
33
+ | `capture_feedback` | Capture thumbs-up/down with context — POST `/v1/feedback/capture` |
34
+ | `feedback_summary` | Compact summary of recent feedback — GET `/v1/feedback/summary` |
35
+ | `prevention_rules` | Generate prevention rules from mistakes — POST `/v1/feedback/rules` |
36
+ | `plan_intent` | Policy-aware execution plan — POST `/v1/intents/plan` |
37
+
38
+ ## Point to Your API
39
+
40
+ Set the base URL in your Gemini function handler:
41
+
42
+ ```javascript
43
+ const RLHF_API_URL = process.env.RLHF_API_URL || 'http://localhost:3000';
44
+ const RLHF_API_KEY = process.env.RLHF_API_KEY;
45
+
46
+ async function callRlhfTool(name, params) {
47
+ const endpoints = {
48
+ capture_feedback: { method: 'POST', path: '/v1/feedback/capture' },
49
+ feedback_summary: { method: 'GET', path: '/v1/feedback/summary' },
50
+ prevention_rules: { method: 'POST', path: '/v1/feedback/rules' },
51
+ plan_intent: { method: 'POST', path: '/v1/intents/plan' },
52
+ };
53
+ const { method, path } = endpoints[name];
54
+ const res = await fetch(`${RLHF_API_URL}${path}`, {
55
+ method,
56
+ headers: { Authorization: `Bearer ${RLHF_API_KEY}`, 'Content-Type': 'application/json' },
57
+ body: method === 'POST' ? JSON.stringify(params) : undefined,
58
+ });
59
+ return res.json();
60
+ }
61
+ ```
62
+
63
+ ## Requirements
64
+
65
+ - Google Gemini SDK (`@google/generative-ai`)
66
+ - Node.js 18+ or Python 3.9+
67
+ - RLHF API running (local or hosted)
68
+
69
+ ## Verify
70
+
71
+ ```bash
72
+ node -e "const t = require('./adapters/gemini/function-declarations.json'); console.log('Tools:', t.tools.map(x=>x.name))"
73
+ # Expected: Tools: [ 'capture_feedback', 'feedback_summary', 'prevention_rules', 'plan_intent' ]
74
+ ```
@@ -0,0 +1,10 @@
1
+ You have access to an RLHF feedback tool.
2
+
3
+ When user gives explicit success/failure feedback:
4
+ - call `capture_feedback` with signal=up/down and rich context
5
+ - include tags for domain and action type
6
+ - reject bare thumbs down/up with no context
7
+
8
+ Before major work:
9
+ - call `feedback_summary`
10
+ - apply `prevention_rules` as constraints
@@ -0,0 +1,28 @@
1
+ {
2
+ "tools": [
3
+ {
4
+ "name": "capture_feedback",
5
+ "description": "Capture thumbs feedback and store actionable memory",
6
+ "input_schema": {
7
+ "type": "object",
8
+ "properties": {
9
+ "signal": { "type": "string", "enum": ["up", "down"] },
10
+ "context": { "type": "string" },
11
+ "what_went_wrong": { "type": "string" },
12
+ "what_to_change": { "type": "string" },
13
+ "what_worked": { "type": "string" },
14
+ "tags": { "type": "array", "items": { "type": "string" } }
15
+ },
16
+ "required": ["signal", "context"]
17
+ }
18
+ },
19
+ {
20
+ "name": "feedback_summary",
21
+ "description": "Return compact summary of recent feedback and trends"
22
+ },
23
+ {
24
+ "name": "prevention_rules",
25
+ "description": "Generate current prevention rules from repeated mistakes"
26
+ }
27
+ ]
28
+ }
@@ -0,0 +1,471 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * billing.js — Stripe billing integration using raw fetch (no stripe npm package).
4
+ *
5
+ * Functions:
6
+ * createCheckoutSession() — Creates Stripe Checkout session for $49/mo 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)
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const crypto = require('crypto');
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Config
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || '';
27
+ const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || '';
28
+ const STRIPE_PRICE_ID = process.env.STRIPE_PRICE_ID || 'price_cloud_pro_49_monthly';
29
+ const API_KEYS_PATH = path.resolve(
30
+ __dirname,
31
+ '../.claude/memory/feedback/api-keys.json'
32
+ );
33
+
34
+ const LOCAL_MODE = !STRIPE_SECRET_KEY;
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Key store helpers
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Load the API key store from disk.
42
+ * Returns { keys: { [key]: { customerId, active, usageCount, createdAt } } }
43
+ */
44
+ function loadKeyStore() {
45
+ try {
46
+ if (!fs.existsSync(API_KEYS_PATH)) {
47
+ return { keys: {} };
48
+ }
49
+ const raw = fs.readFileSync(API_KEYS_PATH, 'utf-8');
50
+ const parsed = JSON.parse(raw);
51
+ if (!parsed || typeof parsed.keys !== 'object') {
52
+ return { keys: {} };
53
+ }
54
+ return parsed;
55
+ } catch {
56
+ return { keys: {} };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Persist the key store to disk. Creates parent directory if needed.
62
+ */
63
+ function saveKeyStore(store) {
64
+ const dir = path.dirname(API_KEYS_PATH);
65
+ if (!fs.existsSync(dir)) {
66
+ fs.mkdirSync(dir, { recursive: true });
67
+ }
68
+ fs.writeFileSync(API_KEYS_PATH, JSON.stringify(store, null, 2), 'utf-8');
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Stripe REST API helper
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Call the Stripe REST API using built-in fetch (Node 18+) or https module.
77
+ * Returns parsed JSON response.
78
+ * Throws on non-2xx responses with the Stripe error message.
79
+ */
80
+ async function stripeRequest(method, endpoint, params = {}) {
81
+ if (LOCAL_MODE) {
82
+ throw new Error('STRIPE_SECRET_KEY not configured — local mode active');
83
+ }
84
+
85
+ const url = `https://api.stripe.com/v1${endpoint}`;
86
+
87
+ // Stripe uses x-www-form-urlencoded for POST/DELETE bodies
88
+ const body = method !== 'GET' && Object.keys(params).length > 0
89
+ ? new URLSearchParams(flattenParams(params)).toString()
90
+ : undefined;
91
+
92
+ // For GET requests, add params as query string
93
+ const fullUrl = method === 'GET' && Object.keys(params).length > 0
94
+ ? `${url}?${new URLSearchParams(flattenParams(params)).toString()}`
95
+ : url;
96
+
97
+ const headers = {
98
+ 'Authorization': `Bearer ${STRIPE_SECRET_KEY}`,
99
+ 'Content-Type': 'application/x-www-form-urlencoded',
100
+ 'Stripe-Version': '2023-10-16',
101
+ };
102
+
103
+ // Use fetch if available (Node 18+), otherwise fall back to https module
104
+ if (typeof fetch !== 'undefined') {
105
+ const response = await fetch(fullUrl, {
106
+ method,
107
+ headers,
108
+ body,
109
+ });
110
+ const json = await response.json();
111
+ if (!response.ok) {
112
+ const msg = (json.error && json.error.message) || `Stripe error ${response.status}`;
113
+ const err = new Error(msg);
114
+ err.stripeError = json.error;
115
+ err.statusCode = response.status;
116
+ throw err;
117
+ }
118
+ return json;
119
+ }
120
+
121
+ // Node <18 fallback via https module
122
+ return new Promise((resolve, reject) => {
123
+ const https = require('https');
124
+ const urlObj = new URL(fullUrl);
125
+ const options = {
126
+ hostname: urlObj.hostname,
127
+ path: urlObj.pathname + urlObj.search,
128
+ method,
129
+ headers: { ...headers },
130
+ };
131
+ if (body) {
132
+ options.headers['Content-Length'] = Buffer.byteLength(body);
133
+ }
134
+ const req = https.request(options, (res) => {
135
+ const chunks = [];
136
+ res.on('data', (c) => chunks.push(c));
137
+ res.on('end', () => {
138
+ try {
139
+ const json = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
140
+ if (res.statusCode >= 400) {
141
+ const msg = (json.error && json.error.message) || `Stripe error ${res.statusCode}`;
142
+ const err = new Error(msg);
143
+ err.stripeError = json.error;
144
+ err.statusCode = res.statusCode;
145
+ reject(err);
146
+ } else {
147
+ resolve(json);
148
+ }
149
+ } catch (e) {
150
+ reject(e);
151
+ }
152
+ });
153
+ });
154
+ req.on('error', reject);
155
+ if (body) req.write(body);
156
+ req.end();
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Flatten nested objects into Stripe's dot-notation format.
162
+ * e.g. { line_items: [{ price: 'p_123', quantity: 1 }] }
163
+ * => { 'line_items[0][price]': 'p_123', 'line_items[0][quantity]': '1' }
164
+ */
165
+ function flattenParams(obj, prefix = '') {
166
+ const result = {};
167
+ for (const [k, v] of Object.entries(obj)) {
168
+ const key = prefix ? `${prefix}[${k}]` : k;
169
+ if (Array.isArray(v)) {
170
+ v.forEach((item, i) => {
171
+ if (item !== null && typeof item === 'object') {
172
+ Object.assign(result, flattenParams(item, `${key}[${i}]`));
173
+ } else {
174
+ result[`${key}[${i}]`] = String(item);
175
+ }
176
+ });
177
+ } else if (v !== null && typeof v === 'object') {
178
+ Object.assign(result, flattenParams(v, key));
179
+ } else if (v !== undefined && v !== null) {
180
+ result[key] = String(v);
181
+ }
182
+ }
183
+ return result;
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Public API
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /**
191
+ * Create a Stripe Checkout Session for $49/mo Cloud Pro.
192
+ *
193
+ * @param {object} opts
194
+ * @param {string} opts.successUrl - Redirect URL on payment success
195
+ * @param {string} opts.cancelUrl - Redirect URL on cancel
196
+ * @param {string} [opts.customerEmail] - Pre-fill customer email
197
+ * @returns {Promise<{sessionId: string, url: string}>} in live mode
198
+ * or {sessionId: 'local_<uuid>', url: null} in local mode
199
+ */
200
+ async function createCheckoutSession({ successUrl, cancelUrl, customerEmail } = {}) {
201
+ if (LOCAL_MODE) {
202
+ return {
203
+ sessionId: `local_${crypto.randomUUID()}`,
204
+ url: null,
205
+ localMode: true,
206
+ };
207
+ }
208
+
209
+ const params = {
210
+ mode: 'subscription',
211
+ line_items: [
212
+ { price: STRIPE_PRICE_ID, quantity: 1 },
213
+ ],
214
+ success_url: successUrl || 'https://example.com/success?session_id={CHECKOUT_SESSION_ID}',
215
+ cancel_url: cancelUrl || 'https://example.com/cancel',
216
+ };
217
+
218
+ if (customerEmail) {
219
+ params.customer_email = customerEmail;
220
+ }
221
+
222
+ const session = await stripeRequest('POST', '/checkout/sessions', params);
223
+ return {
224
+ sessionId: session.id,
225
+ url: session.url,
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Provision a unique API key for a Stripe customer.
231
+ * Stores { customerId, active: true, usageCount: 0, createdAt } in api-keys.json.
232
+ *
233
+ * @param {string} customerId - Stripe customer ID (e.g. cus_xxx)
234
+ * @returns {{ key: string, customerId: string, createdAt: string }}
235
+ */
236
+ function provisionApiKey(customerId) {
237
+ if (!customerId || typeof customerId !== 'string') {
238
+ throw new Error('customerId is required');
239
+ }
240
+
241
+ const store = loadKeyStore();
242
+
243
+ // Check if this customer already has an active key — reuse it
244
+ const existing = Object.entries(store.keys).find(
245
+ ([, meta]) => meta.customerId === customerId && meta.active
246
+ );
247
+ if (existing) {
248
+ return {
249
+ key: existing[0],
250
+ customerId,
251
+ createdAt: existing[1].createdAt,
252
+ reused: true,
253
+ };
254
+ }
255
+
256
+ // Generate cryptographically random key: rlhf_<32 hex chars>
257
+ const key = `rlhf_${crypto.randomBytes(16).toString('hex')}`;
258
+ const createdAt = new Date().toISOString();
259
+
260
+ store.keys[key] = {
261
+ customerId,
262
+ active: true,
263
+ usageCount: 0,
264
+ createdAt,
265
+ };
266
+
267
+ saveKeyStore(store);
268
+
269
+ return { key, customerId, createdAt };
270
+ }
271
+
272
+ /**
273
+ * Validate an API key.
274
+ *
275
+ * @param {string} key - API key to validate
276
+ * @returns {{ valid: boolean, customerId?: string, usageCount?: number }}
277
+ */
278
+ function validateApiKey(key) {
279
+ if (!key || typeof key !== 'string') {
280
+ return { valid: false };
281
+ }
282
+
283
+ const store = loadKeyStore();
284
+ const meta = store.keys[key];
285
+
286
+ if (!meta) {
287
+ return { valid: false };
288
+ }
289
+
290
+ if (!meta.active) {
291
+ return { valid: false, reason: 'key_disabled' };
292
+ }
293
+
294
+ return {
295
+ valid: true,
296
+ customerId: meta.customerId,
297
+ usageCount: meta.usageCount,
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Record one usage event for an API key.
303
+ * Increments usageCount in the key store.
304
+ *
305
+ * @param {string} key - API key to record usage for
306
+ * @returns {{ recorded: boolean, usageCount?: number }}
307
+ */
308
+ function recordUsage(key) {
309
+ if (!key || typeof key !== 'string') {
310
+ return { recorded: false };
311
+ }
312
+
313
+ const store = loadKeyStore();
314
+ const meta = store.keys[key];
315
+
316
+ if (!meta || !meta.active) {
317
+ return { recorded: false };
318
+ }
319
+
320
+ meta.usageCount = (meta.usageCount || 0) + 1;
321
+ saveKeyStore(store);
322
+
323
+ return { recorded: true, usageCount: meta.usageCount };
324
+ }
325
+
326
+ /**
327
+ * Disable all API keys for a customer (called on subscription cancellation).
328
+ *
329
+ * @param {string} customerId - Stripe customer ID
330
+ * @returns {{ disabledCount: number }}
331
+ */
332
+ function disableCustomerKeys(customerId) {
333
+ if (!customerId || typeof customerId !== 'string') {
334
+ return { disabledCount: 0 };
335
+ }
336
+
337
+ const store = loadKeyStore();
338
+ let disabledCount = 0;
339
+
340
+ for (const meta of Object.values(store.keys)) {
341
+ if (meta.customerId === customerId && meta.active) {
342
+ meta.active = false;
343
+ disabledCount++;
344
+ }
345
+ }
346
+
347
+ if (disabledCount > 0) {
348
+ saveKeyStore(store);
349
+ }
350
+
351
+ return { disabledCount };
352
+ }
353
+
354
+ /**
355
+ * Handle a Stripe webhook event.
356
+ *
357
+ * Supported events:
358
+ * checkout.session.completed — provision API key for the new customer
359
+ * customer.subscription.deleted — disable all keys for that customer
360
+ *
361
+ * @param {object} event - Parsed Stripe event object
362
+ * @returns {{ handled: boolean, action?: string, result?: object }}
363
+ */
364
+ function handleWebhook(event) {
365
+ if (!event || !event.type) {
366
+ return { handled: false, reason: 'missing_event_type' };
367
+ }
368
+
369
+ switch (event.type) {
370
+ case 'checkout.session.completed': {
371
+ const session = event.data && event.data.object;
372
+ if (!session) {
373
+ return { handled: false, reason: 'missing_session_data' };
374
+ }
375
+ const customerId = session.customer;
376
+ if (!customerId) {
377
+ return { handled: false, reason: 'missing_customer_id' };
378
+ }
379
+ const result = provisionApiKey(customerId);
380
+ return {
381
+ handled: true,
382
+ action: 'provisioned_api_key',
383
+ result,
384
+ };
385
+ }
386
+
387
+ case 'customer.subscription.deleted': {
388
+ const subscription = event.data && event.data.object;
389
+ if (!subscription) {
390
+ return { handled: false, reason: 'missing_subscription_data' };
391
+ }
392
+ const customerId = subscription.customer;
393
+ if (!customerId) {
394
+ return { handled: false, reason: 'missing_customer_id' };
395
+ }
396
+ const result = disableCustomerKeys(customerId);
397
+ return {
398
+ handled: true,
399
+ action: 'disabled_customer_keys',
400
+ result,
401
+ };
402
+ }
403
+
404
+ default:
405
+ return { handled: false, reason: `unhandled_event_type:${event.type}` };
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Verify a Stripe webhook signature.
411
+ * Returns true if valid, false if STRIPE_WEBHOOK_SECRET is not set (local mode).
412
+ *
413
+ * @param {string|Buffer} rawBody - Raw request body bytes
414
+ * @param {string} signature - Value of stripe-signature header
415
+ * @returns {boolean}
416
+ */
417
+ function verifyWebhookSignature(rawBody, signature) {
418
+ if (!STRIPE_WEBHOOK_SECRET) {
419
+ // Local mode — skip signature verification
420
+ return true;
421
+ }
422
+
423
+ if (!signature || !rawBody) {
424
+ return false;
425
+ }
426
+
427
+ // Stripe signature format: t=<timestamp>,v1=<hmac>,...
428
+ const parts = {};
429
+ for (const part of signature.split(',')) {
430
+ const [k, v] = part.split('=');
431
+ if (k && v) parts[k] = v;
432
+ }
433
+
434
+ if (!parts.t || !parts.v1) {
435
+ return false;
436
+ }
437
+
438
+ const payload = `${parts.t}.${typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8')}`;
439
+ const expected = crypto
440
+ .createHmac('sha256', STRIPE_WEBHOOK_SECRET)
441
+ .update(payload, 'utf-8')
442
+ .digest('hex');
443
+
444
+ // Constant-time comparison
445
+ try {
446
+ return crypto.timingSafeEqual(
447
+ Buffer.from(expected, 'hex'),
448
+ Buffer.from(parts.v1, 'hex')
449
+ );
450
+ } catch {
451
+ return false;
452
+ }
453
+ }
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // Module exports
457
+ // ---------------------------------------------------------------------------
458
+
459
+ module.exports = {
460
+ createCheckoutSession,
461
+ provisionApiKey,
462
+ validateApiKey,
463
+ recordUsage,
464
+ disableCustomerKeys,
465
+ handleWebhook,
466
+ verifyWebhookSignature,
467
+ loadKeyStore,
468
+ // Expose for testing
469
+ _API_KEYS_PATH: API_KEYS_PATH,
470
+ _LOCAL_MODE: () => LOCAL_MODE,
471
+ };