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.
- package/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +308 -0
- package/adapters/README.md +8 -0
- package/adapters/amp/skills/rlhf-feedback/SKILL.md +20 -0
- package/adapters/chatgpt/INSTALL.md +80 -0
- package/adapters/chatgpt/openapi.yaml +292 -0
- package/adapters/claude/.mcp.json +8 -0
- package/adapters/codex/config.toml +4 -0
- package/adapters/gemini/function-declarations.json +95 -0
- package/adapters/mcp/server-stdio.js +444 -0
- package/bin/cli.js +167 -0
- package/config/mcp-allowlists.json +29 -0
- package/config/policy-bundles/constrained-v1.json +53 -0
- package/config/policy-bundles/default-v1.json +80 -0
- package/config/rubrics/default-v1.json +52 -0
- package/config/subagent-profiles.json +32 -0
- package/openapi/openapi.yaml +292 -0
- package/package.json +91 -0
- package/plugins/amp-skill/INSTALL.md +52 -0
- package/plugins/amp-skill/SKILL.md +31 -0
- package/plugins/claude-skill/INSTALL.md +55 -0
- package/plugins/claude-skill/SKILL.md +46 -0
- package/plugins/codex-profile/AGENTS.md +20 -0
- package/plugins/codex-profile/INSTALL.md +57 -0
- package/plugins/gemini-extension/INSTALL.md +74 -0
- package/plugins/gemini-extension/gemini_prompt.txt +10 -0
- package/plugins/gemini-extension/tool_contract.json +28 -0
- package/scripts/billing.js +471 -0
- package/scripts/budget-guard.js +173 -0
- package/scripts/code-reasoning.js +307 -0
- package/scripts/context-engine.js +547 -0
- package/scripts/contextfs.js +513 -0
- package/scripts/contract-audit.js +198 -0
- package/scripts/dpo-optimizer.js +208 -0
- package/scripts/export-dpo-pairs.js +316 -0
- package/scripts/export-training.js +448 -0
- package/scripts/feedback-attribution.js +313 -0
- package/scripts/feedback-inbox-read.js +162 -0
- package/scripts/feedback-loop.js +838 -0
- package/scripts/feedback-schema.js +300 -0
- package/scripts/feedback-to-memory.js +165 -0
- package/scripts/feedback-to-rules.js +109 -0
- package/scripts/generate-paperbanana-diagrams.sh +99 -0
- package/scripts/hybrid-feedback-context.js +676 -0
- package/scripts/intent-router.js +164 -0
- package/scripts/mcp-policy.js +92 -0
- package/scripts/meta-policy.js +194 -0
- package/scripts/plan-gate.js +154 -0
- package/scripts/prove-adapters.js +364 -0
- package/scripts/prove-attribution.js +364 -0
- package/scripts/prove-automation.js +393 -0
- package/scripts/prove-data-quality.js +219 -0
- package/scripts/prove-intelligence.js +256 -0
- package/scripts/prove-lancedb.js +370 -0
- package/scripts/prove-loop-closure.js +255 -0
- package/scripts/prove-rlaif.js +404 -0
- package/scripts/prove-subway-upgrades.js +250 -0
- package/scripts/prove-training-export.js +324 -0
- package/scripts/prove-v2-milestone.js +273 -0
- package/scripts/prove-v3-milestone.js +381 -0
- package/scripts/rlaif-self-audit.js +123 -0
- package/scripts/rubric-engine.js +230 -0
- package/scripts/self-heal.js +127 -0
- package/scripts/self-healing-check.js +111 -0
- package/scripts/skill-quality-tracker.js +284 -0
- package/scripts/subagent-profiles.js +79 -0
- package/scripts/sync-gh-secrets-from-env.sh +29 -0
- package/scripts/thompson-sampling.js +331 -0
- package/scripts/train_from_feedback.py +914 -0
- package/scripts/validate-feedback.js +580 -0
- package/scripts/vector-store.js +100 -0
- 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
|
+
};
|